refactor(ui): move guardrails text onto i18n (#49524)

Moves the text the /guardrails page writes itself onto react-i18next under a
new top-level `guardrails` block in en.json, placed right before
`guardrailsMonitor`. Catalog data (garden cards, custom code templates, option
lists) stores ParseKeys and translates at render time. Provider and vendor
names, API values, code samples and test payloads stay raw

guardrail_info.tsx and custom_code/CustomCodeModal.tsx cross the 800-line
max-lines cap only because Prettier wraps the new t() calls, so each gets a
max-lines suppression entry instead of a split. The react/no-unescaped-entities
entry for add_guardrail_form.tsx is pruned because its apostrophes moved into
en.json
This commit is contained in:
yuneng-jiang 2026-11-02 15:56:49 -08:00 • committed by GitHub
parent 1bbfb3a02f
commit d3de601946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 3242 additions and 1132 deletions

View file

@ -215,9 +215,6 @@
},
"max-lines": {
"count": 1
},
"react/no-unescaped-entities": {
"count": 2
}
},
"src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx": {
@ -247,6 +244,9 @@
}
},
"src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx": {
"max-lines": {
"count": 1
},
"no-nested-ternary": {
"count": 6
},
@ -281,6 +281,9 @@
"local/no-complex-jsx-arrow": {
"count": 1
},
"max-lines": {
"count": 1
},
"max-params": {
"count": 1
},

View file

@ -1,12 +1,10 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import { deleteGuardrailCall } from "@/components/networking";
import { toast } from "@/lib/toast";
import { formatGuardrailMode, getGuardrailLogoAndName } from "./guardrail_info_helpers";
const DANGLING_REFERENCE_WARNING =
"Keys, teams, and policies that list this guardrail will silently stop running it. Policy pipeline steps that use it will block every request with a 400 error, unless the step's on_error is next or allow (or its on_fail, when on_error is not set).";
export interface DeletableGuardrail {
guardrail_id: string;
guardrail_name?: string | null;
@ -25,6 +23,7 @@ interface GuardrailDeleteModalProps {
}
export function GuardrailDeleteModal({ guardrail, accessToken, onClose, onDeleted }: GuardrailDeleteModalProps) {
const { t } = useTranslation();
const [isDeleting, setIsDeleting] = useState(false);
const guardrailName = guardrail ? guardrail.guardrail_name || guardrail.guardrail_id : "";
const providerDisplayName = getGuardrailLogoAndName(guardrail?.litellm_params?.guardrail || "").displayName;
@ -34,11 +33,11 @@ export function GuardrailDeleteModal({ guardrail, accessToken, onClose, onDelete
setIsDeleting(true);
try {
await deleteGuardrailCall(accessToken, guardrail.guardrail_id);
toast.success(`Guardrail "${guardrailName}" deleted successfully`);
toast.success(t("guardrails.deleteModal.toast.deleted", { name: guardrailName }));
onClose();
onDeleted();
} catch {
toast.fromError("Failed to delete guardrail");
toast.fromError(t("guardrails.deleteModal.toast.deleteFailed"));
onClose();
} finally {
setIsDeleting(false);
@ -48,16 +47,21 @@ export function GuardrailDeleteModal({ guardrail, accessToken, onClose, onDelete
return (
<DeleteResourceModal
isOpen={guardrail !== null}
title="Delete Guardrail"
alertMessage={DANGLING_REFERENCE_WARNING}
message={`Are you sure you want to delete guardrail: ${guardrailName}? This action cannot be undone.`}
resourceInformationTitle="Guardrail Information"
title={t("guardrails.deleteModal.title")}
alertMessage={t("guardrails.deleteModal.danglingReferenceWarning")}
message={t("guardrails.deleteModal.message", { name: guardrailName })}
resourceInformationTitle={t("guardrails.deleteModal.infoTitle")}
resourceInformation={[
{ label: "Name", value: guardrail?.guardrail_name },
{ label: "ID", value: guardrail?.guardrail_id, code: true },
{ label: "Provider", value: providerDisplayName },
{ label: "Mode", value: formatGuardrailMode(guardrail?.litellm_params?.mode) },
{ label: "Default On", value: guardrail?.litellm_params?.default_on ? "Yes" : "No" },
{ label: t("guardrails.deleteModal.info.name"), value: guardrail?.guardrail_name },
{ label: t("guardrails.deleteModal.info.id"), value: guardrail?.guardrail_id, code: true },
{ label: t("guardrails.deleteModal.info.provider"), value: providerDisplayName },
{ label: t("guardrails.deleteModal.info.mode"), value: formatGuardrailMode(guardrail?.litellm_params?.mode) },
{
label: t("guardrails.deleteModal.info.defaultOn"),
value: guardrail?.litellm_params?.default_on
? t("guardrails.deleteModal.info.yes")
: t("guardrails.deleteModal.info.no"),
},
]}
onCancel={onClose}
onOk={handleDelete}

View file

@ -1,7 +1,9 @@
"use client";
import type { ParseKeys } from "i18next";
import { CircleHelp } from "lucide-react";
import React, { useEffect, useId } from "react";
import { useTranslation } from "react-i18next";
import {
useController,
useWatch,
@ -121,22 +123,24 @@ export const GuardrailField: React.FC<GuardrailFieldProps> = ({
);
};
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" },
const SKIP_MESSAGE_ITEMS: { labelKey: ParseKeys; value: string }[] = [
{ labelKey: "guardrails.formField.skipMessage.options.inherit", value: "inherit" },
{ labelKey: "guardrails.formField.skipMessage.options.yes", value: "yes" },
{ labelKey: "guardrails.formField.skipMessage.options.no", value: "no" },
];
export const SkipMessageSelect: React.FC<{ control: GuardrailFieldControlProps }> = ({ control }) => {
const { t } = useTranslation();
const { id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy } = control;
const items = SKIP_MESSAGE_ITEMS.map(({ labelKey, value: itemValue }) => ({ label: t(labelKey), value: itemValue }));
return (
<Select items={SKIP_MESSAGE_ITEMS} value={asText(value) || null} onValueChange={onChange}>
<Select items={items} value={asText(value) || null} onValueChange={onChange}>
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy} className="w-full">
<SelectValue placeholder="Select an option" />
<SelectValue placeholder={t("guardrails.formField.skipMessage.placeholder")} />
</SelectTrigger>
<SelectContent>
{SKIP_MESSAGE_ITEMS.map((item) => (
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -150,8 +154,9 @@ export const LoggingOnlyScopeSelect: React.FC<{
control: GuardrailFieldControlProps;
directionalScopeSupported: boolean;
}> = ({ control, directionalScopeSupported }) => {
const { t } = useTranslation();
const { id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy } = control;
const items = getLoggingOnlyScopeOptions(directionalScopeSupported);
const items = getLoggingOnlyScopeOptions(directionalScopeSupported, t);
useEffect(() => {
const currentChoice = isLoggingOnlyScopeChoice(value) ? value : "default";
@ -162,7 +167,7 @@ export const LoggingOnlyScopeSelect: React.FC<{
return (
<Select items={items} value={asText(value) || "default"} onValueChange={onChange}>
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy} className="w-full">
<SelectValue placeholder="Select an option" />
<SelectValue placeholder={t("guardrails.formField.loggingOnlyScope.placeholder")} />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
@ -180,6 +185,7 @@ export const LoggingOnlyScopeField: React.FC<{
mode: unknown;
directionalScopeSupported: boolean;
}> = ({ control, mode, directionalScopeSupported }) => {
const { t } = useTranslation();
const scopeChoice = useWatch({ control, name: "logging_only_scope_choice" });
const continueDisabled = scopeChoice === "input" || scopeChoice === "output";
if (!modeIncludesLoggingOnly(mode)) return null;
@ -190,8 +196,8 @@ export const LoggingOnlyScopeField: React.FC<{
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.",
t("guardrails.formField.loggingOnlyScope.label"),
t("guardrails.formField.loggingOnlyScope.hint"),
)}
>
{(fieldControl) => (
@ -202,8 +208,8 @@ export const LoggingOnlyScopeField: React.FC<{
control={control}
name="logging_only_continue_on_input_failure"
label={labelWithHint(
"Continue observing the response after a flagged request",
"When on, a flagged or failing request scan is logged and the response is still scanned, so both verdicts land.",
t("guardrails.formField.continueOnInputFailure.label"),
t("guardrails.formField.continueOnInputFailure.hint"),
)}
>
{(fieldControl) => (
@ -214,7 +220,7 @@ export const LoggingOnlyScopeField: React.FC<{
onCheckedChange={fieldControl.onChange}
aria-invalid={fieldControl["aria-invalid"]}
aria-describedby={fieldControl["aria-describedby"]}
aria-label="Continue observing the response after a flagged request"
aria-label={t("guardrails.formField.continueOnInputFailure.label")}
/>
)}
</GuardrailField>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import {
@ -15,39 +16,53 @@ type GuardrailModeParams = {
logging_only_continue_on_input_failure?: boolean | 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>
<p className="mt-2">Continue after a flagged request</p>
<h3 className="text-lg font-medium">{loggingOnlyContinueFromParams(litellmParams) ? "On" : "Off"}</h3>
export const GuardrailModeCard: React.FC<{ litellmParams: GuardrailModeParams }> = ({ litellmParams }) => {
const { t } = useTranslation();
return (
<Card className="block p-6">
<p>{t("guardrails.modeDisplay.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 ? t("guardrails.modeDisplay.defaultOn") : t("guardrails.modeDisplay.defaultOff")}
</Badge>
</div>
)}
</Card>
);
{modeIncludesLoggingOnly(litellmParams.mode) && (
<div className="mt-4">
<p>{t("guardrails.modeDisplay.loggingOnlyScope")}</p>
<h3 className="text-lg font-medium">{formatLoggingOnlyScope(litellmParams.logging_only_scope, t)}</h3>
<p className="mt-2">{t("guardrails.modeDisplay.continueAfterFlagged")}</p>
<h3 className="text-lg font-medium">
{loggingOnlyContinueFromParams(litellmParams)
? t("guardrails.modeDisplay.on")
: t("guardrails.modeDisplay.off")}
</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) && (
export const GuardrailModeRows: React.FC<{ litellmParams: GuardrailModeParams }> = ({ litellmParams }) => {
const { t } = useTranslation();
return (
<>
<div>
<p className="font-medium">Logging only scope</p>
<div>{formatLoggingOnlyScope(litellmParams.logging_only_scope)}</div>
<p className="font-medium">Continue after a flagged request</p>
<div>{loggingOnlyContinueFromParams(litellmParams) ? "On" : "Off"}</div>
<p className="font-medium">{t("guardrails.modeDisplay.mode")}</p>
<div>{formatGuardrailMode(litellmParams.mode) || "-"}</div>
</div>
)}
</>
);
{modeIncludesLoggingOnly(litellmParams.mode) && (
<div>
<p className="font-medium">{t("guardrails.modeDisplay.loggingOnlyScope")}</p>
<div>{formatLoggingOnlyScope(litellmParams.logging_only_scope, t)}</div>
<p className="font-medium">{t("guardrails.modeDisplay.continueAfterFlagged")}</p>
<div>
{loggingOnlyContinueFromParams(litellmParams)
? t("guardrails.modeDisplay.on")
: t("guardrails.modeDisplay.off")}
</div>
</div>
)}
</>
);
};

View file

@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { DEFAULT_DECISION_THRESHOLD } from "./decision_model/decisionModelQuestion";
import { GuardrailModeRows } from "./GuardrailModeDisplay";
@ -17,29 +18,32 @@ const DecisionModelSection = ({
}: {
decisionModel: string;
checks: DecisionModelCheckView[];
}) => (
<div>
<p className="font-medium">Decision Model</p>
<div className="font-mono">{decisionModel}</div>
<p className="mt-2 font-medium">Checks</p>
<div className="mt-2 overflow-hidden rounded-lg border border-border">
<div className="flex border-b border-border bg-muted/40 px-4 py-2 text-xs font-semibold">
<span className="flex-1">Check</span>
<span className="w-24 text-right">Action</span>
<span className="w-24 text-right">Threshold</span>
</div>
{checks.map((check) => (
<div key={check.name} className="flex items-center border-b border-border px-4 py-2 text-sm last:border-b-0">
<span className="flex-1">{check.name.replace(/_/g, " ")}</span>
<span className="w-24 text-right">
<Badge variant="secondary">{check.action ?? "block"}</Badge>
</span>
<span className="w-24 text-right">{check.threshold ?? DEFAULT_DECISION_THRESHOLD}</span>
}) => {
const { t } = useTranslation();
return (
<div>
<p className="font-medium">{t("guardrails.readOnlyDetails.decisionModel.heading")}</p>
<div className="font-mono">{decisionModel}</div>
<p className="mt-2 font-medium">{t("guardrails.readOnlyDetails.decisionModel.checks")}</p>
<div className="mt-2 overflow-hidden rounded-lg border border-border">
<div className="flex border-b border-border bg-muted/40 px-4 py-2 text-xs font-semibold">
<span className="flex-1">{t("guardrails.readOnlyDetails.decisionModel.columns.check")}</span>
<span className="w-24 text-right">{t("guardrails.readOnlyDetails.decisionModel.columns.action")}</span>
<span className="w-24 text-right">{t("guardrails.readOnlyDetails.decisionModel.columns.threshold")}</span>
</div>
))}
{checks.map((check) => (
<div key={check.name} className="flex items-center border-b border-border px-4 py-2 text-sm last:border-b-0">
<span className="flex-1">{check.name.replace(/_/g, " ")}</span>
<span className="w-24 text-right">
<Badge variant="secondary">{check.action ?? "block"}</Badge>
</span>
<span className="w-24 text-right">{check.threshold ?? DEFAULT_DECISION_THRESHOLD}</span>
</div>
))}
</div>
</div>
</div>
);
);
};
export const GuardrailReadOnlyDetails = ({
guardrailId,
@ -72,45 +76,52 @@ export const GuardrailReadOnlyDetails = ({
updatedAt: string;
showToolPermission: boolean;
toolPermissionConfig: ToolPermissionConfig;
}) => (
<div className="space-y-4">
<div>
<p className="font-medium">Guardrail ID</p>
<div className="font-mono">{guardrailId}</div>
</div>
<div>
<p className="font-medium">Guardrail Name</p>
<div>{guardrailName || "Unnamed Guardrail"}</div>
</div>
<div>
<p className="font-medium">Provider</p>
<div>{displayName}</div>
</div>
<GuardrailModeRows litellmParams={litellmParams} />
<GuardrailStreamScopeDetail raw={streamScope} />
<div>
<p className="font-medium">Default On</p>
<Badge variant={defaultOn ? "secondary" : "outline"}>{defaultOn ? "Yes" : "No"}</Badge>
</div>
{litellmParams.guardrail === "decision_model" && litellmParams.decision_model && (
<DecisionModelSection decisionModel={litellmParams.decision_model} checks={litellmParams.checks ?? []} />
)}
{piiEntityCount > 0 && (
}) => {
const { t } = useTranslation();
return (
<div className="space-y-4">
<div>
<p className="font-medium">PII Protection</p>
<div className="mt-2">
<Badge variant="secondary">{piiEntityCount} PII entities configured</Badge>
</div>
<p className="font-medium">{t("guardrails.readOnlyDetails.guardrailId")}</p>
<div className="font-mono">{guardrailId}</div>
</div>
)}
<div>
<p className="font-medium">Created At</p>
<div>{createdAt}</div>
<div>
<p className="font-medium">{t("guardrails.readOnlyDetails.guardrailName")}</p>
<div>{guardrailName || t("guardrails.readOnlyDetails.unnamed")}</div>
</div>
<div>
<p className="font-medium">{t("guardrails.readOnlyDetails.provider")}</p>
<div>{displayName}</div>
</div>
<GuardrailModeRows litellmParams={litellmParams} />
<GuardrailStreamScopeDetail raw={streamScope} />
<div>
<p className="font-medium">{t("guardrails.readOnlyDetails.defaultOn")}</p>
<Badge variant={defaultOn ? "secondary" : "outline"}>
{defaultOn ? t("guardrails.readOnlyDetails.yes") : t("guardrails.readOnlyDetails.no")}
</Badge>
</div>
{litellmParams.guardrail === "decision_model" && litellmParams.decision_model && (
<DecisionModelSection decisionModel={litellmParams.decision_model} checks={litellmParams.checks ?? []} />
)}
{piiEntityCount > 0 && (
<div>
<p className="font-medium">{t("guardrails.readOnlyDetails.piiProtection")}</p>
<div className="mt-2">
<Badge variant="secondary">
{piiEntityCount} {t("guardrails.readOnlyDetails.piiEntitiesConfigured", { count: piiEntityCount })}
</Badge>
</div>
</div>
)}
<div>
<p className="font-medium">{t("guardrails.readOnlyDetails.createdAt")}</p>
<div>{createdAt}</div>
</div>
<div>
<p className="font-medium">{t("guardrails.readOnlyDetails.lastUpdated")}</p>
<div>{updatedAt}</div>
</div>
{showToolPermission && <ToolPermissionRulesEditor value={toolPermissionConfig} disabled />}
</div>
<div>
<p className="font-medium">Last Updated</p>
<div>{updatedAt}</div>
</div>
{showToolPermission && <ToolPermissionRulesEditor value={toolPermissionConfig} disabled />}
</div>
);
);
};

View file

@ -1,5 +1,6 @@
import { Code, Trash2 } from "lucide-react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { DeletableGuardrail, GuardrailDeleteModal } from "./GuardrailDeleteModal";
@ -20,6 +21,7 @@ export function GuardrailSettingsActions({
accessToken,
onDeleted,
}: GuardrailSettingsActionsProps) {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
if (isEditing || guardrail.guardrail_definition_location === "config") return null;
@ -29,16 +31,16 @@ export function GuardrailSettingsActions({
{guardrail.litellm_params?.guardrail === "custom_code" ? (
<Button variant="outline" onClick={onEditCode}>
<Code />
Edit Code
{t("guardrails.settingsActions.editCode")}
</Button>
) : (
<Button variant="outline" onClick={onEdit}>
Edit Settings
{t("guardrails.settingsActions.editSettings")}
</Button>
)}
<Button variant="destructive" onClick={() => setIsModalOpen(true)}>
<Trash2 />
Delete
{t("guardrails.settingsActions.delete")}
</Button>
<GuardrailDeleteModal
guardrail={isModalOpen ? guardrail : null}

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Copy, Info } from "lucide-react";
import { toast } from "@/lib/toast";
import { Button } from "@/components/ui/button";
@ -25,6 +26,7 @@ export function GuardrailTestPanel({
errors,
onClose,
}: GuardrailTestPanelProps) {
const { t } = useTranslation();
const [inputText, setInputText] = useState("");
const [metadataText, setMetadataText] = useState("");
const [metadataError, setMetadataError] = useState<string | null>(null);
@ -36,24 +38,24 @@ export function GuardrailTestPanel({
try {
const parsed = JSON.parse(raw);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { metadata: null, error: "Metadata must be a JSON object" };
return { metadata: null, error: t("guardrails.testPanel.errors.metadataNotObject") };
}
return { metadata: parsed, error: null };
} catch {
return { metadata: null, error: "Invalid JSON" };
return { metadata: null, error: t("guardrails.testPanel.errors.invalidJson") };
}
};
const handleSubmit = () => {
if (!inputText.trim()) {
toast.fromError("Please enter text to test");
toast.fromError(t("guardrails.testPanel.toast.emptyText"));
return;
}
const { metadata, error } = parseMetadata(metadataText);
if (error) {
setMetadataError(error);
toast.fromError(`Metadata: ${error}`);
toast.fromError(t("guardrails.testPanel.toast.metadataError", { error }));
return;
}
setMetadataError(null);
@ -99,9 +101,9 @@ export function GuardrailTestPanel({
const handleCopyInput = async () => {
const success = await copyToClipboard(inputText);
if (success) {
toast.success("Input copied to clipboard");
toast.success(t("guardrails.testPanel.toast.copied"));
} else {
toast.fromError("Failed to copy input");
toast.fromError(t("guardrails.testPanel.toast.copyFailed"));
}
};
@ -112,7 +114,7 @@ export function GuardrailTestPanel({
<div className="flex items-center space-x-3">
<div className="flex-1 min-w-0">
<div className="mb-1 flex items-center space-x-2">
<h2 className="text-lg font-semibold">Test Guardrails:</h2>
<h2 className="text-lg font-semibold">{t("guardrails.testPanel.title")}</h2>
<div className="flex flex-wrap gap-2">
{guardrailNames.map((name) => (
<div
@ -125,7 +127,11 @@ export function GuardrailTestPanel({
</div>
</div>
<p className="text-sm text-muted-foreground">
Test {guardrailNames.length > 1 ? "guardrails" : "guardrail"} and compare results
<Trans
i18nKey="guardrails.testPanel.subtitle"
count={guardrailNames.length}
components={{ noun: <></> }}
/>
</p>
</div>
</div>
@ -137,7 +143,7 @@ export function GuardrailTestPanel({
<div>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Input Text</label>
<label className="text-sm font-medium">{t("guardrails.testPanel.inputLabel")}</label>
<Tooltip>
<TooltipTrigger
render={
@ -146,13 +152,13 @@ export function GuardrailTestPanel({
</span>
}
/>
<TooltipContent>Press Enter to submit. Use Shift+Enter for new line.</TooltipContent>
<TooltipContent>{t("guardrails.testPanel.inputTooltip")}</TooltipContent>
</Tooltip>
</div>
{inputText && (
<Button size="sm" variant="secondary" onClick={handleCopyInput}>
<Copy />
Copy Input
{t("guardrails.testPanel.copyInput")}
</Button>
)}
</div>
@ -160,21 +166,26 @@ export function GuardrailTestPanel({
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter text to test with guardrails..."
placeholder={t("guardrails.testPanel.inputPlaceholder")}
rows={8}
className="font-mono text-sm field-sizing-fixed"
/>
<div className="mt-1 flex items-center justify-between">
<span className="text-xs text-muted-foreground">
Press <Kbd>Enter</Kbd> to submit • <Kbd>Shift+Enter</Kbd> for new line
<Trans i18nKey="guardrails.testPanel.keyboardHint" components={{ kbd: <Kbd /> }} />
</span>
<span className="text-xs text-muted-foreground">
<Trans
i18nKey="guardrails.testPanel.characters"
components={{ length: <>{String(inputText.length)}</> }}
/>
</span>
<span className="text-xs text-muted-foreground">Characters: {inputText.length}</span>
</div>
</div>
<div>
<div className="mb-2 flex items-center gap-2">
<label className="text-sm font-medium">Metadata (optional)</label>
<label className="text-sm font-medium">{t("guardrails.testPanel.metadataLabel")}</label>
<Tooltip>
<TooltipTrigger
render={
@ -183,10 +194,7 @@ export function GuardrailTestPanel({
</span>
}
/>
<TooltipContent>
JSON object forwarded to the guardrail as request_data[&apos;metadata&apos;]. Custom guardrails can
read per-request configuration from it.
</TooltipContent>
<TooltipContent>{t("guardrails.testPanel.metadataTooltip")}</TooltipContent>
</Tooltip>
</div>
<Textarea
@ -214,8 +222,8 @@ export function GuardrailTestPanel({
>
{isLoading && <UiLoadingSpinner className="size-4" />}
{isLoading
? `Testing ${guardrailNames.length} guardrail${guardrailNames.length > 1 ? "s" : ""}...`
: `Test ${guardrailNames.length} guardrail${guardrailNames.length > 1 ? "s" : ""}`}
? t("guardrails.testPanel.submit.running", { count: guardrailNames.length })
: t("guardrails.testPanel.submit.idle", { count: guardrailNames.length })}
</Button>
</div>
</div>

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { FlaskConical, Search } from "lucide-react";
import GuardrailTestPanel from "./GuardrailTestPanel";
import { applyGuardrail } from "@/components/networking";
@ -47,6 +48,7 @@ const GuardrailTestPlayground: React.FC<GuardrailTestPlaygroundProps> = ({
accessToken,
onClose,
}) => {
const { t } = useTranslation();
const [selectedGuardrails, setSelectedGuardrails] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [testResults, setTestResults] = useState<TestResult[]>([]);
@ -107,10 +109,10 @@ const GuardrailTestPlayground: React.FC<GuardrailTestPlaygroundProps> = ({
setIsTesting(false);
if (results.length > 0) {
toast.success(`${results.length} guardrail${results.length > 1 ? "s" : ""} applied successfully`);
toast.success(t("guardrails.testPlayground.toast.applied", { count: results.length }));
}
if (errors.length > 0) {
toast.fromError(`${errors.length} guardrail${errors.length > 1 ? "s" : ""} failed`);
toast.fromError(t("guardrails.testPlayground.toast.failed", { count: errors.length }));
}
};
@ -123,13 +125,13 @@ const GuardrailTestPlayground: React.FC<GuardrailTestPlaygroundProps> = ({
<div className="flex w-1/4 flex-col overflow-hidden border-r border-border">
<div className="border-b border-border p-4">
<div className="mb-3">
<h3 className="mb-3 text-lg font-semibold">Guardrails</h3>
<h3 className="mb-3 text-lg font-semibold">{t("guardrails.testPlayground.sidebarTitle")}</h3>
<InputGroup>
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search guardrails..."
placeholder={t("guardrails.testPlayground.searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
@ -144,7 +146,9 @@ const GuardrailTestPlayground: React.FC<GuardrailTestPlaygroundProps> = ({
</div>
) : filteredGuardrails.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
{searchQuery ? "No guardrails match your search" : "No guardrails available"}
{searchQuery
? t("guardrails.testPlayground.noMatches")
: t("guardrails.testPlayground.noGuardrails")}
</div>
) : (
<ul className="m-0 list-none p-0">
@ -168,11 +172,11 @@ const GuardrailTestPlayground: React.FC<GuardrailTestPlaygroundProps> = ({
</div>
<div className="mt-1 space-y-1 text-xs">
<div>
<span className="font-medium">Type: </span>
<span className="font-medium">{t("guardrails.testPlayground.typeLabel")}</span>
<span className="text-muted-foreground">{guardrail.litellm_params.guardrail}</span>
</div>
<div>
<span className="font-medium">Mode: </span>
<span className="font-medium">{t("guardrails.testPlayground.modeLabel")}</span>
<span className="text-muted-foreground">
{formatGuardrailMode(guardrail.litellm_params.mode)}
</span>
@ -186,7 +190,13 @@ const GuardrailTestPlayground: React.FC<GuardrailTestPlaygroundProps> = ({
<div className="border-t border-border bg-muted/40 p-3">
<span className="text-xs text-muted-foreground">
{selectedGuardrails.size} of {filteredGuardrails.length} selected
<Trans
i18nKey="guardrails.testPlayground.selectedCount"
components={{
selected: <>{String(selectedGuardrails.size)}</>,
total: <>{String(filteredGuardrails.length)}</>,
}}
/>
</span>
</div>
</div>
@ -194,17 +204,15 @@ const GuardrailTestPlayground: React.FC<GuardrailTestPlaygroundProps> = ({
{/* Right Panel - Test Area */}
<div className="flex w-3/4 flex-col">
<div className="flex items-center justify-between border-b border-border p-4">
<h2 className="mb-0 text-xl font-semibold">Guardrail Testing Playground</h2>
<h2 className="mb-0 text-xl font-semibold">{t("guardrails.testPlayground.title")}</h2>
</div>
<div className="flex-1 overflow-auto p-4">
{selectedGuardrails.size === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<FlaskConical className="mb-4 size-12" />
<p className="mb-2 text-lg font-medium">Select Guardrails to Test</p>
<p className="max-w-md text-center">
Choose one or more guardrails from the left sidebar to start testing and comparing results.
</p>
<p className="mb-2 text-lg font-medium">{t("guardrails.testPlayground.emptyState.title")}</p>
<p className="max-w-md text-center">{t("guardrails.testPlayground.emptyState.description")}</p>
</div>
) : (
<div className="h-full">

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Check, ChevronDown, ChevronRight, Clock, Copy } from "lucide-react";
import { toast } from "@/lib/toast";
import { Button } from "@/components/ui/button";
@ -22,6 +23,7 @@ interface GuardrailTestResultsProps {
}
export function GuardrailTestResults({ results, errors }: GuardrailTestResultsProps) {
const { t } = useTranslation();
const [collapsedResults, setCollapsedResults] = useState<Set<string>>(new Set());
const toggleResultCollapse = (guardrailName: string) => {
@ -68,7 +70,7 @@ export function GuardrailTestResults({ results, errors }: GuardrailTestResultsPr
return (
<div className="space-y-3 border-t border-border pt-4">
<h3 className="text-sm font-semibold">Results</h3>
<h3 className="text-sm font-semibold">{t("guardrails.testResults.title")}</h3>
{/* Success Results */}
{results &&
@ -93,7 +95,12 @@ export function GuardrailTestResults({ results, errors }: GuardrailTestResultsPr
<div className="flex items-center gap-3">
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
<Clock className="size-3" />
<span className="font-medium">{result.latency}ms</span>
<span className="font-medium">
<Trans
i18nKey="guardrails.testResults.latency"
components={{ value: <>{String(result.latency)}</> }}
/>
</span>
</div>
{!isCollapsed && (
<Button
@ -102,14 +109,14 @@ export function GuardrailTestResults({ results, errors }: GuardrailTestResultsPr
onClick={async () => {
const success = await copyToClipboard(result.response_text);
if (success) {
toast.success("Result copied to clipboard");
toast.success(t("guardrails.testResults.toast.copied"));
} else {
toast.fromError("Failed to copy result");
toast.fromError(t("guardrails.testResults.toast.copyFailed"));
}
}}
>
<Copy />
Copy
{t("guardrails.testResults.copy")}
</Button>
)}
</div>
@ -117,13 +124,16 @@ export function GuardrailTestResults({ results, errors }: GuardrailTestResultsPr
{!isCollapsed && (
<>
<div className="rounded-sm border border-success/20 bg-background p-3">
<label className="mb-2 block text-xs font-medium text-muted-foreground">Output Text</label>
<label className="mb-2 block text-xs font-medium text-muted-foreground">
{t("guardrails.testResults.outputLabel")}
</label>
<div className="font-mono text-sm whitespace-pre-wrap wrap-break-word">
{result.response_text}
</div>
</div>
<div className="text-xs text-muted-foreground">
<span className="font-medium">Characters:</span> {result.response_text.length}
<span className="font-medium">{t("guardrails.testResults.charactersLabel")}</span>{" "}
{result.response_text.length}
</div>
</>
)}
@ -162,11 +172,19 @@ export function GuardrailTestResults({ results, errors }: GuardrailTestResultsPr
className="cursor-pointer text-sm font-medium text-destructive"
onClick={() => toggleResultCollapse(errorItem.guardrailName)}
>
{errorItem.guardrailName} - Error
<Trans
i18nKey="guardrails.testResults.errorTitle"
components={{ name: <>{errorItem.guardrailName}</> }}
/>
</p>
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
<Clock className="size-3" />
<span className="font-medium">{errorItem.latency}ms</span>
<span className="font-medium">
<Trans
i18nKey="guardrails.testResults.latency"
components={{ value: <>{String(errorItem.latency)}</> }}
/>
</span>
</div>
</div>
{!isCollapsed && <p className="mt-1 text-sm text-destructive">{errorItem.error.message}</p>}

View file

@ -1,5 +1,6 @@
import { parseAsString, useQueryState } from "nuqs";
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ChevronDown, Code, Plus } from "lucide-react";
import { getGuardrailsList } from "@/components/networking";
@ -32,6 +33,7 @@ interface GuardrailsResponse {
}
const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole }) => {
const { t } = useTranslation();
const [guardrailsList, setGuardrailsList] = useState<Guardrail[]>([]);
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [isCustomCodeModalVisible, setIsCustomCodeModalVisible] = useState(false);
@ -104,18 +106,18 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
{isAdmin && (
<>
<TabsTrigger value="garden" className="flex-none">
Guardrail Garden
{t("guardrails.panel.tabs.garden")}
</TabsTrigger>
<TabsTrigger value="guardrails" className="flex-none">
Guardrails
{t("guardrails.panel.tabs.guardrails")}
</TabsTrigger>
<TabsTrigger value="playground" className="flex-none" disabled={!accessToken}>
Test Playground
{t("guardrails.panel.tabs.playground")}
</TabsTrigger>
</>
)}
<TabsTrigger value="submitted" className="flex-none">
Submitted Guardrails
{t("guardrails.panel.tabs.submitted")}
</TabsTrigger>
</TabsList>
@ -130,17 +132,17 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
<DropdownMenu>
<DropdownMenuTrigger disabled={!accessToken} className={cn(buttonVariants({ variant: "default" }))}>
<Plus />
Add New Guardrail
{t("guardrails.panel.addMenu.trigger")}
<ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem onClick={handleAddGuardrail}>
<Plus />
Add Provider Guardrail
{t("guardrails.panel.addMenu.provider")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAddCustomCodeGuardrail}>
<Code />
Create Custom Code Guardrail
{t("guardrails.panel.addMenu.customCode")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { GuardrailField, labelWithHint, type GuardrailFormControl } from "./GuardrailFormField";
import {
@ -7,14 +8,6 @@ import {
isGuardrailStreamScope,
} from "./guardrail_info_helpers";
const STREAM_SCOPE_ITEMS = STREAM_SCOPE_OPTIONS.map((option) => ({
label: option.label,
value: option.value,
}));
const REQUEST_SHAPE_HINT =
"Run this guardrail on streaming requests, non-streaming requests, or both, for each selected mode.";
export const StreamScopeFields = ({
modes,
value,
@ -24,8 +17,14 @@ export const StreamScopeFields = ({
value: Record<string, GuardrailStreamScope>;
onChange: (next: Record<string, GuardrailStreamScope>) => void;
}) => {
const { t } = useTranslation();
if (modes.length === 0) return null;
const streamScopeItems = STREAM_SCOPE_OPTIONS.map((option) => ({
label: t(option.labelKey),
value: option.value,
}));
return (
<div className="space-y-3">
{modes.map((mode) => {
@ -33,10 +32,10 @@ export const StreamScopeFields = ({
return (
<div key={mode} className="space-y-1">
<label htmlFor={`stream-scope-${mode}`} className="block text-sm font-medium text-foreground">
{mode} applies to
{t("guardrails.streamScope.appliesTo", { mode })}
</label>
<Select
items={STREAM_SCOPE_ITEMS}
items={streamScopeItems}
value={selected}
onValueChange={(next: GuardrailStreamScope | null) => {
if (!isGuardrailStreamScope(next)) return;
@ -44,10 +43,10 @@ export const StreamScopeFields = ({
}}
>
<SelectTrigger id={`stream-scope-${mode}`} className="w-full">
<SelectValue placeholder="Streaming and non-streaming" />
<SelectValue placeholder={t("guardrails.streamScope.placeholder")} />
</SelectTrigger>
<SelectContent>
{STREAM_SCOPE_OPTIONS.map((option) => (
{streamScopeItems.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
@ -61,34 +60,39 @@ export const StreamScopeFields = ({
);
};
export const StreamScopeFormField = ({ control, modes }: { control: GuardrailFormControl; modes: string[] }) => (
<GuardrailField
control={control}
name="stream_scope_by_mode"
label={labelWithHint("Request shape", REQUEST_SHAPE_HINT)}
>
{({ value, onChange }) => (
<StreamScopeFields
modes={modes}
value={(value as Record<string, GuardrailStreamScope> | undefined) ?? {}}
onChange={onChange}
/>
)}
</GuardrailField>
);
export const StreamScopeFormField = ({ control, modes }: { control: GuardrailFormControl; modes: string[] }) => {
const { t } = useTranslation();
return (
<GuardrailField
control={control}
name="stream_scope_by_mode"
label={labelWithHint(t("guardrails.streamScope.requestShape"), t("guardrails.streamScope.requestShapeHint"))}
>
{({ value, onChange }) => (
<StreamScopeFields
modes={modes}
value={(value as Record<string, GuardrailStreamScope> | undefined) ?? {}}
onChange={onChange}
/>
)}
</GuardrailField>
);
};
export const GuardrailStreamScopeCaption = ({ raw }: { raw: unknown }) => {
const label = formatGuardrailStreamScope(raw);
const { t } = useTranslation();
const label = formatGuardrailStreamScope(raw, t);
if (!label) return null;
return <p className="mt-1 text-sm text-muted-foreground">{label}</p>;
};
export const GuardrailStreamScopeDetail = ({ raw }: { raw: unknown }) => {
const label = formatGuardrailStreamScope(raw);
const { t } = useTranslation();
const label = formatGuardrailStreamScope(raw, t);
if (!label) return null;
return (
<div>
<p className="font-medium">Request shape</p>
<p className="font-medium">{t("guardrails.streamScope.requestShape")}</p>
<div>{label}</div>
</div>
);

View file

@ -1,6 +1,8 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import type { ParseKeys, TFunction } from "i18next";
import { Trans, useTranslation } from "react-i18next";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import {
@ -41,42 +43,50 @@ import { useZodForm } from "@/lib/forms/useZodForm";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
const GUARDRAIL_MODES = [
{ value: "pre_call", label: "Pre Call" },
{ value: "post_call", label: "Post Call" },
{ value: "during_call", label: "During Call" },
] as const;
const GUARDRAIL_MODES: { value: string; labelKey: ParseKeys }[] = [
{ value: "pre_call", labelKey: "guardrails.teamTab.modes.preCall" },
{ value: "post_call", labelKey: "guardrails.teamTab.modes.postCall" },
{ value: "during_call", labelKey: "guardrails.teamTab.modes.duringCall" },
];
const submitGuardrailSchema = z.object({
team_id: z
.string()
.nullable()
.pipe(z.string({ error: "Select a team" }).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(isValidUrl, "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" });
const buildSubmitGuardrailSchema = (t: TFunction) =>
z.object({
team_id: z
.string()
.nullable()
.pipe(
z
.string({ error: t("guardrails.teamTab.validation.teamRequired") })
.min(1, t("guardrails.teamTab.validation.teamRequired")),
),
guardrail_name: z.string().min(1, t("guardrails.teamTab.validation.nameRequired")),
mode: z.string().min(1, t("guardrails.teamTab.validation.modeRequired")),
api_base: z
.string()
.min(1, t("guardrails.teamTab.validation.apiBaseRequired"))
.refine(isValidUrl, t("guardrails.teamTab.validation.apiBaseInvalid")),
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: t("guardrails.teamTab.validation.notJsonObject") });
}
} catch {
ctx.addIssue({ code: "custom", message: t("guardrails.teamTab.validation.invalidJson") });
}
} 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" });
}
}),
});
}),
guardrail_info: z.string().superRefine((value, ctx) => {
if (!value) return;
try {
JSON.parse(value);
} catch {
ctx.addIssue({ code: "custom", message: t("guardrails.teamTab.validation.invalidJson") });
}
}),
});
type SubmitGuardrailValues = z.output<typeof submitGuardrailSchema>;
type SubmitGuardrailValues = z.output<ReturnType<typeof buildSubmitGuardrailSchema>>;
const EMPTY_SUBMIT_VALUES: SubmitGuardrailValues = {
team_id: "",
@ -180,21 +190,21 @@ function submissionToTeamGuardrail(item: GuardrailSubmissionItem): TeamGuardrail
};
}
const STATUS_CONFIG: Record<GuardrailStatus, { label: string; bg: string; text: string; dot: string }> = {
const STATUS_CONFIG: Record<GuardrailStatus, { labelKey: ParseKeys; bg: string; text: string; dot: string }> = {
active: {
label: "Active",
labelKey: "guardrails.teamTab.status.active",
bg: "bg-success/10",
text: "text-success",
dot: "bg-success",
},
pending: {
label: "Pending Review",
labelKey: "guardrails.teamTab.status.pending",
bg: "bg-warning/10",
text: "text-warning",
dot: "bg-warning",
},
rejected: {
label: "Rejected",
labelKey: "guardrails.teamTab.status.rejected",
bg: "bg-destructive/10",
text: "text-destructive",
dot: "bg-destructive",
@ -309,6 +319,7 @@ function GuardrailCard({
onApprove,
onReject,
}: GuardrailCardProps) {
const { t } = useTranslation();
const status = STATUS_CONFIG[g.status];
const teamColor = TEAM_COLORS[g.team] ?? "bg-muted text-foreground";
return (
@ -320,12 +331,14 @@ function GuardrailCard({
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${teamColor}`}>Team: {g.team}</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${teamColor}`}>
<Trans i18nKey="guardrails.teamTab.card.team" components={{ team: <>{g.team}</> }} />
</span>
<span
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${status.bg} ${status.text}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${status.dot}`} />
{status.label}
{t(status.labelKey)}
</span>
</div>
<h3 className="text-sm font-semibold text-foreground mb-1">{g.name}</h3>
@ -336,16 +349,24 @@ function GuardrailCard({
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>
Model: <span className="font-medium text-foreground">{g.model}</span>
<Trans
i18nKey="guardrails.teamTab.card.model"
components={{ value: <span className="font-medium text-foreground">{g.model}</span> }}
/>
</span>
<span>
Submitted: <span className="font-medium text-foreground">{g.submittedAt}</span>
<Trans
i18nKey="guardrails.teamTab.card.submitted"
components={{ value: <span className="font-medium text-foreground">{g.submittedAt}</span> }}
/>
</span>
</div>
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Forward API Key</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{t("guardrails.teamTab.card.forwardApiKey")}
</span>
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} disabled={!isAdmin} />
</div>
<div className="flex items-center gap-2 mt-1">
@ -354,7 +375,7 @@ function GuardrailCard({
onClick={onSelect}
className="text-xs border border-border text-muted-foreground hover:bg-muted px-3 py-1.5 rounded-md transition-colors font-medium"
>
{isSelected ? "Close" : "Review"}
{isSelected ? t("guardrails.teamTab.card.close") : t("guardrails.teamTab.card.review")}
</button>
{isAdmin && g.status === "pending" && (
<>
@ -363,14 +384,14 @@ function GuardrailCard({
onClick={onApprove}
className="text-xs bg-success hover:bg-success/80 text-success-foreground px-3 py-1.5 rounded-md transition-colors font-medium"
>
Approve
{t("guardrails.teamTab.card.approve")}
</button>
<button
type="button"
onClick={onReject}
className="text-xs border border-destructive/30 text-destructive hover:bg-destructive/10 px-3 py-1.5 rounded-md transition-colors font-medium"
>
Reject
{t("guardrails.teamTab.card.reject")}
</button>
</>
)}
@ -384,7 +405,7 @@ function GuardrailCard({
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{isHeadersExpanded ? <ChevronUpIcon className="h-3.5 w-3.5" /> : <ChevronDownIcon className="h-3.5 w-3.5" />}
Static headers
{t("guardrails.teamTab.card.staticHeaders")}
{g.customHeaders.length > 0 && (
<span className="ml-1 bg-muted text-muted-foreground rounded-full px-1.5 py-0.5 text-xs">
{g.customHeaders.length}
@ -394,7 +415,7 @@ function GuardrailCard({
{isHeadersExpanded && (
<div className="mt-2">
{g.customHeaders.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No static headers configured.</p>
<p className="text-xs text-muted-foreground italic">{t("guardrails.teamTab.card.noStaticHeaders")}</p>
) : (
<div className="space-y-1">
{g.customHeaders.map((h, i) => (
@ -447,6 +468,7 @@ function DetailPanel({
onUpdateCustomHeaders,
onUpdateExtraHeaders,
}: DetailPanelProps) {
const { t } = useTranslation();
const [configExpanded, setConfigExpanded] = useState(false);
const [newExtraHeader, setNewExtraHeader] = useState("");
const [newStaticHeaderKey, setNewStaticHeaderKey] = useState("");
@ -459,31 +481,36 @@ function DetailPanel({
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${teamColor}`}>Team: {g.team}</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${teamColor}`}>
<Trans i18nKey="guardrails.teamTab.detail.team" components={{ team: <>{g.team}</> }} />
</span>
<span
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${status.bg} ${status.text}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${status.dot}`} />
{status.label}
{t(status.labelKey)}
</span>
</div>
<h2 className="text-base font-semibold text-foreground">{g.name}</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Submitted by {g.submittedBy} on {g.submittedAt}
<Trans
i18nKey="guardrails.teamTab.detail.submittedBy"
components={{ by: <>{g.submittedBy}</>, at: <>{g.submittedAt}</> }}
/>
</p>
</div>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close detail panel"
aria-label={t("guardrails.teamTab.detail.closeLabel")}
>
<XIcon className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-muted-foreground mb-5">{g.description}</p>
<div className="space-y-4">
<ConfigRow label="Endpoint">
<ConfigRow label={t("guardrails.teamTab.detail.endpoint")}>
<div className="flex items-center gap-1.5">
<code className="text-xs font-mono text-foreground break-all">{g.endpoint}</code>
<a
@ -496,7 +523,7 @@ function DetailPanel({
</a>
</div>
</ConfigRow>
<ConfigRow label="Method">
<ConfigRow label={t("guardrails.teamTab.detail.method")}>
<span className="text-xs font-mono font-medium text-foreground bg-muted px-2 py-0.5 rounded-sm">
{g.method}
</span>
@ -505,29 +532,35 @@ function DetailPanel({
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<KeyIcon className="h-3.5 w-3.5 text-info" />
<span className="text-xs font-semibold text-info">Forward LiteLLM API Key</span>
<span className="text-xs font-semibold text-info">
{t("guardrails.teamTab.detail.forwardKeyTitle")}
</span>
</div>
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} disabled={!isAdmin} />
</div>
<p className="text-xs text-info leading-relaxed">
When enabled, the caller&apos;s LiteLLM API key is forwarded as an{" "}
<code className="font-mono bg-info/15 px-1 rounded-sm">Authorization</code> header to your guardrail
endpoint. This allows your guardrail to authenticate model calls using the original caller&apos;s
credentials.
<Trans
i18nKey="guardrails.teamTab.detail.forwardKeyDescription"
components={{ code: <code className="font-mono bg-info/15 px-1 rounded-sm" /> }}
/>
</p>
</div>
<div>
<div className="flex items-center gap-1.5 mb-2">
<span className="text-xs font-semibold text-foreground">Static headers</span>
<span className="text-xs font-semibold text-foreground">
{t("guardrails.teamTab.detail.staticHeaders")}
</span>
{g.customHeaders.length > 0 && (
<span className="bg-muted text-muted-foreground rounded-full px-1.5 py-0.5 text-xs">
{g.customHeaders.length}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mb-2">Sent with every request to the guardrail.</p>
<p className="text-xs text-muted-foreground mb-2">{t("guardrails.teamTab.detail.staticHeadersHint")}</p>
{g.customHeaders.length === 0 ? (
<p className="text-xs text-muted-foreground italic mb-2">No static headers configured.</p>
<p className="text-xs text-muted-foreground italic mb-2">
{t("guardrails.teamTab.detail.noStaticHeaders")}
</p>
) : (
<ul className="list-none space-y-1 mb-2">
{g.customHeaders.map((h, i) => (
@ -543,7 +576,7 @@ function DetailPanel({
type="button"
onClick={() => onUpdateCustomHeaders(g.customHeaders.filter((_, idx) => idx !== i))}
className="text-muted-foreground hover:text-destructive shrink-0"
aria-label={`Remove ${h.key}`}
aria-label={t("guardrails.teamTab.detail.removeHeader", { name: h.key })}
>
<XIcon className="h-3.5 w-3.5" />
</button>
@ -558,7 +591,7 @@ function DetailPanel({
type="text"
value={newStaticHeaderKey}
onChange={(e) => setNewStaticHeaderKey(e.target.value)}
placeholder="Header name (e.g. X-API-Key)"
placeholder={t("guardrails.teamTab.detail.headerNamePlaceholder")}
className="flex-1 min-w-0 text-xs font-mono border border-border rounded-sm px-2 py-1.5 text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === "Enter") {
@ -577,7 +610,7 @@ function DetailPanel({
type="text"
value={newStaticHeaderValue}
onChange={(e) => setNewStaticHeaderValue(e.target.value)}
placeholder="Value"
placeholder={t("guardrails.teamTab.detail.headerValuePlaceholder")}
className="flex-1 min-w-0 text-xs font-mono border border-border rounded-sm px-2 py-1.5 text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === "Enter") {
@ -605,14 +638,16 @@ function DetailPanel({
}}
className="text-xs font-medium text-info border border-info/20 bg-info/10 hover:bg-info/15 px-2 py-1.5 rounded-sm transition-colors shrink-0"
>
Add
{t("guardrails.teamTab.detail.addStaticHeader")}
</button>
</div>
)}
</div>
<div>
<div className="flex items-center gap-1.5 mb-2">
<span className="text-xs font-semibold text-foreground">Forward client headers</span>
<span className="text-xs font-semibold text-foreground">
{t("guardrails.teamTab.detail.forwardClientHeaders")}
</span>
{g.extraHeaders.length > 0 && (
<span className="bg-muted text-muted-foreground rounded-full px-1.5 py-0.5 text-xs">
{g.extraHeaders.length}
@ -620,10 +655,12 @@ function DetailPanel({
)}
</div>
<p className="text-xs text-muted-foreground mb-2">
Allowed header names to forward from the client request to the guardrail (e.g. x-request-id).
{t("guardrails.teamTab.detail.forwardClientHeadersHint")}
</p>
{g.extraHeaders.length === 0 ? (
<p className="text-xs text-muted-foreground italic mb-2">No forward client headers configured.</p>
<p className="text-xs text-muted-foreground italic mb-2">
{t("guardrails.teamTab.detail.noForwardClientHeaders")}
</p>
) : (
<ul className="list-none space-y-1 mb-2">
{g.extraHeaders.map((name, i) => (
@ -637,7 +674,7 @@ function DetailPanel({
type="button"
onClick={() => onUpdateExtraHeaders(g.extraHeaders.filter((_, idx) => idx !== i))}
className="text-muted-foreground hover:text-destructive shrink-0"
aria-label={`Remove ${name}`}
aria-label={t("guardrails.teamTab.detail.removeHeader", { name })}
>
<XIcon className="h-3.5 w-3.5" />
</button>
@ -652,7 +689,7 @@ function DetailPanel({
type="text"
value={newExtraHeader}
onChange={(e) => setNewExtraHeader(e.target.value)}
placeholder="e.g. x-request-id"
placeholder={t("guardrails.teamTab.detail.forwardHeaderPlaceholder")}
className="flex-1 min-w-0 text-xs font-mono border border-border rounded-sm px-2 py-1.5 text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === "Enter") {
@ -676,7 +713,7 @@ function DetailPanel({
}}
className="text-xs font-medium text-info border border-info/20 bg-info/10 hover:bg-info/15 px-2 py-1.5 rounded-sm transition-colors"
>
Add
{t("guardrails.teamTab.detail.addForwardHeader")}
</button>
</div>
)}
@ -687,7 +724,7 @@ function DetailPanel({
onClick={() => setConfigExpanded(!configExpanded)}
className="w-full flex items-center justify-between px-3 py-2 text-left text-xs font-semibold text-foreground bg-muted hover:bg-border transition-colors"
>
<span>Equivalent config</span>
<span>{t("guardrails.teamTab.detail.equivalentConfig")}</span>
{configExpanded ? (
<ChevronUpIcon className="h-3.5 w-3.5 text-muted-foreground" />
) : (
@ -703,17 +740,19 @@ function DetailPanel({
<div className="flex items-start gap-2 bg-muted border border-border rounded-lg p-3">
<InfoIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-xs text-muted-foreground leading-relaxed">
This guardrail runs on a separate instance. It receives the user request and forwards the result to the
next step in the pipeline. See{" "}
<a
href="https://docs.litellm.ai/docs/adding_provider/generic_guardrail_api"
target="_blank"
rel="noopener noreferrer"
className="text-info hover:underline"
>
LiteLLM Generic Guardrail API docs
</a>{" "}
for configuration details.
<Trans
i18nKey="guardrails.teamTab.detail.separateInstanceNote"
components={{
docs: (
<a
href="https://docs.litellm.ai/docs/adding_provider/generic_guardrail_api"
target="_blank"
rel="noopener noreferrer"
className="text-info hover:underline"
/>
),
}}
/>
</p>
</div>
</div>
@ -723,7 +762,7 @@ function DetailPanel({
className="w-full flex items-center justify-center gap-2 border border-border text-foreground hover:bg-muted text-sm font-medium py-2 rounded-md transition-colors"
>
<ExternalLinkIcon className="h-4 w-4" />
Test Endpoint
{t("guardrails.teamTab.detail.testEndpoint")}
</button>
{isAdmin && g.status === "pending" && (
<div className="flex gap-2">
@ -733,7 +772,7 @@ function DetailPanel({
className="flex-1 flex items-center justify-center gap-1.5 bg-success hover:bg-success/80 text-success-foreground text-sm font-medium py-2 rounded-md transition-colors"
>
<CheckIcon className="h-4 w-4" />
Approve
{t("guardrails.teamTab.detail.approve")}
</button>
<button
type="button"
@ -741,7 +780,7 @@ function DetailPanel({
className="flex-1 flex items-center justify-center gap-1.5 border border-destructive/30 text-destructive hover:bg-destructive/10 text-sm font-medium py-2 rounded-md transition-colors"
>
<XIcon className="h-4 w-4" />
Reject
{t("guardrails.teamTab.detail.reject")}
</button>
</div>
)}
@ -759,6 +798,7 @@ type ConfirmDialogProps = {
};
function ConfirmDialog({ action, guardrailName, onConfirm, onCancel }: ConfirmDialogProps) {
const { t } = useTranslation();
const isApprove = action === "approve";
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-overlay">
@ -775,14 +815,15 @@ function ConfirmDialog({ action, guardrailName, onConfirm, onCancel }: ConfirmDi
)}
</div>
<h3 className="text-base font-semibold text-foreground mb-1">
{isApprove ? "Approve Guardrail" : "Reject Guardrail"}
{isApprove ? t("guardrails.teamTab.confirm.approveTitle") : t("guardrails.teamTab.confirm.rejectTitle")}
</h3>
<p className="text-sm text-muted-foreground mb-5">
Are you sure you want to {action}{" "}
<span className="font-medium text-foreground">&quot;{guardrailName}&quot;</span>?{" "}
{isApprove
? "This will make it active and available for use."
: "This will mark it as rejected and notify the team."}
<Trans
i18nKey={
isApprove ? "guardrails.teamTab.confirm.approveMessage" : "guardrails.teamTab.confirm.rejectMessage"
}
components={{ name: <span className="font-medium text-foreground">&quot;{guardrailName}&quot;</span> }}
/>
</p>
<div className="flex gap-3">
<button
@ -790,7 +831,7 @@ function ConfirmDialog({ action, guardrailName, onConfirm, onCancel }: ConfirmDi
onClick={onCancel}
className="flex-1 border border-border text-foreground hover:bg-muted text-sm font-medium py-2 rounded-md transition-colors"
>
Cancel
{t("guardrails.teamTab.confirm.cancel")}
</button>
<button
type="button"
@ -801,7 +842,7 @@ function ConfirmDialog({ action, guardrailName, onConfirm, onCancel }: ConfirmDi
: "bg-destructive text-destructive-foreground hover:bg-destructive/80"
}`}
>
{isApprove ? "Approve" : "Reject"}
{isApprove ? t("guardrails.teamTab.confirm.approve") : t("guardrails.teamTab.confirm.reject")}
</button>
</div>
</div>
@ -814,6 +855,7 @@ interface TeamGuardrailsTabProps {
}
export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
const { t } = useTranslation();
const { userRole } = useAuthorized();
const isAdmin = userRole ? isProxyAdminRole(userRole) : false;
const [guardrails, setGuardrails] = useState<TeamGuardrail[]>([]);
@ -835,7 +877,12 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
const submitGuardrailSchema = useMemo(() => buildSubmitGuardrailSchema(t), [t]);
const submitForm = useZodForm(submitGuardrailSchema, { defaultValues: EMPTY_SUBMIT_VALUES });
const guardrailModeItems = useMemo(
() => GUARDRAIL_MODES.map(({ value, labelKey }) => ({ value, label: t(labelKey) })),
[t],
);
const registerGuardrail = useRegisterGuardrail();
const fetchSubmissions = useCallback(async () => {
@ -855,12 +902,12 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
setGuardrails(res.submissions.map(submissionToTeamGuardrail));
setSummary(res.summary);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load submissions");
setError(err instanceof Error ? err.message : t("guardrails.teamTab.list.loadFailed"));
setGuardrails([]);
} finally {
setIsLoading(false);
}
}, [accessToken, statusFilter, searchDebounced]);
}, [accessToken, statusFilter, searchDebounced, t]);
useEffect(() => {
fetchSubmissions();
@ -880,7 +927,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
litellm_params,
guardrail_info: values.guardrail_info ? JSON.parse(values.guardrail_info) : undefined,
});
toast.success("Guardrail submitted for review");
toast.success(t("guardrails.teamTab.toast.submitted"));
setIsSubmitModalOpen(false);
submitForm.reset();
fetchSubmissions();
@ -906,9 +953,11 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
litellm_params: { forward_api_key: newValue },
});
setGuardrails((prev) => prev.map((x) => (x.id === id ? { ...x, forwardKey: newValue } : x)));
toast.success(newValue ? "Forward API key enabled" : "Forward API key disabled");
toast.success(
newValue ? t("guardrails.teamTab.toast.forwardKeyEnabled") : t("guardrails.teamTab.toast.forwardKeyDisabled"),
);
} catch {
toast.fromError("Failed to update forward API key");
toast.fromError(t("guardrails.teamTab.toast.forwardKeyFailed"));
}
}
@ -932,9 +981,9 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
: x,
),
);
toast.success("Static headers updated");
toast.success(t("guardrails.teamTab.toast.staticHeadersUpdated"));
} catch {
toast.fromError("Failed to update static headers");
toast.fromError(t("guardrails.teamTab.toast.staticHeadersFailed"));
}
}
@ -945,9 +994,9 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
litellm_params: { extra_headers: extraHeaders },
});
setGuardrails((prev) => prev.map((x) => (x.id === id ? { ...x, extraHeaders } : x)));
toast.success("Forward client headers updated");
toast.success(t("guardrails.teamTab.toast.forwardHeadersUpdated"));
} catch {
toast.fromError("Failed to update forward client headers");
toast.fromError(t("guardrails.teamTab.toast.forwardHeadersFailed"));
}
}
@ -958,9 +1007,9 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
setConfirmAction(null);
if (selectedId === id) setSelectedId(null);
await fetchSubmissions();
toast.success("Guardrail approved");
toast.success(t("guardrails.teamTab.toast.approved"));
} catch {
toast.fromError("Failed to approve guardrail");
toast.fromError(t("guardrails.teamTab.toast.approveFailed"));
}
}
@ -971,9 +1020,9 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
setConfirmAction(null);
if (selectedId === id) setSelectedId(null);
await fetchSubmissions();
toast.success("Guardrail rejected");
toast.success(t("guardrails.teamTab.toast.rejected"));
} catch {
toast.fromError("Failed to reject guardrail");
toast.fromError(t("guardrails.teamTab.toast.rejectFailed"));
}
}
@ -990,32 +1039,32 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
<div className="flex h-full">
<div className={`flex-1 min-w-0 p-6 overflow-auto ${selected ? "border-r border-border" : ""}`}>
<div className="grid grid-cols-4 gap-4 mb-6">
<StatCard label="Total Submitted" value={totalCount} color="text-foreground" />
<StatCard label="Pending Review" value={pendingCount} color="text-warning" />
<StatCard label="Active" value={activeCount} color="text-success" />
<StatCard label="Rejected" value={rejectedCount} color="text-destructive" />
<StatCard label={t("guardrails.teamTab.stats.total")} value={totalCount} color="text-foreground" />
<StatCard label={t("guardrails.teamTab.stats.pending")} value={pendingCount} color="text-warning" />
<StatCard label={t("guardrails.teamTab.stats.active")} value={activeCount} color="text-success" />
<StatCard label={t("guardrails.teamTab.stats.rejected")} value={rejectedCount} color="text-destructive" />
</div>
<div className="flex items-center gap-3 mb-5">
<div className="relative flex-1 max-w-xs">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search guardrails..."
placeholder={t("guardrails.teamTab.toolbar.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
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-ring focus:border-info"
/>
</div>
<select
aria-label="Filter by status"
aria-label={t("guardrails.teamTab.toolbar.statusFilterLabel")}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="border border-border rounded-md px-3 py-2 text-sm text-foreground focus:outline-hidden focus:ring-1 focus:ring-ring focus:border-info bg-background"
>
<option value="all">All Status</option>
<option value="pending">Pending Review</option>
<option value="active">Active</option>
<option value="rejected">Rejected</option>
<option value="all">{t("guardrails.teamTab.toolbar.statusFilter.all")}</option>
<option value="pending">{t("guardrails.teamTab.toolbar.statusFilter.pending")}</option>
<option value="active">{t("guardrails.teamTab.toolbar.statusFilter.active")}</option>
<option value="rejected">{t("guardrails.teamTab.toolbar.statusFilter.rejected")}</option>
</select>
<button
type="button"
@ -1023,14 +1072,18 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
className="ml-auto flex items-center gap-2 bg-info hover:bg-info/80 text-info-foreground text-sm font-medium px-4 py-2 rounded-md transition-colors"
>
<PlusIcon className="h-4 w-4" />
Add Guardrail
{t("guardrails.teamTab.toolbar.addGuardrail")}
</button>
</div>
<div className="space-y-3">
{isLoading && <div className="text-center py-12 text-muted-foreground text-sm">Loading submissions…</div>}
{isLoading && (
<div className="text-center py-12 text-muted-foreground text-sm">
{t("guardrails.teamTab.list.loading")}
</div>
)}
{error && <div className="text-center py-12 text-destructive text-sm">{error}</div>}
{!isLoading && !error && filtered.length === 0 && (
<div className="text-center py-12 text-muted-foreground text-sm">No guardrails match your filters.</div>
<div className="text-center py-12 text-muted-foreground text-sm">{t("guardrails.teamTab.list.empty")}</div>
)}
{!isLoading &&
!error &&
@ -1084,23 +1137,33 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
<DialogHeader>
<DialogTitle>Submit Guardrail for Review</DialogTitle>
<DialogTitle>{t("guardrails.teamTab.submitModal.title")}</DialogTitle>
</DialogHeader>
<div className="rounded-md bg-info/10 border border-info/20 px-4 py-3 text-sm text-info mb-4">
Your guardrail will be sent for admin review before it becomes active.
{t("guardrails.teamTab.submitModal.notice")}
</div>
<TooltipProvider>
<form onSubmit={handleSubmitGuardrail}>
<FieldGroup>
<FormField control={submitForm.control} name="team_id" label="Team">
<FormField control={submitForm.control} name="team_id" label={t("guardrails.teamTab.submitModal.team")}>
{({ id, value, onChange }) => <TeamDropdown id={id} value={value} onChange={onChange} />}
</FormField>
<FormField control={submitForm.control} name="guardrail_name" label="Guardrail Name">
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g. pii-detection" />}
<FormField
control={submitForm.control}
name="guardrail_name"
label={t("guardrails.teamTab.submitModal.guardrailName")}
>
{({ ref, ...field }) => (
<Input
{...field}
ref={ref}
placeholder={t("guardrails.teamTab.submitModal.guardrailNamePlaceholder")}
/>
)}
</FormField>
<FormField control={submitForm.control} name="mode" label="Mode">
<FormField control={submitForm.control} name="mode" label={t("guardrails.teamTab.submitModal.mode")}>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select items={GUARDRAIL_MODES} value={value} onValueChange={onChange}>
<Select items={guardrailModeItems} value={value} onValueChange={onChange}>
<SelectTrigger
id={id}
aria-invalid={ariaInvalid}
@ -1110,7 +1173,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
<SelectValue />
</SelectTrigger>
<SelectContent>
{GUARDRAIL_MODES.map((mode) => (
{guardrailModeItems.map((mode) => (
<SelectItem key={mode.value} value={mode.value} title={mode.label}>
{mode.label}
</SelectItem>
@ -1119,7 +1182,11 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
</Select>
)}
</FormField>
<FormField control={submitForm.control} name="api_base" label="API Base URL">
<FormField
control={submitForm.control}
name="api_base"
label={t("guardrails.teamTab.submitModal.apiBase")}
>
{({ ref, ...field }) => (
<Input
{...field}
@ -1133,8 +1200,8 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
control={submitForm.control}
name="extra_litellm_params"
label={labelWithHint(
"Additional litellm_params (optional)",
"JSON object merged into litellm_params. e.g. forward_api_key, headers, model, unreachable_fallback",
t("guardrails.teamTab.submitModal.extraParams"),
t("guardrails.teamTab.submitModal.extraParamsHint"),
)}
>
{({ ref, ...field }) => (
@ -1147,7 +1214,11 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
/>
)}
</FormField>
<FormField control={submitForm.control} name="guardrail_info" label="Guardrail Info (optional)">
<FormField
control={submitForm.control}
name="guardrail_info"
label={t("guardrails.teamTab.submitModal.guardrailInfo")}
>
{({ ref, ...field }) => (
<Textarea
{...field}
@ -1169,9 +1240,9 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
submitForm.reset();
}}
>
Cancel
{t("guardrails.teamTab.submitModal.cancel")}
</Button>
<Button onClick={handleSubmitGuardrail}>Submit for Review</Button>
<Button onClick={handleSubmitGuardrail}>{t("guardrails.teamTab.submitModal.submit")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -1,5 +1,7 @@
import type { ParseKeys } from "i18next";
import React, { useEffect, useMemo, useState } from "react";
import { useForm, useWatch, type UseFormReturn } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "@/lib/toast";
import {
createGuardrailCall,
@ -84,14 +86,14 @@ import ToolPermissionRulesEditor, { ToolPermissionConfig } from "./tool_permissi
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
// Define human-friendly descriptions for each mode
const modeDescriptions = {
pre_call: "Before LLM Call - Runs before the LLM call and checks the input (Recommended)",
during_call: "During LLM Call - Runs in parallel with the LLM call, with response held until check completes",
post_call: "After LLM Call - Runs after the LLM call and checks only the output",
logging_only: "Logging Only - Only runs on logging callbacks without affecting the LLM call",
pre_mcp_call: "Before MCP Tool Call - Runs before MCP tool execution and validates tool calls",
during_mcp_call: "During MCP Tool Call - Runs in parallel with MCP tool execution for monitoring",
post_mcp_call: "After MCP Tool Call - Runs after MCP tool execution and checks the tool result",
const MODE_DESCRIPTION_KEYS: Record<string, ParseKeys> = {
pre_call: "guardrails.addForm.modeDescriptions.preCall",
during_call: "guardrails.addForm.modeDescriptions.duringCall",
post_call: "guardrails.addForm.modeDescriptions.postCall",
logging_only: "guardrails.addForm.modeDescriptions.loggingOnly",
pre_mcp_call: "guardrails.addForm.modeDescriptions.preMcpCall",
during_mcp_call: "guardrails.addForm.modeDescriptions.duringMcpCall",
post_mcp_call: "guardrails.addForm.modeDescriptions.postMcpCall",
};
interface GuardrailPreset {
@ -197,9 +199,9 @@ const INITIAL_VALUES: GuardrailFormValues = {
stream_scope_by_mode: {},
};
const ALWAYS_ON_ITEMS = [
{ label: "Yes", value: true },
{ label: "No", value: false },
const ALWAYS_ON_ITEMS: { labelKey: ParseKeys; value: boolean }[] = [
{ labelKey: "guardrails.addForm.fields.alwaysOn.yes", value: true },
{ labelKey: "guardrails.addForm.fields.alwaysOn.no", value: false },
];
const DEFAULT_MODES = ["pre_call", "during_call", "post_call", "logging_only"];
@ -231,6 +233,7 @@ interface ProviderParamsResponse {
}
const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, accessToken, onSuccess, preset }) => {
const { t } = useTranslation();
const form = useForm<GuardrailFormValues>({ defaultValues: INITIAL_VALUES });
const watchedMode = useWatch({ control: form.control, name: "mode" });
const [loading, setLoading] = useState(false);
@ -313,12 +316,12 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
setDecisionModelGroups(parseDecisionModelGroups(modelGroupsResp?.data));
} catch (error) {
console.error("Error fetching guardrail data:", error);
toast.fromError("Failed to load guardrail configuration");
toast.fromError(t("guardrails.addForm.toasts.loadFailed"));
}
};
fetchData();
}, [accessToken]);
}, [accessToken, t]);
// Apply preset when settings are loaded and form becomes visible
useEffect(() => {
@ -449,14 +452,14 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
// Validate configuration steps
if (currentStep === 1) {
if (shouldRenderPIIConfigSettings(selectedProvider) && selectedEntities.length === 0) {
toast.fromError("Please select at least one PII entity to continue");
toast.fromError(t("guardrails.addForm.toasts.piiEntityRequired"));
return;
}
if (isDecisionModelProvider) {
if (!(await form.trigger(["decision_model"]))) {
return;
}
const questionsProblem = decisionChecksProblem(decisionChecks);
const questionsProblem = decisionChecksProblem(decisionChecks, t);
if (questionsProblem) {
toast.fromError(questionsProblem);
return;
@ -500,7 +503,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
setLoading(true);
// First validate currently visible fields
if (!(await form.trigger())) {
toast.fromError("Failed to create guardrail: please fix the highlighted fields");
toast.fromError(t("guardrails.addForm.toasts.fixHighlightedFields"));
return;
}
@ -589,9 +592,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
const hasContentFilterSelections =
selectedPatterns.length > 0 || blockedWords.length > 0 || selectedContentCategories.length > 0;
if (!hasContentFilterSelections && !hasCompetitorIntent) {
toast.fromError(
"Please configure at least one content filter setting (category, pattern, keyword, or competitor intent)",
);
toast.fromError(t("guardrails.addForm.toasts.contentFilterRequired"));
setLoading(false);
return;
}
@ -645,7 +646,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
// For some guardrails, the config values need to be in litellm_params
guardrailData.guardrail_info = configObj;
} catch (error) {
toast.fromError("Invalid JSON in configuration");
toast.fromError(t("guardrails.addForm.toasts.invalidJson"));
setLoading(false);
return;
}
@ -654,13 +655,13 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
if (guardrailProvider === "llm_as_a_judge") {
const criteria: GuardrailCriterion[] = values.criteria ?? [];
if (criteria.length === 0) {
toast.fromError("Add at least one evaluation criterion");
toast.fromError(t("guardrails.addForm.toasts.criterionRequired"));
setLoading(false);
return;
}
const weightTotal = criteria.reduce((sum, c) => sum + (Number(c?.weight) || 0), 0);
if (weightTotal !== 100) {
toast.fromError(`Criterion weights must sum to 100% (currently ${weightTotal}%)`);
toast.fromError(t("guardrails.addForm.toasts.criterionWeights", { total: weightTotal }));
setLoading(false);
return;
}
@ -675,7 +676,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
}
if (guardrailProvider === "decision_model") {
const questionsProblem = decisionChecksProblem(decisionChecks);
const questionsProblem = decisionChecksProblem(decisionChecks, t);
if (questionsProblem) {
toast.fromError(questionsProblem);
setLoading(false);
@ -688,7 +689,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
if (guardrailProvider === "tool_permission") {
if (toolPermissionConfig.rules.length === 0) {
toast.fromError("Add at least one tool permission rule");
toast.fromError(t("guardrails.addForm.toasts.toolRuleRequired"));
setLoading(false);
return;
}
@ -765,7 +766,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
await createGuardrailCall(accessToken, guardrailData);
toast.success("Guardrail created successfully");
toast.success(t("guardrails.addForm.toasts.created"));
// Reset form and close modal
resetForm();
@ -773,7 +774,11 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
onClose();
} catch (error) {
console.error("Failed to create guardrail:", error);
toast.fromError("Failed to create guardrail: " + (error instanceof Error ? error.message : String(error)));
toast.fromError(
t("guardrails.addForm.toasts.createFailed", {
message: error instanceof Error ? error.message : String(error),
}),
);
} finally {
setLoading(false);
}
@ -794,24 +799,30 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
(term) => providerFilter.contains(term, query),
);
const supportedModes = getSupportedModesForProvider(guardrailSettings, selectedProvider) ?? DEFAULT_MODES;
const alwaysOnItems = ALWAYS_ON_ITEMS.map(({ labelKey, value }) => ({ label: t(labelKey), value }));
return (
<FieldGroup>
<GuardrailField
control={form.control}
name="guardrail_name"
label="Guardrail Name"
rules={requiredRule("Please enter a guardrail name")}
label={t("guardrails.addForm.fields.name.label")}
rules={requiredRule(t("guardrails.addForm.fields.name.required"))}
>
{({ ref, value, ...field }) => (
<Input {...field} ref={ref} value={asText(value)} placeholder="Enter a name for this guardrail" />
<Input
{...field}
ref={ref}
value={asText(value)}
placeholder={t("guardrails.addForm.fields.name.placeholder")}
/>
)}
</GuardrailField>
<GuardrailField
control={form.control}
name="provider"
label="Guardrail Provider"
rules={requiredRule("Please select a provider")}
label={t("guardrails.addForm.fields.provider.label")}
rules={requiredRule(t("guardrails.addForm.fields.provider.required"))}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Combobox
@ -830,11 +841,11 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
id={id}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
placeholder="Select a guardrail provider"
placeholder={t("guardrails.addForm.fields.provider.placeholder")}
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>No matching providers</ComboboxEmpty>
<ComboboxEmpty>{t("guardrails.addForm.fields.provider.empty")}</ComboboxEmpty>
<ComboboxList>
{(key: string) => (
<ComboboxItem key={key} value={key}>
@ -857,8 +868,8 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
<GuardrailField
control={form.control}
name="mode"
label={labelWithHint("Mode", "How the guardrail should be applied")}
rules={requiredRule("Please select a mode")}
label={labelWithHint(t("guardrails.addForm.fields.mode.label"), t("guardrails.addForm.fields.mode.hint"))}
rules={requiredRule(t("guardrails.addForm.fields.mode.required"))}
>
{({ id, value, onChange }) => (
<MultiSelect
@ -866,7 +877,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
options={supportedModes.map((mode) => ({
label: mode,
value: mode,
description: modeDescriptions[mode as keyof typeof modeDescriptions],
description: MODE_DESCRIPTION_KEYS[mode] ? t(MODE_DESCRIPTION_KEYS[mode]) : undefined,
}))}
value={asStringArray(value)}
onValueChange={onChange}
@ -880,20 +891,23 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
<GuardrailField
control={form.control}
name="default_on"
label={labelWithHint("Always On", "If enabled, this guardrail will be applied to all requests by default.")}
label={labelWithHint(
t("guardrails.addForm.fields.alwaysOn.label"),
t("guardrails.addForm.fields.alwaysOn.hint"),
)}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select
items={ALWAYS_ON_ITEMS}
items={alwaysOnItems}
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" />
<SelectValue placeholder={t("guardrails.addForm.fields.alwaysOn.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={true}>Yes</SelectItem>
<SelectItem value={false}>No</SelectItem>
<SelectItem value={true}>{t("guardrails.addForm.fields.alwaysOn.yes")}</SelectItem>
<SelectItem value={false}>{t("guardrails.addForm.fields.alwaysOn.no")}</SelectItem>
</SelectContent>
</Select>
)}
@ -903,8 +917,8 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
control={form.control}
name="skip_system_message_choice"
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.",
t("guardrails.addForm.fields.skipSystemMessages.label"),
t("guardrails.addForm.fields.skipSystemMessages.hint"),
)}
>
{(fieldControl) => <SkipMessageSelect control={fieldControl} />}
@ -914,8 +928,8 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
control={form.control}
name="skip_tool_message_choice"
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.",
t("guardrails.addForm.fields.skipToolMessages.label"),
t("guardrails.addForm.fields.skipToolMessages.hint"),
)}
>
{(fieldControl) => <SkipMessageSelect control={fieldControl} />}
@ -1078,14 +1092,13 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
<div className="space-y-6">
<div>
<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>.
<Trans i18nKey="guardrails.addForm.endpoint.intro" components={{ code: <code /> }} />
</p>
</div>
<div>
<label htmlFor="guardrail-call-type" className="mb-1 block text-sm font-medium text-foreground">
Call type
{t("guardrails.addForm.endpoint.callType.label")}
</label>
<Select
items={CALL_TYPE_ITEMS}
@ -1096,7 +1109,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
}}
>
<SelectTrigger id="guardrail-call-type" className="w-65">
<SelectValue placeholder="Select a call type" />
<SelectValue placeholder={t("guardrails.addForm.endpoint.callType.placeholder")} />
</SelectTrigger>
<SelectContent>
{CALL_TYPE_ITEMS.map((item) => (
@ -1106,7 +1119,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">More call types coming soon.</p>
<p className="mt-1 text-xs text-muted-foreground">{t("guardrails.addForm.endpoint.callType.comingSoon")}</p>
</div>
{selectedEndpointType === "realtime" && (
@ -1116,7 +1129,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
onClick={() => setEndpointSettingsOpen((o) => !o)}
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>
<span>{t("guardrails.addForm.endpoint.realtime.title")}</span>
<svg
className={`w-4 h-4 text-muted-foreground transition-transform ${endpointSettingsOpen ? "rotate-180" : ""}`}
fill="none"
@ -1135,17 +1148,16 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
htmlFor="guardrail-end-session-after"
className="mb-1 block text-sm font-medium text-foreground"
>
End session after X violations
{t("guardrails.addForm.endpoint.realtime.endSessionAfter.label")}
</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.
{t("guardrails.addForm.endpoint.realtime.endSessionAfter.description")}
</p>
<Input
id="guardrail-end-session-after"
type="number"
min={1}
placeholder="e.g. 3"
placeholder={t("guardrails.addForm.endpoint.realtime.endSessionAfter.placeholder")}
value={endSessionAfterNFails ?? ""}
onChange={(e) =>
setEndSessionAfterNFails(e.target.value ? parseInt(e.target.value, 10) : undefined)
@ -1155,7 +1167,9 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">On violation</label>
<label className="mb-2 block text-sm font-medium text-foreground">
{t("guardrails.addForm.endpoint.realtime.onViolation.label")}
</label>
<div className="space-y-2">
{(["warn", "end_session"] as const).map((opt) => (
<label key={opt} className="flex items-start gap-2 cursor-pointer">
@ -1169,12 +1183,14 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
/>
<div>
<span className="text-sm font-medium text-foreground">
{opt === "warn" ? "Warn" : "End session"}
{opt === "warn"
? t("guardrails.addForm.endpoint.realtime.onViolation.warn.label")
: t("guardrails.addForm.endpoint.realtime.onViolation.endSession.label")}
</span>
<p className="m-0 text-xs text-muted-foreground">
{opt === "warn"
? "Bot speaks the message, session continues"
: "Bot speaks the message, connection closes immediately"}
? t("guardrails.addForm.endpoint.realtime.onViolation.warn.description")
: t("guardrails.addForm.endpoint.realtime.onViolation.endSession.description")}
</p>
</div>
</label>
@ -1187,16 +1203,15 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
htmlFor="guardrail-realtime-message"
className="mb-1 block text-sm font-medium text-foreground"
>
Message the user hears
{t("guardrails.addForm.endpoint.realtime.message.label")}
</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.
{t("guardrails.addForm.endpoint.realtime.message.description")}
</p>
<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."
placeholder={t("guardrails.addForm.endpoint.realtime.message.placeholder")}
value={realtimeViolationMessage}
onChange={(e) => setRealtimeViolationMessage(e.target.value)}
className="w-full resize-none"
@ -1213,28 +1228,28 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
const getStepConfigs = () => {
if (shouldRenderContentFilterConfigSettings(selectedProvider)) {
return [
{ title: "Basic Info", optional: false },
{ title: "Topics", optional: false },
{ title: "Patterns", optional: false },
{ title: "Keywords", optional: false },
{ title: "Endpoint Settings (Optional)", optional: true },
{ title: t("guardrails.addForm.steps.basicInfo"), optional: false },
{ title: t("guardrails.addForm.steps.topics"), optional: false },
{ title: t("guardrails.addForm.steps.patterns"), optional: false },
{ title: t("guardrails.addForm.steps.keywords"), optional: false },
{ title: t("guardrails.addForm.steps.endpointSettings"), optional: true },
];
}
if (shouldRenderPIIConfigSettings(selectedProvider)) {
return [
{ title: "Basic Info", optional: false },
{ title: "PII Configuration", optional: false },
{ title: t("guardrails.addForm.steps.basicInfo"), optional: false },
{ title: t("guardrails.addForm.steps.piiConfiguration"), optional: false },
];
}
if (isDecisionModelProvider) {
return [
{ title: "Basic Info", optional: false },
{ title: "Decision Model Configuration", optional: false },
{ title: t("guardrails.addForm.steps.basicInfo"), optional: false },
{ title: t("guardrails.addForm.steps.decisionModelConfiguration"), optional: false },
];
}
return [
{ title: "Basic Info", optional: false },
{ title: "Provider Configuration", optional: false },
{ title: t("guardrails.addForm.steps.basicInfo"), optional: false },
{ title: t("guardrails.addForm.steps.providerConfiguration"), optional: false },
];
};
@ -1250,7 +1265,9 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
<div className="flex min-w-0 flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<DialogTitle className="m-0 text-base font-semibold text-foreground">Create guardrail</DialogTitle>
<DialogTitle className="m-0 text-base font-semibold text-foreground">
{t("guardrails.addForm.title")}
</DialogTitle>
<button
type="button"
onClick={handleClose}
@ -1290,9 +1307,15 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
>
<span className={`text-sm ${getStepTitleClass(isDone, isCurrent)}`}>{step.title}</span>
{step.optional && !isCurrent && (
<span className="text-[11px] text-muted-foreground">optional</span>
<span className="text-[11px] text-muted-foreground">
{t("guardrails.addForm.steps.optional")}
</span>
)}
{isDone && (
<span className="text-[11px] text-info hover:underline">
{t("guardrails.addForm.steps.edit")}
</span>
)}
{isDone && <span className="text-[11px] text-info hover:underline">Edit</span>}
</div>
{/* Expanded form content for current step */}
@ -1307,21 +1330,21 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
{/* 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
{t("guardrails.addForm.actions.cancel")}
</Button>
{currentStep > 0 && (
<Button type="button" variant="outline" onClick={prevStep}>
Previous
{t("guardrails.addForm.actions.previous")}
</Button>
)}
{currentStep < stepConfigs.length - 1 ? (
<Button type="button" onClick={nextStep}>
Next
{t("guardrails.addForm.actions.next")}
</Button>
) : (
<Button type="button" onClick={handleSubmit} disabled={loading}>
{loading && <UiLoadingSpinner className="size-4" />}
Create Guardrail
{t("guardrails.addForm.actions.create")}
</Button>
)}
</div>

View file

@ -1,11 +1,12 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Trash2 } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@/components/shared/DataTable";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACTION_ITEMS, SEVERITY_ITEMS } from "./action_options";
import { ACTION_ITEMS, SEVERITY_ITEMS, translateOptions } from "./action_options";
interface ContentCategory {
id: string;
@ -30,9 +31,12 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
onRemove,
readOnly = false,
}) => {
const { t } = useTranslation();
const actionItems = translateOptions(ACTION_ITEMS, t);
const severityItems = translateOptions(SEVERITY_ITEMS, t);
const columns: ColumnDef<ContentCategory>[] = [
{
header: "Category",
header: t("guardrails.contentFilter.categoryTable.columns.category"),
accessorKey: "display_name",
cell: ({ row }) => {
const { category, display_name: displayName } = row.original;
@ -45,7 +49,7 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
},
},
{
header: "Severity Threshold",
header: t("guardrails.contentFilter.categoryTable.columns.severityThreshold"),
accessorKey: "severity_threshold",
size: 180,
cell: ({ row }) => {
@ -55,17 +59,21 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
}
return (
<Select
items={SEVERITY_ITEMS}
items={severityItems}
value={severity}
onValueChange={(value: string | null) =>
value && onSeverityChange?.(id, value as "high" | "medium" | "low")
}
>
<SelectTrigger size="sm" className="w-[150px]" aria-label="Severity Threshold">
<SelectTrigger
size="sm"
className="w-[150px]"
aria-label={t("guardrails.contentFilter.categoryTable.columns.severityThreshold")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEVERITY_ITEMS.map((item) => (
{severityItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -76,7 +84,7 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
},
},
{
header: "Action",
header: t("guardrails.contentFilter.categoryTable.columns.action"),
accessorKey: "action",
size: 150,
cell: ({ row }) => {
@ -86,15 +94,19 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
}
return (
<Select
items={ACTION_ITEMS}
items={actionItems}
value={action}
onValueChange={(value: string | null) => value && onActionChange?.(id, value as "BLOCK" | "MASK")}
>
<SelectTrigger size="sm" className="w-[120px]" aria-label="Action">
<SelectTrigger
size="sm"
className="w-[120px]"
aria-label={t("guardrails.contentFilter.categoryTable.columns.action")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_ITEMS.map((item) => (
{actionItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -114,14 +126,16 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
cell: ({ row }) => (
<Button variant="ghost" size="sm" onClick={() => onRemove?.(row.original.id)}>
<Trash2 />
Delete
{t("guardrails.contentFilter.categoryTable.delete")}
</Button>
),
});
}
if (categories.length === 0) {
return <div className="py-10 text-center text-muted-foreground">No categories configured.</div>;
return (
<div className="py-10 text-center text-muted-foreground">{t("guardrails.contentFilter.categoryTable.empty")}</div>
);
}
return <DataTable data={categories} columns={columns} getRowId={(row) => row.id} size="compact" />;

View file

@ -1,4 +1,6 @@
import type { ParseKeys } from "i18next";
import React, { useEffect, useId, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { getMajorAirlines } from "@/components/networking";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
@ -49,26 +51,43 @@ const DEFAULT_CONFIG: CompetitorIntentConfig = {
threshold_low: 0.3,
};
const INTENT_TYPES = [
{ value: "airline", label: "Airline (auto-load competitors from IATA)" },
{ value: "generic", label: "Generic (specify competitors manually)" },
] as const;
const INTENT_TYPES: readonly { value: CompetitorIntentConfig["competitor_intent_type"]; labelKey: ParseKeys }[] = [
{ value: "airline", labelKey: "guardrails.contentFilter.competitorIntent.intentTypes.airline" },
{ value: "generic", labelKey: "guardrails.contentFilter.competitorIntent.intentTypes.generic" },
];
const COMPETITOR_COMPARISON_POLICIES = [
{ value: "refuse", label: "Refuse (block request)" },
{ value: "reframe", label: "Reframe (suggest alternative)" },
] as const;
type PolicyChoice = "refuse" | "reframe";
const POSSIBLE_COMPETITOR_COMPARISON_POLICIES = [
{ value: "refuse", label: "Refuse (block request)" },
{ value: "reframe", label: "Reframe (suggest alternative to backend LLM)" },
] as const;
const COMPETITOR_COMPARISON_POLICIES: readonly { value: PolicyChoice; labelKey: ParseKeys }[] = [
{ value: "refuse", labelKey: "guardrails.contentFilter.competitorIntent.policies.refuse" },
{ value: "reframe", labelKey: "guardrails.contentFilter.competitorIntent.policies.reframe" },
];
const POSSIBLE_COMPETITOR_COMPARISON_POLICIES: readonly { value: PolicyChoice; labelKey: ParseKeys }[] = [
{ value: "refuse", labelKey: "guardrails.contentFilter.competitorIntent.policies.refuse" },
{ value: "reframe", labelKey: "guardrails.contentFilter.competitorIntent.policies.reframeToBackend" },
];
const THRESHOLDS = [
{ field: "threshold_high", label: "High", hint: "e.g. 0.7", fallback: 0.7 },
{ field: "threshold_medium", label: "Medium", hint: "e.g. 0.45", fallback: 0.45 },
{ field: "threshold_low", label: "Low", hint: "e.g. 0.3", fallback: 0.3 },
] as const;
{
field: "threshold_high",
labelKey: "guardrails.contentFilter.competitorIntent.thresholds.high",
hint: 0.7,
fallback: 0.7,
},
{
field: "threshold_medium",
labelKey: "guardrails.contentFilter.competitorIntent.thresholds.medium",
hint: 0.45,
fallback: 0.45,
},
{
field: "threshold_low",
labelKey: "guardrails.contentFilter.competitorIntent.thresholds.low",
hint: 0.3,
fallback: 0.3,
},
] as const satisfies readonly { field: string; labelKey: ParseKeys; hint: number; fallback: number }[];
const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps> = ({
enabled,
@ -76,6 +95,7 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
onChange,
accessToken,
}) => {
const { t } = useTranslation();
const effectiveConfig = config ?? DEFAULT_CONFIG;
const [airlineOptions, setAirlineOptions] = useState<MajorAirline[]>([]);
const [loadingAirlines, setLoadingAirlines] = useState(false);
@ -139,7 +159,7 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
const header = (
<CardHeader className="gap-0">
<CardTitle className="text-base">Competitor Intent Filter</CardTitle>
<CardTitle className="text-base">{t("guardrails.contentFilter.competitorIntent.title")}</CardTitle>
<CardAction>
<Switch checked={enabled} onCheckedChange={handleEnabledChange} />
</CardAction>
@ -152,8 +172,7 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
{header}
<CardContent>
<p className="text-sm text-muted-foreground">
Block or reframe competitor comparison questions. When enabled, airline type auto-loads competitors from
IATA; generic type requires manual competitor list.
{t("guardrails.contentFilter.competitorIntent.disabledDescription")}
</p>
</CardContent>
</Card>
@ -175,19 +194,30 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
})
: [];
const intentTypes = INTENT_TYPES.map(({ value, labelKey }) => ({ value, label: t(labelKey) }));
const competitorComparisonPolicies = COMPETITOR_COMPARISON_POLICIES.map(({ value, labelKey }) => ({
value,
label: t(labelKey),
}));
const possibleCompetitorComparisonPolicies = POSSIBLE_COMPETITOR_COMPARISON_POLICIES.map(({ value, labelKey }) => ({
value,
label: t(labelKey),
}));
return (
<Card>
{header}
<CardContent>
<p className="mb-4 text-sm text-muted-foreground">
Block or reframe competitor comparison questions. Airline type uses major airlines (excluding your brand);
generic requires manual competitor list.
{t("guardrails.contentFilter.competitorIntent.enabledDescription")}
</p>
<FieldGroup>
<Field>
<FieldLabel htmlFor={`${fieldId}-type`}>Type</FieldLabel>
<FieldLabel htmlFor={`${fieldId}-type`}>
{t("guardrails.contentFilter.competitorIntent.typeLabel")}
</FieldLabel>
<Select
items={INTENT_TYPES}
items={intentTypes}
value={effectiveConfig.competitor_intent_type}
onValueChange={(v: string | null) => v !== null && handleConfigChange("competitor_intent_type", v)}
>
@ -195,7 +225,7 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
<SelectValue />
</SelectTrigger>
<SelectContent>
{INTENT_TYPES.map((type) => (
{intentTypes.map((type) => (
<SelectItem key={type.value} value={type.value} title={type.label}>
{type.label}
</SelectItem>
@ -205,7 +235,9 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
</Field>
<Field>
<FieldLabel htmlFor={`${fieldId}-brand-self`}>Your Brand (brand_self)</FieldLabel>
<FieldLabel htmlFor={`${fieldId}-brand-self`}>
{t("guardrails.contentFilter.competitorIntent.brandSelfLabel", { field: "brand_self" })}
</FieldLabel>
<TagsInput
id={`${fieldId}-brand-self`}
value={effectiveConfig.brand_self}
@ -219,49 +251,57 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
loading={loadingAirlines}
placeholder={
effectiveConfig.competitor_intent_type === "airline"
? "Search or select airline, or type to add custom"
: "Type and press Enter to add"
? t("guardrails.contentFilter.competitorIntent.airlinePlaceholder")
: t("guardrails.contentFilter.competitorIntent.typeToAddPlaceholder")
}
/>
<FieldDescription>
{effectiveConfig.competitor_intent_type === "airline"
? "Select your airline from the list (excluded from competitors) or type to add a custom term"
: "Names/codes users use for your brand"}
? t("guardrails.contentFilter.competitorIntent.airlineBrandDescription")
: t("guardrails.contentFilter.competitorIntent.genericBrandDescription")}
</FieldDescription>
</Field>
{effectiveConfig.competitor_intent_type === "airline" && (
<Field>
<FieldLabel htmlFor={`${fieldId}-locations`}>Locations (optional)</FieldLabel>
<FieldLabel htmlFor={`${fieldId}-locations`}>
{t("guardrails.contentFilter.competitorIntent.locationsLabel")}
</FieldLabel>
<TagsInput
id={`${fieldId}-locations`}
value={effectiveConfig.locations ?? []}
onValueChange={(v) => handleNestedArrayChange("locations", v)}
tokenSeparators={[","]}
placeholder="Type and press Enter to add"
placeholder={t("guardrails.contentFilter.competitorIntent.typeToAddPlaceholder")}
/>
<FieldDescription>Countries, cities, airports for disambiguation (e.g. qatar, doha)</FieldDescription>
<FieldDescription>{t("guardrails.contentFilter.competitorIntent.locationsDescription")}</FieldDescription>
</Field>
)}
{effectiveConfig.competitor_intent_type === "generic" && (
<Field>
<FieldLabel htmlFor={`${fieldId}-competitors`}>Competitors</FieldLabel>
<FieldLabel htmlFor={`${fieldId}-competitors`}>
{t("guardrails.contentFilter.competitorIntent.competitorsLabel")}
</FieldLabel>
<TagsInput
id={`${fieldId}-competitors`}
value={effectiveConfig.competitors ?? []}
onValueChange={(v) => handleNestedArrayChange("competitors", v)}
tokenSeparators={[","]}
placeholder="Type and press Enter to add"
placeholder={t("guardrails.contentFilter.competitorIntent.typeToAddPlaceholder")}
/>
<FieldDescription>Competitor names to detect (required for generic type)</FieldDescription>
<FieldDescription>
{t("guardrails.contentFilter.competitorIntent.competitorsDescription")}
</FieldDescription>
</Field>
)}
<Field>
<FieldLabel htmlFor={`${fieldId}-competitor-comparison`}>Policy: Competitor comparison</FieldLabel>
<FieldLabel htmlFor={`${fieldId}-competitor-comparison`}>
{t("guardrails.contentFilter.competitorIntent.competitorComparisonLabel")}
</FieldLabel>
<Select
items={COMPETITOR_COMPARISON_POLICIES}
items={competitorComparisonPolicies}
value={effectiveConfig.policy?.competitor_comparison ?? "refuse"}
onValueChange={(v: string | null) => v !== null && handlePolicyChange("competitor_comparison", v)}
>
@ -269,7 +309,7 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPETITOR_COMPARISON_POLICIES.map((policy) => (
{competitorComparisonPolicies.map((policy) => (
<SelectItem key={policy.value} value={policy.value} title={policy.label}>
{policy.label}
</SelectItem>
@ -280,10 +320,10 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
<Field>
<FieldLabel htmlFor={`${fieldId}-possible-competitor-comparison`}>
Policy: Possible competitor comparison
{t("guardrails.contentFilter.competitorIntent.possibleCompetitorComparisonLabel")}
</FieldLabel>
<Select
items={POSSIBLE_COMPETITOR_COMPARISON_POLICIES}
items={possibleCompetitorComparisonPolicies}
value={effectiveConfig.policy?.possible_competitor_comparison ?? "reframe"}
onValueChange={(v: string | null) =>
v !== null && handlePolicyChange("possible_competitor_comparison", v)
@ -293,7 +333,7 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
<SelectValue />
</SelectTrigger>
<SelectContent>
{POSSIBLE_COMPETITOR_COMPARISON_POLICIES.map((policy) => (
{possibleCompetitorComparisonPolicies.map((policy) => (
<SelectItem key={policy.value} value={policy.value} title={policy.label}>
{policy.label}
</SelectItem>
@ -303,11 +343,11 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
</Field>
<Field>
<FieldLabel>Confidence thresholds</FieldLabel>
<FieldLabel>{t("guardrails.contentFilter.competitorIntent.thresholdsLabel")}</FieldLabel>
<div className="flex flex-wrap gap-4">
{THRESHOLDS.map((threshold) => (
<Field key={threshold.field} className="w-20">
<FieldLabel htmlFor={`${fieldId}-${threshold.field}`}>{threshold.label}</FieldLabel>
<FieldLabel htmlFor={`${fieldId}-${threshold.field}`}>{t(threshold.labelKey)}</FieldLabel>
<ThresholdInput
id={`${fieldId}-${threshold.field}`}
value={effectiveConfig[threshold.field] ?? threshold.fallback}
@ -316,26 +356,35 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
max={1}
step={0.05}
/>
<FieldDescription>{threshold.hint}</FieldDescription>
<FieldDescription>
{t("guardrails.contentFilter.competitorIntent.thresholds.example", { value: threshold.hint })}
</FieldDescription>
</Field>
))}
</div>
<FieldDescription>
Classify competitor intent by confidence (0–1). Higher confidence -&gt; stronger intent.
{t("guardrails.contentFilter.competitorIntent.thresholdsHelp.intro")}
<ul className="mt-1 mb-0 list-disc pl-5">
<li>
<strong>High (≥)</strong>: Treat as full competitor comparison -&gt; uses &quot;Competitor
comparison&quot; policy
<Trans
i18nKey="guardrails.contentFilter.competitorIntent.thresholdsHelp.high"
components={{ strong: <strong /> }}
/>
</li>
<li>
<strong>Medium (≥)</strong>: Treat as possible comparison -&gt; uses &quot;Possible competitor
comparison&quot; policy
<Trans
i18nKey="guardrails.contentFilter.competitorIntent.thresholdsHelp.medium"
components={{ strong: <strong /> }}
/>
</li>
<li>
<strong>Low (≥)</strong>: Log only; allow request. Below Low -&gt; allow with no action
<Trans
i18nKey="guardrails.contentFilter.competitorIntent.thresholdsHelp.low"
components={{ strong: <strong /> }}
/>
</li>
</ul>
Raise thresholds to be more permissive; lower them to be stricter.
{t("guardrails.contentFilter.competitorIntent.thresholdsHelp.outro")}
</FieldDescription>
</Field>
</FieldGroup>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, FileText, Plus, Trash2 } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { getCategoryYaml } from "@/components/networking";
@ -16,7 +17,7 @@ import {
ComboboxList,
} from "@/components/ui/combobox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACTION_ITEMS, SEVERITY_ITEMS } from "./action_options";
import { ACTION_ITEMS, SEVERITY_ITEMS, translateOptions } from "./action_options";
interface ContentCategory {
name: string;
@ -54,6 +55,9 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
pendingSelection,
onPendingSelectionChange,
}) => {
const { t } = useTranslation();
const actionItems = translateOptions(ACTION_ITEMS, t);
const severityItems = translateOptions(SEVERITY_ITEMS, t);
// Use controlled state if parent provides it, otherwise use local state
const [localSelectedCategoryName, setLocalSelectedCategoryName] = React.useState<string>("");
const selectedCategoryName = pendingSelection !== undefined ? pendingSelection : localSelectedCategoryName;
@ -174,7 +178,7 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
const columns: ColumnDef<SelectedCategory>[] = [
{
header: "Category",
header: t("guardrails.contentFilter.categoryConfiguration.columns.category"),
accessorKey: "display_name",
cell: ({ row }) => {
const category = availableCategories.find((c) => c.name === row.original.category);
@ -187,20 +191,24 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
},
},
{
header: "Action",
header: t("guardrails.contentFilter.categoryConfiguration.columns.action"),
accessorKey: "action",
size: 150,
cell: ({ row }) => (
<Select
items={ACTION_ITEMS}
items={actionItems}
value={row.original.action}
onValueChange={(value: string | null) => value && onCategoryUpdate(row.original.id, "action", value)}
>
<SelectTrigger size="sm" className="w-full" aria-label="Action">
<SelectTrigger
size="sm"
className="w-full"
aria-label={t("guardrails.contentFilter.categoryConfiguration.columns.action")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_ITEMS.map((item) => (
{actionItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
<Badge variant={item.value === "BLOCK" ? "destructive" : "secondary"}>{item.value}</Badge>
</SelectItem>
@ -210,22 +218,26 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
),
},
{
header: "Severity Threshold",
header: t("guardrails.contentFilter.categoryConfiguration.columns.severityThreshold"),
accessorKey: "severity_threshold",
size: 180,
cell: ({ row }) => (
<Select
items={SEVERITY_ITEMS}
items={severityItems}
value={row.original.severity_threshold}
onValueChange={(value: string | null) =>
value && onCategoryUpdate(row.original.id, "severity_threshold", value)
}
>
<SelectTrigger size="sm" className="w-full" aria-label="Severity Threshold">
<SelectTrigger
size="sm"
className="w-full"
aria-label={t("guardrails.contentFilter.categoryConfiguration.columns.severityThreshold")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEVERITY_ITEMS.map((item) => (
{severityItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -241,7 +253,7 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
cell: ({ row }) => (
<Button variant="outline" size="sm" onClick={() => onCategoryRemove(row.original.id)}>
<Trash2 />
Remove
{t("guardrails.contentFilter.categoryConfiguration.remove")}
</Button>
),
},
@ -256,9 +268,9 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>Blocked topics</CardTitle>
<CardTitle>{t("guardrails.contentFilter.categoryConfiguration.title")}</CardTitle>
<p className="text-xs font-normal text-muted-foreground">
Select topics to block using keyword and semantic analysis
{t("guardrails.contentFilter.categoryConfiguration.subtitle")}
</p>
</div>
</CardHeader>
@ -270,9 +282,12 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
onValueChange={(category: ContentCategory | null) => setSelectedCategoryName(category?.name ?? "")}
itemToStringLabel={(category: ContentCategory) => category.display_name}
>
<ComboboxInput className="w-full" placeholder="Select a content category" />
<ComboboxInput
className="w-full"
placeholder={t("guardrails.contentFilter.categoryConfiguration.placeholder")}
/>
<ComboboxContent>
<ComboboxEmpty>No matching categories</ComboboxEmpty>
<ComboboxEmpty>{t("guardrails.contentFilter.categoryConfiguration.noMatches")}</ComboboxEmpty>
<ComboboxList>
{(cat: ContentCategory) => (
<ComboboxItem key={cat.name} value={cat}>
@ -287,7 +302,7 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
</Combobox>
<Button onClick={handleAddCategory} disabled={!selectedCategoryName}>
<Plus />
Add
{t("guardrails.contentFilter.categoryConfiguration.add")}
</Button>
</div>
@ -295,7 +310,8 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
{selectedCategoryName && (
<div className="mb-4 rounded-md border border-border bg-muted/40 p-3">
<div className="mb-2 text-sm font-medium">
Preview: {availableCategories.find((c) => c.name === selectedCategoryName)?.display_name}
{t("guardrails.contentFilter.categoryConfiguration.preview")}{" "}
{availableCategories.find((c) => c.name === selectedCategoryName)?.display_name}
{categoryFileTypes[selectedCategoryName] && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
({categoryFileTypes[selectedCategoryName]?.toUpperCase()})
@ -303,13 +319,17 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
)}
</div>
{loadingPreviewYaml ? (
<div className="p-4 text-center text-muted-foreground">Loading content...</div>
<div className="p-4 text-center text-muted-foreground">
{t("guardrails.contentFilter.categoryConfiguration.loadingContent")}
</div>
) : previewYaml ? (
<pre className="m-0 max-h-[300px] max-w-full overflow-auto rounded-md border border-border bg-background p-3 text-xs leading-relaxed break-words whitespace-pre-wrap">
<code>{previewYaml}</code>
</pre>
) : (
<div className="p-2 text-center text-xs text-muted-foreground">Unable to load category content</div>
<div className="p-2 text-center text-xs text-muted-foreground">
{t("guardrails.contentFilter.categoryConfiguration.previewUnavailable")}
</div>
)}
</div>
)}
@ -339,18 +359,25 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
<ChevronRight className={`size-4 transition-transform ${isExpanded ? "rotate-90" : ""}`} />
<FileText className="size-4" />
<span>
View {fileType.toUpperCase()} for {category.display_name}
{t("guardrails.contentFilter.categoryConfiguration.viewContent", {
fileType: fileType.toUpperCase(),
name: category.display_name,
})}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
{loadingYaml[category.category] ? (
<div className="p-4 text-center text-muted-foreground">Loading content...</div>
<div className="p-4 text-center text-muted-foreground">
{t("guardrails.contentFilter.categoryConfiguration.loadingContent")}
</div>
) : categoryYaml[category.category] ? (
<pre className="m-0 max-h-[400px] overflow-auto rounded-md bg-muted p-4 text-xs leading-relaxed">
<code>{categoryYaml[category.category]}</code>
</pre>
) : (
<div className="p-4 text-center text-muted-foreground">Content will load when expanded</div>
<div className="p-4 text-center text-muted-foreground">
{t("guardrails.contentFilter.categoryConfiguration.contentLoadsOnExpand")}
</div>
)}
</CollapsibleContent>
</Collapsible>
@ -360,7 +387,7 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
</>
) : (
<div className="rounded-md border border-dashed border-border p-6 text-center text-muted-foreground">
No blocked topics selected. Add topics to detect and block harmful content.
{t("guardrails.contentFilter.categoryConfiguration.empty")}
</div>
)}
</CardContent>

View file

@ -1,4 +1,5 @@
import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Upload } from "lucide-react";
import { validateBlockedWordsFile } from "@/components/networking";
import { toast } from "@/lib/toast";
@ -102,6 +103,7 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
competitorIntentConfig = null,
onCompetitorIntentChange,
}) => {
const { t } = useTranslation();
const [patternModalVisible, setPatternModalVisible] = useState(false);
const [keywordModalVisible, setKeywordModalVisible] = useState(false);
const [customPatternModalVisible, setCustomPatternModalVisible] = useState(false);
@ -119,7 +121,7 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
const handleAddPrebuiltPattern = () => {
if (!selectedPatternName) {
toast.error("Please select a pattern");
toast.error(t("guardrails.contentFilter.configuration.toasts.selectPattern"));
return;
}
@ -140,7 +142,7 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
const handleAddCustomPattern = () => {
if (!customPatternName || !customPatternRegex) {
toast.error("Please provide pattern name and regex");
toast.error(t("guardrails.contentFilter.configuration.toasts.patternNameAndRegexRequired"));
return;
}
@ -160,7 +162,7 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
const handleAddKeyword = () => {
if (!newKeyword) {
toast.error("Please enter a keyword");
toast.error(t("guardrails.contentFilter.configuration.toasts.keywordRequired"));
return;
}
@ -188,14 +190,17 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
if (onFileUpload) {
onFileUpload(content);
}
toast.success(result.message || "File uploaded successfully");
toast.success(result.message || t("guardrails.contentFilter.configuration.toasts.uploadSucceeded"));
} else {
const errorMessage = result.error || (result.errors && result.errors.join(", ")) || "Invalid file";
toast.error(`Validation failed: ${errorMessage}`);
const errorMessage =
result.error ||
(result.errors && result.errors.join(", ")) ||
t("guardrails.contentFilter.configuration.toasts.invalidFile");
toast.error(t("guardrails.contentFilter.configuration.toasts.validationFailed", { error: errorMessage }));
}
}
} catch (error) {
toast.error(`Failed to upload file: ${error}`);
toast.error(t("guardrails.contentFilter.configuration.toasts.uploadFailed", { error: String(error) }));
} finally {
setUploadValidating(false);
}
@ -219,10 +224,7 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
<div className="space-y-6">
{!showStep && (
<div>
<p className="text-muted-foreground">
Configure patterns, keywords, and content categories to detect and filter sensitive information in requests
and responses.
</p>
<p className="text-muted-foreground">{t("guardrails.contentFilter.configuration.intro")}</p>
</div>
)}
@ -230,9 +232,9 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>Pattern Detection</CardTitle>
<CardTitle>{t("guardrails.contentFilter.configuration.patterns.title")}</CardTitle>
<p className="text-sm font-normal text-muted-foreground">
Detect sensitive information using regex patterns (SSN, credit cards, API keys, etc.)
{t("guardrails.contentFilter.configuration.patterns.description")}
</p>
</div>
</CardHeader>
@ -240,11 +242,11 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
<div className="mb-4 flex flex-wrap gap-2">
<Button onClick={() => setPatternModalVisible(true)}>
<Plus />
Add prebuilt pattern
{t("guardrails.contentFilter.configuration.patterns.addPrebuilt")}
</Button>
<Button variant="outline" onClick={() => setCustomPatternModalVisible(true)}>
<Plus />
Add custom regex
{t("guardrails.contentFilter.configuration.patterns.addCustom")}
</Button>
</div>
<PatternTable
@ -260,9 +262,9 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>Blocked Keywords</CardTitle>
<CardTitle>{t("guardrails.contentFilter.configuration.keywords.title")}</CardTitle>
<p className="text-sm font-normal text-muted-foreground">
Block or mask specific sensitive terms and phrases
{t("guardrails.contentFilter.configuration.keywords.description")}
</p>
</div>
</CardHeader>
@ -270,7 +272,7 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
<div className="mb-4 flex flex-wrap gap-2">
<Button onClick={() => setKeywordModalVisible(true)}>
<Plus />
Add keyword
{t("guardrails.contentFilter.configuration.keywords.addKeyword")}
</Button>
<input
ref={fileInputRef}
@ -286,7 +288,7 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
onClick={() => fileInputRef.current?.click()}
>
{uploadValidating ? <UiLoadingSpinner className="size-4" /> : <Upload />}
Upload YAML file
{t("guardrails.contentFilter.configuration.keywords.uploadYaml")}
</Button>
</div>
<KeywordTable keywords={blockedWords} onActionChange={onBlockedWordUpdate} onRemove={onBlockedWordRemove} />

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import PatternTable from "./PatternTable";
@ -56,6 +57,8 @@ const ContentFilterDisplay: React.FC<ContentFilterDisplayProps> = ({
onCategorySeverityChange,
onCategoryRemove,
}) => {
const { t } = useTranslation();
if (patterns.length === 0 && blockedWords.length === 0 && categories.length === 0) {
return null;
}
@ -69,8 +72,11 @@ const ContentFilterDisplay: React.FC<ContentFilterDisplayProps> = ({
<Card className="mt-6">
<CardContent>
<div className="mb-4 flex items-center justify-between">
<p className="text-lg font-semibold">Content Categories</p>
<Badge variant="secondary">{categories.length} categories configured</Badge>
<p className="text-lg font-semibold">{t("guardrails.contentFilter.display.categoriesTitle")}</p>
<Badge variant="secondary">
{categories.length}{" "}
{t("guardrails.contentFilter.display.categoriesConfigured", { count: categories.length })}
</Badge>
</div>
<CategoryTable
categories={categories}
@ -87,8 +93,10 @@ const ContentFilterDisplay: React.FC<ContentFilterDisplayProps> = ({
<Card className="mt-6">
<CardContent>
<div className="mb-4 flex items-center justify-between">
<p className="text-lg font-semibold">Pattern Detection</p>
<Badge variant="secondary">{patterns.length} patterns configured</Badge>
<p className="text-lg font-semibold">{t("guardrails.contentFilter.display.patternsTitle")}</p>
<Badge variant="secondary">
{patterns.length} {t("guardrails.contentFilter.display.patternsConfigured", { count: patterns.length })}
</Badge>
</div>
<PatternTable
patterns={patterns}
@ -103,8 +111,11 @@ const ContentFilterDisplay: React.FC<ContentFilterDisplayProps> = ({
<Card className="mt-6">
<CardContent>
<div className="mb-4 flex items-center justify-between">
<p className="text-lg font-semibold">Blocked Keywords</p>
<Badge variant="secondary">{blockedWords.length} keywords configured</Badge>
<p className="text-lg font-semibold">{t("guardrails.contentFilter.display.keywordsTitle")}</p>
<Badge variant="secondary">
{blockedWords.length}{" "}
{t("guardrails.contentFilter.display.keywordsConfigured", { count: blockedWords.length })}
</Badge>
</div>
<KeywordTable
keywords={blockedWords}

View file

@ -1,5 +1,6 @@
import { TriangleAlert } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, AlertDescription } from "@/components/shared/Alert";
import { Separator } from "@/components/ui/separator";
import ContentFilterConfiguration from "./ContentFilterConfiguration";
@ -74,6 +75,7 @@ const ContentFilterManager: React.FC<ContentFilterManagerProps> = ({
onDataChange,
onUnsavedChanges,
}) => {
const { t } = useTranslation();
const [selectedPatterns, setSelectedPatterns] = useState<Pattern[]>([]);
const [blockedWords, setBlockedWords] = useState<BlockedWord[]>([]);
const [selectedContentCategories, setSelectedContentCategories] = useState<SelectedContentCategory[]>([]);
@ -234,15 +236,13 @@ const ContentFilterManager: React.FC<ContentFilterManagerProps> = ({
return (
<>
<div className="my-6 flex items-center gap-4">
<span className="shrink-0 font-medium">Content Filter Configuration</span>
<span className="shrink-0 font-medium">{t("guardrails.contentFilter.manager.heading")}</span>
<Separator className="flex-1" />
</div>
{hasUnsavedChanges && (
<Alert variant="warning" className="mb-4">
<TriangleAlert />
<AlertDescription>
You have unsaved changes to patterns or keywords. Remember to click &quot;Save Changes&quot; at the bottom.
</AlertDescription>
<AlertDescription>{t("guardrails.contentFilter.manager.unsavedChanges")}</AlertDescription>
</Alert>
)}
<div className="mb-6">

View file

@ -1,9 +1,10 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACTION_ITEMS } from "./action_options";
import { ACTION_ITEMS, translateOptions } from "./action_options";
interface CustomPatternModalProps {
visible: boolean;
@ -28,50 +29,61 @@ const CustomPatternModal: React.FC<CustomPatternModalProps> = ({
onAdd,
onCancel,
}) => {
const { t } = useTranslation();
const actionItems = translateOptions(ACTION_ITEMS, t);
return (
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>Add custom regex pattern</DialogTitle>
<DialogTitle>{t("guardrails.contentFilter.customPatternModal.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div>
<p className="font-semibold">Pattern name</p>
<p className="font-semibold">{t("guardrails.contentFilter.customPatternModal.nameLabel")}</p>
<Input
className="mt-2"
placeholder="e.g., internal_id, employee_code"
placeholder={t("guardrails.contentFilter.customPatternModal.namePlaceholder", {
example: "internal_id, employee_code",
})}
value={patternName}
onChange={(e) => onNameChange(e.target.value)}
/>
</div>
<div>
<p className="font-semibold">Regex pattern</p>
<p className="font-semibold">{t("guardrails.contentFilter.customPatternModal.regexLabel")}</p>
<Input
className="mt-2"
placeholder="e.g., ID-[0-9]{6}"
placeholder={t("guardrails.contentFilter.customPatternModal.regexPlaceholder", {
example: "ID-[0-9]{6}",
})}
value={patternRegex}
onChange={(e) => onRegexChange(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Enter a valid regular expression to match sensitive data</p>
<p className="text-xs text-muted-foreground">
{t("guardrails.contentFilter.customPatternModal.regexHelp")}
</p>
</div>
<div>
<p className="font-semibold">Action</p>
<p className="font-semibold">{t("guardrails.contentFilter.customPatternModal.actionLabel")}</p>
<p className="mt-1 mb-2 text-muted-foreground">
Choose what action the guardrail should take when this pattern is detected
{t("guardrails.contentFilter.customPatternModal.actionDescription")}
</p>
<Select
items={ACTION_ITEMS}
items={actionItems}
value={patternAction}
onValueChange={(value: string | null) => value && onActionChange(value as "BLOCK" | "MASK")}
>
<SelectTrigger className="w-full" aria-label="Action">
<SelectTrigger
className="w-full"
aria-label={t("guardrails.contentFilter.customPatternModal.actionLabel")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_ITEMS.map((item) => (
{actionItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -83,9 +95,9 @@ const CustomPatternModal: React.FC<CustomPatternModalProps> = ({
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
{t("guardrails.contentFilter.customPatternModal.cancel")}
</Button>
<Button onClick={onAdd}>Add</Button>
<Button onClick={onAdd}>{t("guardrails.contentFilter.customPatternModal.add")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -1,10 +1,11 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ACTION_ITEMS } from "./action_options";
import { ACTION_ITEMS, translateOptions } from "./action_options";
interface KeywordModalProps {
visible: boolean;
@ -29,39 +30,41 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
onAdd,
onCancel,
}) => {
const { t } = useTranslation();
const actionItems = translateOptions(ACTION_ITEMS, t);
return (
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>Add blocked keyword</DialogTitle>
<DialogTitle>{t("guardrails.contentFilter.keywordModal.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div>
<p className="font-semibold">Keyword</p>
<p className="font-semibold">{t("guardrails.contentFilter.keywordModal.keywordLabel")}</p>
<Input
className="mt-2"
placeholder="Enter sensitive keyword or phrase"
placeholder={t("guardrails.contentFilter.keywordModal.keywordPlaceholder")}
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
/>
</div>
<div>
<p className="font-semibold">Action</p>
<p className="font-semibold">{t("guardrails.contentFilter.keywordModal.actionLabel")}</p>
<p className="mt-1 mb-2 text-muted-foreground">
Choose what action the guardrail should take when this keyword is detected
{t("guardrails.contentFilter.keywordModal.actionDescription")}
</p>
<Select
items={ACTION_ITEMS}
items={actionItems}
value={action}
onValueChange={(value: string | null) => value && onActionChange(value as "BLOCK" | "MASK")}
>
<SelectTrigger className="w-full" aria-label="Action">
<SelectTrigger className="w-full" aria-label={t("guardrails.contentFilter.keywordModal.actionLabel")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_ITEMS.map((item) => (
{actionItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -71,10 +74,10 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
</div>
<div>
<p className="font-semibold">Description (optional)</p>
<p className="font-semibold">{t("guardrails.contentFilter.keywordModal.descriptionLabel")}</p>
<Textarea
className="mt-2 field-sizing-fixed"
placeholder="Explain why this keyword is sensitive"
placeholder={t("guardrails.contentFilter.keywordModal.descriptionPlaceholder")}
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
rows={3}
@ -84,9 +87,9 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
{t("guardrails.contentFilter.keywordModal.cancel")}
</Button>
<Button onClick={onAdd}>Add</Button>
<Button onClick={onAdd}>{t("guardrails.contentFilter.keywordModal.add")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -1,10 +1,11 @@
import { Trash2 } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import React from "react";
import { useTranslation } from "react-i18next";
import { DataTable } from "@/components/shared/DataTable";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACTION_ITEMS } from "./action_options";
import { ACTION_ITEMS, translateOptions } from "./action_options";
interface BlockedWord {
id: string;
@ -20,26 +21,32 @@ interface KeywordTableProps {
}
const KeywordTable: React.FC<KeywordTableProps> = ({ keywords, onActionChange, onRemove }) => {
const { t } = useTranslation();
const actionItems = translateOptions(ACTION_ITEMS, t);
const columns: ColumnDef<BlockedWord>[] = [
{
header: "Keyword",
header: t("guardrails.contentFilter.keywordTable.columns.keyword"),
accessorKey: "keyword",
},
{
header: "Action",
header: t("guardrails.contentFilter.keywordTable.columns.action"),
accessorKey: "action",
size: 150,
cell: ({ row }) => (
<Select
items={ACTION_ITEMS}
items={actionItems}
value={row.original.action}
onValueChange={(value: string | null) => value && onActionChange(row.original.id, "action", value)}
>
<SelectTrigger size="sm" className="w-[120px]" aria-label="Action">
<SelectTrigger
size="sm"
className="w-[120px]"
aria-label={t("guardrails.contentFilter.keywordTable.columns.action")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_ITEMS.map((item) => (
{actionItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -49,7 +56,7 @@ const KeywordTable: React.FC<KeywordTableProps> = ({ keywords, onActionChange, o
),
},
{
header: "Description",
header: t("guardrails.contentFilter.keywordTable.columns.description"),
accessorKey: "description",
cell: ({ row }) => row.original.description || "-",
},
@ -60,14 +67,16 @@ const KeywordTable: React.FC<KeywordTableProps> = ({ keywords, onActionChange, o
cell: ({ row }) => (
<Button variant="ghost" size="sm" onClick={() => onRemove(row.original.id)}>
<Trash2 />
Delete
{t("guardrails.contentFilter.keywordTable.delete")}
</Button>
),
},
];
if (keywords.length === 0) {
return <div className="py-10 text-center text-muted-foreground">No keywords added.</div>;
return (
<div className="py-10 text-center text-muted-foreground">{t("guardrails.contentFilter.keywordTable.empty")}</div>
);
}
return <DataTable data={keywords} columns={columns} getRowId={(row) => row.id} size="compact" />;

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Combobox,
@ -13,7 +14,7 @@ import {
} from "@/components/ui/combobox";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACTION_ITEMS } from "./action_options";
import { ACTION_ITEMS, translateOptions } from "./action_options";
interface PrebuiltPattern {
name: string;
@ -55,6 +56,8 @@ const PatternModal: React.FC<PatternModalProps> = ({
onAdd,
onCancel,
}) => {
const { t } = useTranslation();
const actionItems = translateOptions(ACTION_ITEMS, t);
const selectedPattern = prebuiltPatterns.find((pattern) => pattern.name === selectedPatternName) ?? null;
const patternGroups = categories
.map((category) => ({
@ -67,12 +70,12 @@ const PatternModal: React.FC<PatternModalProps> = ({
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>Add prebuilt pattern</DialogTitle>
<DialogTitle>{t("guardrails.contentFilter.patternModal.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div>
<p className="font-semibold">Pattern type</p>
<p className="font-semibold">{t("guardrails.contentFilter.patternModal.patternTypeLabel")}</p>
<Combobox
items={patternGroups}
value={selectedPattern}
@ -80,9 +83,12 @@ const PatternModal: React.FC<PatternModalProps> = ({
itemToStringLabel={(pattern: PrebuiltPattern) => pattern.display_name}
filter={matchesPatternQuery}
>
<ComboboxInput className="mt-2 w-full" placeholder="Choose pattern type" />
<ComboboxInput
className="mt-2 w-full"
placeholder={t("guardrails.contentFilter.patternModal.patternTypePlaceholder")}
/>
<ComboboxContent>
<ComboboxEmpty>No matching patterns</ComboboxEmpty>
<ComboboxEmpty>{t("guardrails.contentFilter.patternModal.noMatches")}</ComboboxEmpty>
<ComboboxList>
{(group: PatternGroup) => (
<ComboboxGroup key={group.category} items={group.items}>
@ -102,20 +108,20 @@ const PatternModal: React.FC<PatternModalProps> = ({
</div>
<div>
<p className="font-semibold">Action</p>
<p className="font-semibold">{t("guardrails.contentFilter.patternModal.actionLabel")}</p>
<p className="mt-1 mb-2 text-muted-foreground">
Choose what action the guardrail should take when this pattern is detected
{t("guardrails.contentFilter.patternModal.actionDescription")}
</p>
<Select
items={ACTION_ITEMS}
items={actionItems}
value={patternAction}
onValueChange={(value: string | null) => value && onActionChange(value as "BLOCK" | "MASK")}
>
<SelectTrigger className="w-full" aria-label="Action">
<SelectTrigger className="w-full" aria-label={t("guardrails.contentFilter.patternModal.actionLabel")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_ITEMS.map((item) => (
{actionItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -127,9 +133,9 @@ const PatternModal: React.FC<PatternModalProps> = ({
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
{t("guardrails.contentFilter.patternModal.cancel")}
</Button>
<Button onClick={onAdd}>Add</Button>
<Button onClick={onAdd}>{t("guardrails.contentFilter.patternModal.add")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -1,11 +1,12 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Trash2 } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@/components/shared/DataTable";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACTION_ITEMS } from "./action_options";
import { ACTION_ITEMS, translateOptions } from "./action_options";
interface Pattern {
id: string;
@ -23,20 +24,28 @@ interface PatternTableProps {
}
const PatternTable: React.FC<PatternTableProps> = ({ patterns, onActionChange, onRemove }) => {
const { t } = useTranslation();
const actionItems = translateOptions(ACTION_ITEMS, t);
const columns: ColumnDef<Pattern>[] = [
{
header: "Type",
header: t("guardrails.contentFilter.patternTable.columns.type"),
accessorKey: "type",
size: 100,
cell: ({ row }) => <Badge variant="secondary">{row.original.type === "prebuilt" ? "Prebuilt" : "Custom"}</Badge>,
cell: ({ row }) => (
<Badge variant="secondary">
{row.original.type === "prebuilt"
? t("guardrails.contentFilter.patternTable.types.prebuilt")
: t("guardrails.contentFilter.patternTable.types.custom")}
</Badge>
),
},
{
header: "Pattern name",
header: t("guardrails.contentFilter.patternTable.columns.name"),
accessorKey: "name",
cell: ({ row }) => row.original.display_name || row.original.name,
},
{
header: "Regex pattern",
header: t("guardrails.contentFilter.patternTable.columns.regex"),
accessorKey: "pattern",
cell: ({ row }) =>
row.original.pattern ? (
@ -46,20 +55,24 @@ const PatternTable: React.FC<PatternTableProps> = ({ patterns, onActionChange, o
),
},
{
header: "Action",
header: t("guardrails.contentFilter.patternTable.columns.action"),
accessorKey: "action",
size: 150,
cell: ({ row }) => (
<Select
items={ACTION_ITEMS}
items={actionItems}
value={row.original.action}
onValueChange={(value: string | null) => value && onActionChange(row.original.id, value as "BLOCK" | "MASK")}
>
<SelectTrigger size="sm" className="w-[120px]" aria-label="Action">
<SelectTrigger
size="sm"
className="w-[120px]"
aria-label={t("guardrails.contentFilter.patternTable.columns.action")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_ITEMS.map((item) => (
{actionItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -75,14 +88,16 @@ const PatternTable: React.FC<PatternTableProps> = ({ patterns, onActionChange, o
cell: ({ row }) => (
<Button variant="ghost" size="sm" onClick={() => onRemove(row.original.id)}>
<Trash2 />
Delete
{t("guardrails.contentFilter.patternTable.delete")}
</Button>
),
},
];
if (patterns.length === 0) {
return <div className="py-10 text-center text-muted-foreground">No patterns added.</div>;
return (
<div className="py-10 text-center text-muted-foreground">{t("guardrails.contentFilter.patternTable.empty")}</div>
);
}
return <DataTable data={patterns} columns={columns} getRowId={(row) => row.id} size="compact" />;

View file

@ -1,10 +1,20 @@
export const ACTION_ITEMS = [
{ value: "BLOCK", label: "Block" },
{ value: "MASK", label: "Mask" },
] as const;
import type { ParseKeys, TFunction } from "i18next";
export const SEVERITY_ITEMS = [
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
] as const;
interface OptionItem<V extends string> {
value: V;
labelKey: ParseKeys;
}
export const ACTION_ITEMS: readonly OptionItem<"BLOCK" | "MASK">[] = [
{ value: "BLOCK", labelKey: "guardrails.contentFilter.options.action.block" },
{ value: "MASK", labelKey: "guardrails.contentFilter.options.action.mask" },
];
export const SEVERITY_ITEMS: readonly OptionItem<"high" | "medium" | "low">[] = [
{ value: "high", labelKey: "guardrails.contentFilter.options.severity.high" },
{ value: "medium", labelKey: "guardrails.contentFilter.options.severity.medium" },
{ value: "low", labelKey: "guardrails.contentFilter.options.severity.low" },
];
export const translateOptions = <V extends string>(items: readonly OptionItem<V>[], t: TFunction) =>
items.map(({ value, labelKey }) => ({ value, label: t(labelKey) }));

View file

@ -1,4 +1,5 @@
import React from "react";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
choiceToLoggingOnlyScope,
effectiveLoggingOnlyContinue,
@ -9,8 +10,9 @@ import {
} from "../guardrail_info_helpers";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const LOGGING_ONLY_SCOPE_ITEMS = getLoggingOnlyScopeOptions(true);
const LOGGING_ONLY_SCOPE_CHOICES: ReadonlySet<string> = new Set(LOGGING_ONLY_SCOPE_ITEMS.map(({ value }) => value));
const LOGGING_ONLY_SCOPE_CHOICES: ReadonlySet<string> = new Set(
getLoggingOnlyScopeOptions(true).map(({ value }) => value),
);
const isLoggingOnlyScopeChoice = (value: unknown): value is LoggingOnlyScopeChoice =>
typeof value === "string" && LOGGING_ONLY_SCOPE_CHOICES.has(value);
@ -18,29 +20,35 @@ const isLoggingOnlyScopeChoice = (value: unknown): value is LoggingOnlyScopeChoi
export const CustomCodeLoggingOnlyScopeSelect: React.FC<{
value: LoggingOnlyScopeChoice;
onChange: (choice: LoggingOnlyScopeChoice) => void;
}> = ({ value, onChange }) => (
<div className="w-[240px]">
<label className="mb-1 block text-xs font-medium text-muted-foreground">Logging only scope</label>
<Select
items={LOGGING_ONLY_SCOPE_ITEMS}
value={value}
onValueChange={(nextValue) => {
if (isLoggingOnlyScopeChoice(nextValue)) onChange(nextValue);
}}
>
<SelectTrigger className="w-full" aria-label="Logging only scope">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOGGING_ONLY_SCOPE_ITEMS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}> = ({ value, onChange }) => {
const { t } = useTranslation();
const items = useMemo(() => getLoggingOnlyScopeOptions(true, t), [t]);
return (
<div className="w-[240px]">
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{t("guardrails.customCode.loggingOnlyScope.label")}
</label>
<Select
items={items}
value={value}
onValueChange={(nextValue) => {
if (isLoggingOnlyScopeChoice(nextValue)) onChange(nextValue);
}}
>
<SelectTrigger className="w-full" aria-label={t("guardrails.customCode.loggingOnlyScope.label")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{items.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
export const getCustomCodeLoggingOnlyScopeCreate = (
mode: string[],

View file

@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect } from "react";
import React, { useState, useRef, useEffect, useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import { CheckCircle2, ChevronRight, Code, ExternalLink, PlayCircle, Save, Users, XCircle } from "lucide-react";
import { createGuardrailCall, updateGuardrailCall, testCustomCodeGuardrail } from "@/components/networking";
import { toast } from "@/lib/toast";
@ -47,9 +48,9 @@ import {
} from "../guardrail_info_helpers";
import {
CODE_TEMPLATES,
MODE_OPTION_BY_VALUE,
DEFAULT_OPEN_PRIMITIVE_CATEGORY,
MODE_OPTIONS,
PRIMITIVES,
PRIMITIVE_CATEGORIES,
TEMPLATE_ITEMS,
type ModeOption,
} from "./custom_code_catalog";
@ -78,6 +79,7 @@ interface CustomCodeModalProps {
}
const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onSuccess, accessToken, editData }) => {
const { t } = useTranslation();
const anchor = useComboboxAnchor();
const isEditMode = !!editData;
const [guardrailName, setGuardrailName] = useState("");
@ -268,15 +270,15 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
// Save guardrail (create or update)
const handleSave = async () => {
if (!guardrailName.trim()) {
toast.fromError("Please enter a guardrail name");
toast.fromError(t("guardrails.customCode.toast.nameRequired"));
return;
}
if (!code.trim()) {
toast.fromError("Please enter custom code");
toast.fromError(t("guardrails.customCode.toast.codeRequired"));
return;
}
if (!accessToken) {
toast.fromError("No access token available");
toast.fromError(t("guardrails.customCode.toast.noAccessToken"));
return;
}
@ -319,7 +321,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
}
await updateGuardrailCall(accessToken, editData.guardrail_id, updateData);
toast.success("Custom code guardrail updated successfully");
toast.success(t("guardrails.customCode.toast.updated"));
} else {
// Create new guardrail
const streamScope = streamScopePayload(mode, streamScopeByMode);
@ -337,15 +339,16 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
};
await createGuardrailCall(accessToken, guardrailData);
toast.success("Custom code guardrail created successfully");
toast.success(t("guardrails.customCode.toast.created"));
}
onSuccess();
onClose();
} catch (error) {
console.error("Failed to save guardrail:", error);
toast.fromError(
`Failed to ${isEditMode ? "update" : "create"} guardrail: ` +
(error instanceof Error ? error.message : String(error)),
t(isEditMode ? "guardrails.customCode.toast.updateFailed" : "guardrails.customCode.toast.createFailed", {
error: error instanceof Error ? error.message : String(error),
}),
);
} finally {
setIsSaving(false);
@ -355,7 +358,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
// Test guardrail using backend endpoint
const handleTest = async () => {
if (!accessToken) {
setTestResult({ error: "No access token available" });
setTestResult({ error: t("guardrails.customCode.test.errors.noAccessToken") });
return;
}
@ -368,7 +371,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
try {
parsedInput = JSON.parse(testInput);
} catch (e) {
setTestResult({ error: "Invalid test input JSON" });
setTestResult({ error: t("guardrails.customCode.test.errors.invalidInput") });
setIsTesting(false);
return;
}
@ -405,12 +408,12 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
error_type: response.error_type,
});
} else {
setTestResult({ error: "Unknown error occurred" });
setTestResult({ error: t("guardrails.customCode.test.errors.unknown") });
}
} catch (error) {
console.error("Failed to test custom code:", error);
setTestResult({
error: error instanceof Error ? error.message : "Failed to test custom code",
error: error instanceof Error ? error.message : t("guardrails.customCode.test.errors.failed"),
});
} finally {
setIsTesting(false);
@ -418,7 +421,19 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
};
const lineCount = code.split("\n").length;
const selectedModeOptions = mode.map((value) => MODE_OPTION_BY_VALUE[value]).filter(Boolean);
const modeOptions: ModeOption[] = useMemo(
() => MODE_OPTIONS.map(({ value, labelKey }) => ({ value, label: t(labelKey) })),
[t],
);
const modeOptionByValue: Record<string, ModeOption> = useMemo(
() => Object.fromEntries(modeOptions.map((option) => [option.value, option])),
[modeOptions],
);
const templateItems = useMemo(
() => TEMPLATE_ITEMS.map(({ value, labelKey }) => ({ value, label: t(labelKey) })),
[t],
);
const selectedModeOptions = mode.map((value) => modeOptionByValue[value]).filter(Boolean);
const rawEditMode = editData?.litellm_params?.mode;
const tagScopedModeLabel =
rawEditMode !== null && typeof rawEditMode === "object" && !Array.isArray(rawEditMode)
@ -430,30 +445,38 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1400px]">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
{isEditMode ? "Edit Custom Guardrail" : "Create Custom Guardrail"}
{isEditMode ? t("guardrails.customCode.title.edit") : t("guardrails.customCode.title.create")}
</DialogTitle>
<DialogDescription>Define custom logic using Python-like syntax</DialogDescription>
<DialogDescription>{t("guardrails.customCode.description")}</DialogDescription>
</DialogHeader>
{/* Top Controls */}
<div className="flex flex-wrap items-center gap-4 border-b border-border py-4">
<div className="min-w-[160px] max-w-[200px] flex-1">
<label className="mb-1 block text-xs font-medium text-muted-foreground">Guardrail Name</label>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{t("guardrails.customCode.nameLabel")}
</label>
<Input
value={guardrailName}
onChange={(e) => setGuardrailName(e.target.value)}
placeholder="e.g., block-pii-custom"
placeholder={t("guardrails.customCode.namePlaceholder")}
/>
</div>
<div className="w-[280px]">
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{tagScopedModeLabel ? "Mode (tag-scoped, read-only)" : "Mode (can select multiple)"}
{tagScopedModeLabel
? t("guardrails.customCode.modeLabel.tagScoped")
: t("guardrails.customCode.modeLabel.multiple")}
</label>
{tagScopedModeLabel ? (
<Input value={tagScopedModeLabel} disabled aria-label="Mode (tag-scoped)" />
<Input
value={tagScopedModeLabel}
disabled
aria-label={t("guardrails.customCode.tagScopedModeAriaLabel")}
/>
) : (
<Combobox
items={MODE_OPTIONS}
items={modeOptions}
value={selectedModeOptions}
onValueChange={(options: ModeOption[]) => setMode(options.map((option) => option.value))}
multiple
@ -464,10 +487,12 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
{option.label}
</ComboboxChip>
))}
<ComboboxChipsInput placeholder={mode.length === 0 ? "Select modes" : undefined} />
<ComboboxChipsInput
placeholder={mode.length === 0 ? t("guardrails.customCode.modePlaceholder") : undefined}
/>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No matching modes</ComboboxEmpty>
<ComboboxEmpty>{t("guardrails.customCode.noMatchingModes")}</ComboboxEmpty>
<ComboboxList>
{(option: ModeOption) => (
<ComboboxItem key={option.value} value={option}>
@ -484,31 +509,31 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
)}
{mode.includes("logging_only") && (
<div className="flex shrink-0 items-center gap-2 pt-5">
<span className="text-sm text-muted-foreground">
Continue observing the response after a flagged request
</span>
<span className="text-sm text-muted-foreground">{t("guardrails.customCode.continueObserving")}</span>
<Switch
checked={loggingOnlyScopeChoice === "default" && loggingOnlyContinue}
disabled={loggingOnlyScopeChoice !== "default"}
onCheckedChange={setLoggingOnlyContinue}
aria-label="Continue observing the response after a flagged request"
aria-label={t("guardrails.customCode.continueObserving")}
/>
</div>
)}
<div className="w-[180px]">
<label className="mb-1 block text-xs font-medium text-muted-foreground">Template</label>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{t("guardrails.customCode.templateLabel")}
</label>
<Select
items={TEMPLATE_ITEMS}
items={templateItems}
value={selectedTemplate}
onValueChange={(value: string | null) => value && handleTemplateChange(value)}
>
<SelectTrigger className="w-full" aria-label="Template">
<SelectTrigger className="w-full" aria-label={t("guardrails.customCode.templateLabel")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>STANDARD</SelectLabel>
{TEMPLATE_ITEMS.map((template) => (
<SelectLabel>{t("guardrails.customCode.standardTemplates")}</SelectLabel>
{templateItems.map((template) => (
<SelectItem key={template.value} value={template.value}>
{template.label}
</SelectItem>
@ -521,15 +546,19 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
className="flex w-full items-center gap-1 rounded-sm px-2 py-1.5 text-xs text-primary hover:bg-accent"
>
<Users className="size-3.5" />
<span>Browse Community templates</span>
<span>{t("guardrails.customCode.browseCommunity")}</span>
<ExternalLink className="size-2.5" />
</button>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 pt-5">
<span className="text-sm text-muted-foreground">Default On</span>
<Switch checked={defaultOn} onCheckedChange={setDefaultOn} aria-label="Default On" />
<span className="text-sm text-muted-foreground">{t("guardrails.customCode.defaultOn")}</span>
<Switch
checked={defaultOn}
onCheckedChange={setDefaultOn}
aria-label={t("guardrails.customCode.defaultOn")}
/>
</div>
</div>
{mode.length > 0 && (
@ -543,8 +572,10 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
{/* Code Editor */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="mb-2 flex shrink-0 items-center justify-between">
<span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">Python Logic</span>
<span className="text-xs text-muted-foreground">Restricted environment (no imports)</span>
<span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
{t("guardrails.customCode.pythonLogic")}
</span>
<span className="text-xs text-muted-foreground">{t("guardrails.customCode.restrictedEnvironment")}</span>
</div>
<div
className="relative rounded-lg overflow-hidden border border-gray-700 bg-[#1e1e1e] shrink-0"
@ -591,59 +622,87 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
<CollapsibleTrigger className="flex w-full items-center gap-2 p-3 text-sm font-medium">
<ChevronRight className={`size-4 transition-transform ${testExpanded ? "rotate-90" : ""}`} />
<PlayCircle className="size-4 text-muted-foreground" />
Test Your Guardrail
{t("guardrails.customCode.test.title")}
</CollapsibleTrigger>
<CollapsibleContent className="p-3 pt-0">
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-medium text-muted-foreground">Test Input (JSON)</label>
<label className="block text-xs font-medium text-muted-foreground">
{t("guardrails.customCode.test.inputLabel")}
</label>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Load example:</span>
<span className="text-xs text-muted-foreground">
{t("guardrails.customCode.test.loadExample")}
</span>
<button
type="button"
onClick={() => setTestInput(JSON.stringify(TEST_INPUT_EXAMPLES.pre_call.data, null, 2))}
className="px-2 py-1 text-xs rounded-sm border border-warning/20 bg-warning/10 text-warning hover:bg-warning/15 transition-colors"
>
Pre-call
{t("guardrails.customCode.test.examples.preCall")}
</button>
<button
type="button"
onClick={() => setTestInput(JSON.stringify(TEST_INPUT_EXAMPLES.pre_mcp_call.data, null, 2))}
className="px-2 py-1 text-xs rounded-sm border border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100 transition-colors dark:border-purple-800 dark:bg-purple-950 dark:text-purple-300 dark:hover:bg-purple-900"
>
Pre MCP
{t("guardrails.customCode.test.examples.preMcp")}
</button>
<button
type="button"
onClick={() => setTestInput(JSON.stringify(TEST_INPUT_EXAMPLES.post_call.data, null, 2))}
className="px-2 py-1 text-xs rounded-sm border border-success/20 bg-success/10 text-success hover:bg-success/15 transition-colors"
>
Post-call
{t("guardrails.customCode.test.examples.postCall")}
</button>
</div>
</div>
<div className="mb-2 rounded-sm border border-border bg-muted/40 p-2 text-xs text-muted-foreground">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<div>
<strong>texts</strong>: Message content (always)
<Trans
i18nKey="guardrails.customCode.test.fields.texts"
components={{ field: <strong>texts</strong> }}
/>
</div>
<div>
<strong>images</strong>: Base64 images (vision)
<Trans
i18nKey="guardrails.customCode.test.fields.images"
components={{ field: <strong>images</strong> }}
/>
</div>
<div>
<strong>tools</strong>: Tool definitions <span className="text-warning">(pre_call)</span>, MCP
as OpenAI tool <span className="text-purple-600">(pre_mcp_call)</span>
<Trans
i18nKey="guardrails.customCode.test.fields.tools"
components={{
field: <strong>tools</strong>,
preCall: <span className="text-warning">(pre_call)</span>,
preMcpCall: <span className="text-purple-600">(pre_mcp_call)</span>,
}}
/>
</div>
<div>
<strong>tool_calls</strong>: LLM tool calls <span className="text-success">(post_call)</span>
<Trans
i18nKey="guardrails.customCode.test.fields.toolCalls"
components={{
field: <strong>tool_calls</strong>,
postCall: <span className="text-success">(post_call)</span>,
}}
/>
</div>
<div>
<strong>structured_messages</strong>: Full messages{" "}
<Trans
i18nKey="guardrails.customCode.test.fields.structuredMessages"
components={{ field: <strong>structured_messages</strong> }}
/>{" "}
<span className="text-warning">(pre_call)</span>
</div>
<div>
<strong>model</strong>: Model name (always)
<Trans
i18nKey="guardrails.customCode.test.fields.model"
components={{ field: <strong>model</strong> }}
/>
</div>
</div>
</div>
@ -658,7 +717,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
<div className="flex items-center gap-3">
<Button size="sm" onClick={handleTest} disabled={isTesting} aria-busy={isTesting}>
{isTesting ? <UiLoadingSpinner className="size-4" /> : <PlayCircle />}
{isTesting ? "Running..." : "Run Test"}
{isTesting ? t("guardrails.customCode.test.running") : t("guardrails.customCode.test.run")}
</Button>
{testResult && (
<div
@ -682,15 +741,19 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
</>
) : testResult.action === "allow" ? (
<>
<CheckCircle2 className="size-4" /> Allowed
<CheckCircle2 className="size-4" />
{` ${t("guardrails.customCode.test.result.allowed")}`}
</>
) : testResult.action === "block" ? (
<>
<XCircle className="size-4" /> Blocked: {testResult.reason}
<XCircle className="size-4" />
{` ${t("guardrails.customCode.test.result.blocked")} `}
{testResult.reason}
</>
) : testResult.action === "modify" ? (
<>
<CheckCircle2 className="size-4" /> Modified
<CheckCircle2 className="size-4" />
{` ${t("guardrails.customCode.test.result.modified")}`}
{testResult.texts && testResult.texts.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
-&gt; {testResult.texts[0].substring(0, 50)}
@ -700,7 +763,8 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
</>
) : (
<>
<CheckCircle2 className="size-4" /> {testResult.action || "Unknown"}
<CheckCircle2 className="size-4" />{" "}
{testResult.action || t("guardrails.customCode.test.result.unknownAction")}
</>
)}
</div>
@ -716,15 +780,15 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
<Users className="size-5 text-info" />
</div>
<div>
<div className="text-sm font-medium">Built a useful guardrail?</div>
<div className="text-sm font-medium">{t("guardrails.customCode.contribute.title")}</div>
<div className="text-xs text-muted-foreground">
Share it with the community and help others build faster
{t("guardrails.customCode.contribute.description")}
</div>
</div>
</div>
<Button size="sm" onClick={() => window.open("https://github.com/BerriAI/litellm-guardrails", "_blank")}>
<ExternalLink />
Contribute Template
{t("guardrails.customCode.contribute.button")}
</Button>
</div>
</div>
@ -733,19 +797,19 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
<div className="w-[300px] shrink-0 overflow-auto border-l border-border pl-6">
<div className="mb-3 flex items-center gap-2">
<Code className="size-4 text-muted-foreground" />
<span className="font-semibold">Available Primitives</span>
<span className="font-semibold">{t("guardrails.customCode.primitives.title")}</span>
</div>
<p className="mb-3 text-xs text-muted-foreground">Click to copy functions to clipboard</p>
<p className="mb-3 text-xs text-muted-foreground">{t("guardrails.customCode.primitives.hint")}</p>
<div className="space-y-2">
{Object.entries(PRIMITIVES).map(([category, primitives]) => (
{PRIMITIVE_CATEGORIES.map(({ id, labelKey, primitives }) => (
<Collapsible
key={category}
defaultOpen={category === "Return Values"}
key={id}
defaultOpen={id === DEFAULT_OPEN_PRIMITIVE_CATEGORY}
className="rounded-lg border border-border"
>
<CollapsibleTrigger className="group flex w-full items-center justify-between px-3 py-2 text-sm font-medium">
{category}
{t(labelKey)}
<ChevronRight className="size-4 transition-transform group-data-panel-open:rotate-90" />
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-3">
@ -760,12 +824,13 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
>
{copiedPrimitive === p.name ? (
<span className="flex items-center gap-1 font-mono text-xs">
<CheckCircle2 className="size-3.5" /> Copied!
<CheckCircle2 className="size-3.5" />
{` ${t("guardrails.customCode.primitives.copied")}`}
</span>
) : (
<>
<div className="font-mono text-xs">{p.name}</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">{p.desc}</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">{t(p.descKey)}</div>
</>
)}
</button>
@ -780,14 +845,14 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
{/* Footer */}
<div className="mt-4 flex items-center justify-between border-t border-border pt-4">
<span className="text-xs text-muted-foreground">Changes are auto-saved to local draft</span>
<span className="text-xs text-muted-foreground">{t("guardrails.customCode.footer.autosaveNote")}</span>
<div className="flex items-center gap-3">
<Button variant="secondary" onClick={onClose}>
Cancel
{t("guardrails.customCode.footer.cancel")}
</Button>
<Button onClick={handleSave} disabled={isSaving || !guardrailName.trim()} aria-busy={isSaving}>
{isSaving ? <UiLoadingSpinner className="size-4" /> : <Save />}
{isEditMode ? "Update Guardrail" : "Save Guardrail"}
{isEditMode ? t("guardrails.customCode.footer.update") : t("guardrails.customCode.footer.save")}
</Button>
</div>
</div>

View file

@ -1,6 +1,8 @@
import type { ParseKeys } from "i18next";
export const CODE_TEMPLATES = {
empty: {
name: "Empty Template",
nameKey: "guardrails.customCode.templates.empty",
code: `async def apply_guardrail(inputs, request_data, input_type):
# inputs: {texts, images, tools, tool_calls, structured_messages, model}
# request_data: {model, user_id, team_id, end_user_id, metadata}
@ -8,7 +10,7 @@ export const CODE_TEMPLATES = {
return allow()`,
},
blockSSN: {
name: "Block SSN",
nameKey: "guardrails.customCode.templates.blockSsn",
code: `def apply_guardrail(inputs, request_data, input_type):
for text in inputs["texts"]:
if regex_match(text, r"\\d{3}-\\d{2}-\\d{4}"):
@ -16,7 +18,7 @@ export const CODE_TEMPLATES = {
return allow()`,
},
redactEmail: {
name: "Redact Emails",
nameKey: "guardrails.customCode.templates.redactEmail",
code: `def apply_guardrail(inputs, request_data, input_type):
pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
modified = []
@ -25,7 +27,7 @@ export const CODE_TEMPLATES = {
return modify(texts=modified)`,
},
blockSQL: {
name: "Block SQL Injection",
nameKey: "guardrails.customCode.templates.blockSql",
code: `def apply_guardrail(inputs, request_data, input_type):
if input_type != "request":
return allow()
@ -35,7 +37,7 @@ export const CODE_TEMPLATES = {
return allow()`,
},
validateJSON: {
name: "Validate JSON",
nameKey: "guardrails.customCode.templates.validateJson",
code: `def apply_guardrail(inputs, request_data, input_type):
if input_type != "response":
return allow()
@ -51,7 +53,7 @@ export const CODE_TEMPLATES = {
return allow()`,
},
externalAPI: {
name: "External API Check (async)",
nameKey: "guardrails.customCode.templates.externalApi",
code: `async def apply_guardrail(inputs, request_data, input_type):
# Call an external moderation API (async for non-blocking)
for text in inputs["texts"]:
@ -71,66 +73,129 @@ export const CODE_TEMPLATES = {
return allow()`,
},
};
} satisfies Record<string, { nameKey: ParseKeys; code: string }>;
export const PRIMITIVES = {
"Return Values": [
{ name: "allow()", desc: "Let request/response through" },
{ name: "block(reason)", desc: "Reject with message" },
{ name: "flag(reason, metadata={})", desc: "Let through, record a non-blocking violation" },
{ name: "modify(texts=[], images=[], tool_calls=[])", desc: "Transform content" },
],
"HTTP Requests (async)": [
{ name: "await http_request(url, method, headers, body)", desc: "Make async HTTP request" },
{ name: "await http_get(url, headers)", desc: "Async GET request" },
{ name: "await http_post(url, body, headers)", desc: "Async POST request" },
],
"Regex Functions": [
{ name: "regex_match(text, pattern)", desc: "Returns True if pattern found" },
{ name: "regex_replace(text, pattern, replacement)", desc: "Replace all matches" },
{ name: "regex_find_all(text, pattern)", desc: "Return list of matches" },
],
"JSON Functions": [
{ name: "json_parse(text)", desc: "Parse JSON string, returns None on error" },
{ name: "json_stringify(obj)", desc: "Convert to JSON string" },
{ name: "json_schema_valid(obj, schema)", desc: "Validate against JSON schema" },
],
"URL Functions": [
{ name: "extract_urls(text)", desc: "Extract all URLs from text" },
{ name: "is_valid_url(url)", desc: "Check if URL is valid" },
{ name: "all_urls_valid(text)", desc: "Check all URLs in text are valid" },
],
"Code Detection": [
{ name: "detect_code(text)", desc: "Returns True if code detected" },
{ name: "detect_code_languages(text)", desc: "Returns list of detected languages" },
{ name: 'contains_code_language(text, ["sql"])', desc: "Check for specific languages" },
],
"Text Utilities": [
{ name: "contains(text, substring)", desc: "Check if substring exists" },
{ name: "contains_any(text, [substr1, substr2])", desc: "Check if any substring exists" },
{ name: "word_count(text)", desc: "Count words" },
{ name: "char_count(text)", desc: "Count characters" },
{ name: "lower(text) / upper(text) / trim(text)", desc: "String transforms" },
],
};
interface Primitive {
name: string;
descKey: ParseKeys;
}
export const MODE_OPTIONS = [
{ value: "pre_call", label: "pre_call (Request)" },
{ value: "post_call", label: "post_call (Response)" },
{ value: "during_call", label: "during_call (Parallel)" },
{ value: "logging_only", label: "logging_only" },
{ value: "pre_mcp_call", label: "pre_mcp_call (Before MCP Tool Call)" },
{ value: "post_mcp_call", label: "post_mcp_call (After MCP Tool Call)" },
{ value: "during_mcp_call", label: "during_mcp_call (During MCP Tool Call)" },
interface PrimitiveCategory {
id: string;
labelKey: ParseKeys;
primitives: Primitive[];
}
export const DEFAULT_OPEN_PRIMITIVE_CATEGORY = "returnValues";
export const PRIMITIVE_CATEGORIES: PrimitiveCategory[] = [
{
id: "returnValues",
labelKey: "guardrails.customCode.primitives.categories.returnValues",
primitives: [
{ name: "allow()", descKey: "guardrails.customCode.primitives.descriptions.allow" },
{ name: "block(reason)", descKey: "guardrails.customCode.primitives.descriptions.block" },
{ name: "flag(reason, metadata={})", descKey: "guardrails.customCode.primitives.descriptions.flag" },
{
name: "modify(texts=[], images=[], tool_calls=[])",
descKey: "guardrails.customCode.primitives.descriptions.modify",
},
],
},
{
id: "httpRequests",
labelKey: "guardrails.customCode.primitives.categories.httpRequests",
primitives: [
{
name: "await http_request(url, method, headers, body)",
descKey: "guardrails.customCode.primitives.descriptions.httpRequest",
},
{ name: "await http_get(url, headers)", descKey: "guardrails.customCode.primitives.descriptions.httpGet" },
{
name: "await http_post(url, body, headers)",
descKey: "guardrails.customCode.primitives.descriptions.httpPost",
},
],
},
{
id: "regex",
labelKey: "guardrails.customCode.primitives.categories.regex",
primitives: [
{ name: "regex_match(text, pattern)", descKey: "guardrails.customCode.primitives.descriptions.regexMatch" },
{
name: "regex_replace(text, pattern, replacement)",
descKey: "guardrails.customCode.primitives.descriptions.regexReplace",
},
{ name: "regex_find_all(text, pattern)", descKey: "guardrails.customCode.primitives.descriptions.regexFindAll" },
],
},
{
id: "json",
labelKey: "guardrails.customCode.primitives.categories.json",
primitives: [
{ name: "json_parse(text)", descKey: "guardrails.customCode.primitives.descriptions.jsonParse" },
{ name: "json_stringify(obj)", descKey: "guardrails.customCode.primitives.descriptions.jsonStringify" },
{
name: "json_schema_valid(obj, schema)",
descKey: "guardrails.customCode.primitives.descriptions.jsonSchemaValid",
},
],
},
{
id: "url",
labelKey: "guardrails.customCode.primitives.categories.url",
primitives: [
{ name: "extract_urls(text)", descKey: "guardrails.customCode.primitives.descriptions.extractUrls" },
{ name: "is_valid_url(url)", descKey: "guardrails.customCode.primitives.descriptions.isValidUrl" },
{ name: "all_urls_valid(text)", descKey: "guardrails.customCode.primitives.descriptions.allUrlsValid" },
],
},
{
id: "codeDetection",
labelKey: "guardrails.customCode.primitives.categories.codeDetection",
primitives: [
{ name: "detect_code(text)", descKey: "guardrails.customCode.primitives.descriptions.detectCode" },
{
name: "detect_code_languages(text)",
descKey: "guardrails.customCode.primitives.descriptions.detectCodeLanguages",
},
{
name: 'contains_code_language(text, ["sql"])',
descKey: "guardrails.customCode.primitives.descriptions.containsCodeLanguage",
},
],
},
{
id: "textUtilities",
labelKey: "guardrails.customCode.primitives.categories.textUtilities",
primitives: [
{ name: "contains(text, substring)", descKey: "guardrails.customCode.primitives.descriptions.contains" },
{
name: "contains_any(text, [substr1, substr2])",
descKey: "guardrails.customCode.primitives.descriptions.containsAny",
},
{ name: "word_count(text)", descKey: "guardrails.customCode.primitives.descriptions.wordCount" },
{ name: "char_count(text)", descKey: "guardrails.customCode.primitives.descriptions.charCount" },
{
name: "lower(text) / upper(text) / trim(text)",
descKey: "guardrails.customCode.primitives.descriptions.stringTransforms",
},
],
},
];
export const TEMPLATE_ITEMS = Object.entries(CODE_TEMPLATES).map(([key, template]) => ({
value: key,
label: template.name,
}));
export const MODE_OPTIONS: { value: string; labelKey: ParseKeys }[] = [
{ value: "pre_call", labelKey: "guardrails.customCode.modes.preCall" },
{ value: "post_call", labelKey: "guardrails.customCode.modes.postCall" },
{ value: "during_call", labelKey: "guardrails.customCode.modes.duringCall" },
{ value: "logging_only", labelKey: "guardrails.customCode.modes.loggingOnly" },
{ value: "pre_mcp_call", labelKey: "guardrails.customCode.modes.preMcpCall" },
{ value: "post_mcp_call", labelKey: "guardrails.customCode.modes.postMcpCall" },
{ value: "during_mcp_call", labelKey: "guardrails.customCode.modes.duringMcpCall" },
];
export type ModeOption = (typeof MODE_OPTIONS)[number];
export const MODE_OPTION_BY_VALUE: Record<string, ModeOption> = Object.fromEntries(
MODE_OPTIONS.map((option) => [option.value, option]),
export const TEMPLATE_ITEMS: { value: string; labelKey: ParseKeys }[] = Object.entries(CODE_TEMPLATES).map(
([key, template]) => ({ value: key, labelKey: template.nameKey }),
);
export type ModeOption = { value: string; label: string };

View file

@ -1,8 +1,10 @@
"use client";
import type { ParseKeys } from "i18next";
import { Info, Plus, X } from "lucide-react";
import React from "react";
import { useWatch } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import {
@ -36,9 +38,9 @@ export interface DecisionModelFieldsProps {
control: GuardrailFormControl;
}
const ACTION_ITEMS = [
{ label: "Block", value: "block" },
{ label: "Log only", value: "log" },
const ACTION_ITEMS: { labelKey: ParseKeys; value: "block" | "log" }[] = [
{ labelKey: "guardrails.decisionModel.actionOptions.block", value: "block" },
{ labelKey: "guardrails.decisionModel.actionOptions.log", value: "log" },
];
const ProviderLogo: React.FC<{ provider: string }> = ({ provider }) => (
@ -46,16 +48,21 @@ const ProviderLogo: React.FC<{ provider: string }> = ({ provider }) => (
);
const NoDecisionModels: React.FC<{ providerFilter: string | null }> = ({ providerFilter }) => {
const source = providerFilter ? ` from the ${getProviderLogoAndName(providerFilter).displayName} provider` : "";
const { t } = useTranslation();
const description = providerFilter
? t("guardrails.decisionModel.noModels.descriptionForProvider", {
provider: getProviderLogoAndName(providerFilter).displayName,
})
: t("guardrails.decisionModel.noModels.description");
return (
<Alert variant="info" role="note" aria-label="No decision models">
<Alert variant="info" role="note" aria-label={t("guardrails.decisionModel.noModels.ariaLabel")}>
<Info />
<AlertTitle>No decision models yet</AlertTitle>
<AlertTitle>{t("guardrails.decisionModel.noModels.title")}</AlertTitle>
<AlertDescription>
<p>
Add a decision model{source} to use this guardrail. A model shows up here once its mode is decisions.{" "}
{description}{" "}
<a href={uiHref("models-and-endpoints")} target="_blank" rel="noopener noreferrer">
Add a model
{t("guardrails.decisionModel.noModels.addModel")}
</a>
</p>
</AlertDescription>
@ -93,9 +100,11 @@ const DecisionModelFields: React.FC<DecisionModelFieldsProps> = ({
onChecksChange,
control,
}) => {
const { t } = useTranslation();
const selectedModel = asText(useWatch({ control, name: "decision_model" }));
const duplicateNames = duplicateDecisionCheckNames(checks);
const providerByModel = new Map(decisionModels.map((option) => [option.model, option.provider]));
const actionItems = ACTION_ITEMS.map(({ labelKey, value }) => ({ label: t(labelKey), value }));
const updateCheck = (id: string, patch: Partial<DecisionModelCheckDraft>) =>
onChecksChange(checks.map((check) => (check.id === id ? { ...check, ...patch } : check)));
@ -107,19 +116,14 @@ const DecisionModelFields: React.FC<DecisionModelFieldsProps> = ({
return (
<div className="space-y-5">
<div className="rounded-md border border-success/20 bg-success/10 px-3.5 py-2.5 text-[13px] text-success">
The <strong>Decision Model</strong> answers yes/no questions about each request (pre_call, during_call) or
response (post_call). A question whose probability reaches its threshold blocks the call, or is recorded when
set to Log only.
<Trans i18nKey="guardrails.decisionModel.intro" components={{ strong: <strong /> }} />
</div>
<GuardrailField
control={control}
name="decision_model"
label={labelWithHint(
"Decision Model",
"A decisions-API model on this proxy, such as typesafe/jev-latest. It scores every question below.",
)}
rules={requiredRule("Select a decision model")}
label={labelWithHint(t("guardrails.decisionModel.model.label"), t("guardrails.decisionModel.model.hint"))}
rules={requiredRule(t("guardrails.decisionModel.model.required"))}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Combobox
@ -134,11 +138,15 @@ const DecisionModelFields: React.FC<DecisionModelFieldsProps> = ({
id={id}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
placeholder={decisionModels.length > 0 ? "Select a decision model" : "No decision models yet"}
placeholder={
decisionModels.length > 0
? t("guardrails.decisionModel.model.placeholder")
: t("guardrails.decisionModel.model.placeholderEmpty")
}
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>No matching models</ComboboxEmpty>
<ComboboxEmpty>{t("guardrails.decisionModel.model.empty")}</ComboboxEmpty>
<ComboboxList>
{(model: string) => {
const provider = providerByModel.get(model);
@ -160,11 +168,8 @@ const DecisionModelFields: React.FC<DecisionModelFieldsProps> = ({
{decisionModels.length === 0 && <NoDecisionModels providerFilter={providerFilter} />}
<Field>
<FieldLabel>Questions</FieldLabel>
<p className="m-0 text-xs text-muted-foreground">
Yes/no questions the decision model answers about the text. Block stops the call once a score reaches the
threshold, and Log only records it.
</p>
<FieldLabel>{t("guardrails.decisionModel.questions.label")}</FieldLabel>
<p className="m-0 text-xs text-muted-foreground">{t("guardrails.decisionModel.questions.description")}</p>
{checks.map((check, index) => {
const position = index + 1;
return (
@ -172,30 +177,36 @@ const DecisionModelFields: React.FC<DecisionModelFieldsProps> = ({
<button
type="button"
onClick={() => removeCheck(check.id)}
aria-label={`Remove question ${position}`}
aria-label={t("guardrails.decisionModel.question.remove", { position })}
className="absolute top-2 right-2 p-1 text-muted-foreground transition-colors hover:text-destructive"
>
<X className="size-4" />
</button>
<div>
<span className="mb-1 block text-xs font-medium text-muted-foreground">Name</span>
<span className="mb-1 block text-xs font-medium text-muted-foreground">
{t("guardrails.decisionModel.question.nameLabel")}
</span>
<Input
aria-label={`Question ${position} name`}
placeholder="Name (e.g. invoice_policy)"
aria-label={t("guardrails.decisionModel.question.nameAriaLabel", { position })}
placeholder={t("guardrails.decisionModel.question.namePlaceholder")}
className="bg-background"
value={check.name}
onChange={(event) => updateCheck(check.id, { name: event.target.value })}
/>
{duplicateNames.has(check.name.trim()) && (
<p className="m-0 mt-1 text-xs text-destructive">Another question already uses this name</p>
<p className="m-0 mt-1 text-xs text-destructive">
{t("guardrails.decisionModel.question.duplicateName")}
</p>
)}
</div>
<div>
<span className="mb-1 block text-xs font-medium text-muted-foreground">Question</span>
<span className="mb-1 block text-xs font-medium text-muted-foreground">
{t("guardrails.decisionModel.question.questionLabel")}
</span>
<Textarea
aria-label={`Question ${position}`}
aria-label={t("guardrails.decisionModel.question.questionAriaLabel", { position })}
rows={2}
placeholder="The question the decision model answers, e.g. Does the text ask about invoices?"
placeholder={t("guardrails.decisionModel.question.questionPlaceholder")}
className="w-full resize-none bg-background"
value={check.instructions}
onChange={(event) => updateCheck(check.id, { instructions: event.target.value })}
@ -203,19 +214,24 @@ const DecisionModelFields: React.FC<DecisionModelFieldsProps> = ({
</div>
<div className="flex items-end gap-6">
<div className="w-32">
<span className="mb-1 block text-xs font-medium text-muted-foreground">Action</span>
<span className="mb-1 block text-xs font-medium text-muted-foreground">
{t("guardrails.decisionModel.question.actionLabel")}
</span>
<Select
items={ACTION_ITEMS}
items={actionItems}
value={check.action}
onValueChange={(next: string | null) =>
next && updateCheck(check.id, { action: next as "block" | "log" })
}
>
<SelectTrigger className="w-full bg-background" aria-label={`Question ${position} action`}>
<SelectTrigger
className="w-full bg-background"
aria-label={t("guardrails.decisionModel.question.actionAriaLabel", { position })}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_ITEMS.map((item) => (
{actionItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -224,10 +240,12 @@ const DecisionModelFields: React.FC<DecisionModelFieldsProps> = ({
</Select>
</div>
<div className="w-56">
<span className="mb-1 block text-xs font-medium text-muted-foreground">Threshold</span>
<span className="mb-1 block text-xs font-medium text-muted-foreground">
{t("guardrails.decisionModel.question.thresholdLabel")}
</span>
<div className="flex h-9 items-center">
<ThresholdSlider
aria-label={`Question ${position} threshold`}
aria-label={t("guardrails.decisionModel.question.thresholdAriaLabel", { position })}
value={check.threshold}
onChange={(next) => updateCheck(check.id, { threshold: next })}
/>
@ -240,7 +258,7 @@ const DecisionModelFields: React.FC<DecisionModelFieldsProps> = ({
<div>
<Button variant="outline" size="sm" onClick={addCheck}>
<Plus className="size-3" />
Add question
{t("guardrails.decisionModel.questions.add")}
</Button>
</div>
</Field>

View file

@ -1,6 +1,7 @@
"use client";
import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { StatusBadge } from "@/components/shared/table_cells";
import { Button } from "@/components/ui/button";
import { FieldLabel } from "@/components/ui/field";
@ -27,6 +28,7 @@ export interface DecisionTestSectionProps {
}
const DecisionTestSection: React.FC<DecisionTestSectionProps> = ({ accessToken, model, checks }) => {
const { t } = useTranslation();
const [input, setInput] = useState("");
const [runs, setRuns] = useState<DecisionTestRun[]>([]);
const [running, setRunning] = useState(false);
@ -35,9 +37,9 @@ const DecisionTestSection: React.FC<DecisionTestSectionProps> = ({ accessToken,
const runnableQuestions = runnableDecisionQuestions(checks);
let disabledReason: string | null = null;
if (!model) disabledReason = "Select a decision model to run a test";
else if (!input.trim()) disabledReason = "Type an input to run a test";
else if (runnableQuestions.length === 0) disabledReason = "Add a question to run a test";
if (!model) disabledReason = t("guardrails.decisionModel.test.disabled.noModel");
else if (!input.trim()) disabledReason = t("guardrails.decisionModel.test.disabled.noInput");
else if (runnableQuestions.length === 0) disabledReason = t("guardrails.decisionModel.test.disabled.noQuestions");
const canRun = !disabledReason && !running;
const runTest = async () => {
@ -79,16 +81,13 @@ const DecisionTestSection: React.FC<DecisionTestSectionProps> = ({ accessToken,
return (
<div className="space-y-3 border-t border-border pt-4">
<div className="space-y-1">
<FieldLabel>Test</FieldLabel>
<p className="m-0 text-xs text-muted-foreground">
Try your questions on a sample input before you save. Uses the thresholds above. Each run is a real decision
model call, so it is billed and shows up in Logs.
</p>
<FieldLabel>{t("guardrails.decisionModel.test.label")}</FieldLabel>
<p className="m-0 text-xs text-muted-foreground">{t("guardrails.decisionModel.test.description")}</p>
</div>
<Textarea
aria-label="Test input"
aria-label={t("guardrails.decisionModel.test.inputAriaLabel")}
rows={3}
placeholder="Paste a sample input to score against your questions"
placeholder={t("guardrails.decisionModel.test.inputPlaceholder")}
className="w-full resize-none"
value={input}
onChange={(event) => setInput(event.target.value)}
@ -101,16 +100,14 @@ const DecisionTestSection: React.FC<DecisionTestSectionProps> = ({ accessToken,
/>
<div className="flex items-center gap-2">
<Button type="button" onClick={runTest} disabled={!canRun}>
{running ? "Running…" : "Run test"}
{running ? t("guardrails.decisionModel.test.running") : t("guardrails.decisionModel.test.run")}
</Button>
{!running && disabledReason && <p className="m-0 text-xs text-muted-foreground">{disabledReason}</p>}
{!running && !disabledReason && <p className="m-0 text-xs text-muted-foreground">{runTestShortcutLabel()}</p>}
</div>
{runs.length > 0 && (
<>
<p className="m-0 text-xs text-muted-foreground">
Latest first. Each run keeps its input so you can compare.
</p>
<p className="m-0 text-xs text-muted-foreground">{t("guardrails.decisionModel.test.historyHint")}</p>
<div className="space-y-2" data-slot="decision-test-history">
{runs.map((run) => {
const visible = "error" in run ? [] : visibleTestResults(run, checks, model);
@ -123,25 +120,32 @@ const DecisionTestSection: React.FC<DecisionTestSectionProps> = ({ accessToken,
{visible.length > 0 && (
<StatusBadge
tone={DECISION_TEST_CHIP_TONE[decisionTestOverall(visible)]}
label={decisionTestOverall(visible) === "block" ? "Block" : "Pass"}
label={
decisionTestOverall(visible) === "block"
? t("guardrails.decisionModel.test.overall.block")
: t("guardrails.decisionModel.test.overall.pass")
}
/>
)}
</div>
{"error" in run && <p className="m-0 mt-1.5 text-xs text-destructive">{run.error}</p>}
{!("error" in run) && visible.length === 0 && (
<p className="m-0 mt-1.5 text-xs text-muted-foreground">
The model or questions changed since this run. Run it again to score them.
{t("guardrails.decisionModel.test.stale")}
</p>
)}
{visible.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{visible.map(({ check, result }) => {
const chip = decisionTestChip(result, check);
const score = result.kind === "probability" ? result.probability.toFixed(2) : "No answer";
const score =
result.kind === "probability"
? result.probability.toFixed(2)
: t("guardrails.decisionModel.test.chip.noAnswer");
let label = "";
if (chip === "block") label = "Block";
else if (chip === "logged") label = "Pass · logged";
else if (chip === "pass") label = "Pass";
if (chip === "block") label = t("guardrails.decisionModel.test.chip.block");
else if (chip === "logged") label = t("guardrails.decisionModel.test.chip.logged");
else if (chip === "pass") label = t("guardrails.decisionModel.test.chip.pass");
return (
<StatusBadge
key={check.name}

View file

@ -1,3 +1,6 @@
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
export interface DecisionModelCheckDraft {
id: string;
name: string;
@ -27,13 +30,16 @@ export function duplicateDecisionCheckNames(checks: readonly DecisionModelCheckD
return new Set(names.filter((name, index) => names.indexOf(name) !== index));
}
export function decisionChecksProblem(checks: readonly DecisionModelCheckDraft[]): string | null {
export function decisionChecksProblem(
checks: readonly DecisionModelCheckDraft[],
t: TFunction = i18n.t.bind(i18n),
): string | null {
const trimmed = trimDecisionChecks(checks);
if (trimmed.length === 0) return "Add at least one question";
if (trimmed.length === 0) return t("guardrails.decisionModel.problems.noQuestions");
if (trimmed.some((check) => !check.name || !check.instructions)) {
return "Give every question a name and a question, or remove it";
return t("guardrails.decisionModel.problems.incompleteQuestion");
}
if (duplicateDecisionCheckNames(checks).size > 0) return "Each question needs a different name";
if (duplicateDecisionCheckNames(checks).size > 0) return t("guardrails.decisionModel.problems.duplicateNames");
return null;
}

View file

@ -1,7 +1,9 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import type { ParseKeys, TFunction } from "i18next";
import { MoreHorizontal, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
@ -18,7 +20,7 @@ import { cn } from "@/lib/cva.config";
import { formatGuardrailMode, getGuardrailLogoAndName } from "./guardrail_info_helpers";
import { Logo } from "@/components/molecules/logo/Logo";
const CONFIG_DELETE_HINT = "Config guardrails are defined in the config file and cannot be deleted from the dashboard.";
const CONFIG_DELETE_HINT_KEY: ParseKeys = "guardrails.table.configDeleteHint";
function GuardrailProviderCell({ provider }: { provider: string }) {
const { logo, displayName } = getGuardrailLogoAndName(provider);
@ -36,12 +38,13 @@ interface GuardrailRowActionsProps {
}
function GuardrailRowActions({ guardrail, onDeleteClick }: GuardrailRowActionsProps) {
const { t } = useTranslation();
const isConfigGuardrail = guardrail.guardrail_definition_location === GuardrailDefinitionLocation.CONFIG;
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open guardrail actions"
aria-label={t("guardrails.table.actions.open")}
data-testid={`guardrail-actions-${guardrail.guardrail_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
@ -52,11 +55,13 @@ function GuardrailRowActions({ guardrail, onDeleteClick }: GuardrailRowActionsPr
variant="destructive"
disabled={isConfigGuardrail}
data-testid="guardrail-action-delete"
title={isConfigGuardrail ? CONFIG_DELETE_HINT : undefined}
onClick={() => onDeleteClick(guardrail.guardrail_id, guardrail.guardrail_name || "Unnamed Guardrail")}
title={isConfigGuardrail ? t(CONFIG_DELETE_HINT_KEY) : undefined}
onClick={() =>
onDeleteClick(guardrail.guardrail_id, guardrail.guardrail_name || t("guardrails.table.unnamed"))
}
>
<Trash2 />
Delete
{t("guardrails.table.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -66,17 +71,19 @@ function GuardrailRowActions({ guardrail, onDeleteClick }: GuardrailRowActionsPr
interface GuardrailTableColumnsDeps {
onGuardrailClick: (guardrailId: string) => void;
onDeleteClick: (guardrailId: string, guardrailName: string) => void;
t: TFunction;
}
export const getGuardrailTableColumns = ({
onGuardrailClick,
onDeleteClick,
t,
}: GuardrailTableColumnsDeps): ColumnDef<Guardrail>[] => [
{
id: "guardrail_id",
accessorKey: "guardrail_id",
meta: { title: "Guardrail ID" },
header: ({ column }) => <DataTableSortHeader column={column} title="Guardrail ID" />,
meta: { title: t("guardrails.table.columns.guardrailId") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("guardrails.table.columns.guardrailId")} />,
size: 200,
enableSorting: true,
cell: ({ row }) => (
@ -90,8 +97,8 @@ export const getGuardrailTableColumns = ({
{
id: "guardrail_name",
accessorKey: "guardrail_name",
meta: { title: "Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
meta: { title: t("guardrails.table.columns.name") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("guardrails.table.columns.name")} />,
size: 200,
enableSorting: true,
cell: ({ row }) => {
@ -105,16 +112,16 @@ export const getGuardrailTableColumns = ({
},
{
id: "provider",
meta: { title: "Provider" },
header: "Provider",
meta: { title: t("guardrails.table.columns.provider") },
header: t("guardrails.table.columns.provider"),
size: 180,
enableSorting: false,
cell: ({ row }) => <GuardrailProviderCell provider={row.original.litellm_params.guardrail} />,
},
{
id: "mode",
meta: { title: "Mode" },
header: "Mode",
meta: { title: t("guardrails.table.columns.mode") },
header: t("guardrails.table.columns.mode"),
size: 130,
enableSorting: false,
cell: ({ row }) => {
@ -128,22 +135,25 @@ export const getGuardrailTableColumns = ({
},
{
id: "default_on",
meta: { title: "Default On" },
header: "Default On",
meta: { title: t("guardrails.table.columns.defaultOn") },
header: t("guardrails.table.columns.defaultOn"),
size: 120,
enableSorting: false,
cell: ({ row }) => {
const isDefaultOn = !!row.original.litellm_params?.default_on;
return (
<StatusBadge tone={isDefaultOn ? "success" : "neutral"} label={isDefaultOn ? "Default On" : "Default Off"} />
<StatusBadge
tone={isDefaultOn ? "success" : "neutral"}
label={isDefaultOn ? t("guardrails.table.defaultOnStatus.on") : t("guardrails.table.defaultOnStatus.off")}
/>
);
},
},
{
id: "created_at",
accessorKey: "created_at",
meta: { title: "Created At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
meta: { title: t("guardrails.table.columns.createdAt") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("guardrails.table.columns.createdAt")} />,
size: 150,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} />,
@ -151,8 +161,8 @@ export const getGuardrailTableColumns = ({
{
id: "updated_at",
accessorKey: "updated_at",
meta: { title: "Updated At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" />,
meta: { title: t("guardrails.table.columns.updatedAt") },
header: ({ column }) => <DataTableSortHeader column={column} title={t("guardrails.table.columns.updatedAt")} />,
size: 150,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
@ -160,7 +170,7 @@ export const getGuardrailTableColumns = ({
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
header: () => <span className="sr-only">{t("guardrails.table.columns.actions")}</span>,
size: 64,
enableSorting: false,
enableHiding: false,

View file

@ -1,8 +1,15 @@
import { useTerminology } from "@/contexts/TerminologyContext";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowRight, Search } from "lucide-react";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { GuardrailCardInfo, ALL_CARDS } from "./guardrail_garden_data";
import {
GuardrailCardInfo,
ALL_CARDS,
gardenCardDescription,
gardenCardName,
gardenTagLabel,
} from "./guardrail_garden_data";
import GuardrailCard from "./guardrail_garden_card";
import GuardrailDetailView from "./guardrail_garden_detail";
@ -12,6 +19,7 @@ interface GuardrailGardenProps {
}
const GuardrailGarden: React.FC<GuardrailGardenProps> = ({ accessToken, onGuardrailCreated }) => {
const { t } = useTranslation();
const term = useTerminology();
const [searchQuery, setSearchQuery] = useState("");
const [selectedCard, setSelectedCard] = useState<GuardrailCardInfo | null>(null);
@ -23,9 +31,9 @@ const GuardrailGarden: React.FC<GuardrailGardenProps> = ({ accessToken, onGuardr
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (
card.name.toLowerCase().includes(q) ||
term(card.description).toLowerCase().includes(q) ||
card.tags.some((t) => t.toLowerCase().includes(q))
gardenCardName(card, t).toLowerCase().includes(q) ||
term(gardenCardDescription(card, t)).toLowerCase().includes(q) ||
card.tags.some((tag) => gardenTagLabel(tag, t).toLowerCase().includes(q))
);
});
@ -52,7 +60,7 @@ const GuardrailGarden: React.FC<GuardrailGardenProps> = ({ accessToken, onGuardr
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search guardrails"
placeholder={t("guardrails.garden.searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
@ -62,25 +70,23 @@ const GuardrailGarden: React.FC<GuardrailGardenProps> = ({ accessToken, onGuardr
<section className="mb-10" aria-labelledby="guardrail-garden-litellm">
<div className="mb-1 flex items-center justify-between">
<h2 id="guardrail-garden-litellm" className="m-0 text-xl font-semibold text-foreground">
LiteLLM Content Filter
{t("guardrails.garden.litellmHeading")}
</h2>
<span
className="inline-flex cursor-pointer items-center gap-1.5 text-sm text-primary"
onClick={() => setShowAllLitellm(!showAllLitellm)}
>
{showAllLitellm ? (
<>Show less</>
<>{t("guardrails.garden.showLess")}</>
) : (
<>
<ArrowRight className="size-3" />
{`Show all (${litellmCards.length})`}
{t("guardrails.garden.showAll", { total: litellmCards.length })}
</>
)}
</span>
</div>
<p className="mt-1 mb-5 text-[13px] text-muted-foreground">
Built-in guardrails powered by LiteLLM. Zero latency, no external dependencies, no additional cost.
</p>
<p className="mt-1 mb-5 text-[13px] text-muted-foreground">{t("guardrails.garden.litellmDescription")}</p>
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4">
{(showAllLitellm ? litellmCards : litellmCards.slice(0, CARDS_PER_ROW * VISIBLE_ROWS)).map((card) => (
<GuardrailCard key={card.id} card={card} onClick={() => setSelectedCard(card)} />
@ -90,12 +96,9 @@ const GuardrailGarden: React.FC<GuardrailGardenProps> = ({ accessToken, onGuardr
<section className="mb-10" aria-labelledby="guardrail-garden-decision-model">
<h2 id="guardrail-garden-decision-model" className="mt-0 mb-1 text-xl font-semibold text-foreground">
Decision Model
{t("guardrails.garden.decisionModelHeading")}
</h2>
<p className="mt-1 mb-5 text-[13px] text-muted-foreground">
Ask a decision model yes/no questions about each request or response, and block or log when a score reaches
its threshold.
</p>
<p className="mt-1 mb-5 text-[13px] text-muted-foreground">{t("guardrails.garden.decisionModelDescription")}</p>
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4">
{decisionModelCards.map((card) => (
<GuardrailCard key={card.id} card={card} onClick={() => setSelectedCard(card)} />
@ -105,11 +108,9 @@ const GuardrailGarden: React.FC<GuardrailGardenProps> = ({ accessToken, onGuardr
<section className="mb-10" aria-labelledby="guardrail-garden-partner">
<h2 id="guardrail-garden-partner" className="mt-0 mb-1 text-xl font-semibold text-foreground">
Partner Guardrails
{t("guardrails.garden.partnerHeading")}
</h2>
<p className="mt-1 mb-5 text-[13px] text-muted-foreground">
{term("Third-party guardrail integrations from leading AI security providers.")}
</p>
<p className="mt-1 mb-5 text-[13px] text-muted-foreground">{term(t("guardrails.garden.partnerDescription"))}</p>
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4">
{partnerCards.map((card) => (
<GuardrailCard key={card.id} card={card} onClick={() => setSelectedCard(card)} />

View file

@ -1,28 +1,33 @@
import { useTerminology } from "@/contexts/TerminologyContext";
import React from "react";
import { useTranslation } from "react-i18next";
import { CircleCheck } from "lucide-react";
import { GuardrailCardInfo } from "./guardrail_garden_data";
import { GuardrailCardInfo, gardenCardDescription, gardenCardName } from "./guardrail_garden_data";
import { Logo } from "@/components/molecules/logo/Logo";
const GuardrailCard: React.FC<{ card: GuardrailCardInfo; onClick: () => void }> = ({ card, onClick }) => {
const { t } = useTranslation();
const term = useTerminology();
const name = gardenCardName(card, t);
return (
<div
onClick={onClick}
className="flex min-h-[170px] cursor-pointer flex-col rounded-xl border border-border bg-card px-5 pt-5 pb-4 transition-[border-color,box-shadow] hover:border-primary/40 hover:shadow-sm"
>
<div className="mb-2.5 flex items-center gap-2.5">
<Logo src={card.logo} label={card.name} className="w-7 h-7 rounded-md object-contain shrink-0" />
<span className="text-sm leading-tight font-semibold text-foreground">{card.name}</span>
<Logo src={card.logo} label={name} className="w-7 h-7 rounded-md object-contain shrink-0" />
<span className="text-sm leading-tight font-semibold text-foreground">{name}</span>
</div>
<p className="line-clamp-3 m-0 flex-1 text-xs leading-relaxed text-muted-foreground">{term(card.description)}</p>
<p className="line-clamp-3 m-0 flex-1 text-xs leading-relaxed text-muted-foreground">
{term(gardenCardDescription(card, t))}
</p>
{card.eval && (
<div className="mt-2.5 flex items-center gap-1 text-success">
<CircleCheck className="size-3" />
<span className="text-[11px] font-medium">
F1: {card.eval.f1}% &middot; {card.eval.testCases} test cases
F1: {card.eval.f1}% &middot; {card.eval.testCases} {t("guardrails.gardenCard.testCases")}
</span>
</div>
)}

View file

@ -1,12 +1,15 @@
import type { ParseKeys, TFunction } from "i18next";
import { Providers, providerLogoMap } from "@/components/provider_info_helpers";
import { guardrailLogoMap } from "./guardrail_info_helpers";
export interface GuardrailCardInfo {
id: string;
name: string;
nameKey?: ParseKeys;
description: string;
descriptionKey?: ParseKeys;
category: "litellm" | "decision_model" | "partner";
subcategory?: string;
subcategoryKey?: ParseKeys;
logo: string;
tags: string[];
eval?: {
@ -19,16 +22,71 @@ export interface GuardrailCardInfo {
providerKey?: string;
}
export const GARDEN_TAG_LABEL_KEYS: Readonly<Partial<Record<string, ParseKeys>>> = {
"Content Category": "guardrails.gardenCatalog.tags.contentCategory",
"Topic Blocker": "guardrails.gardenCatalog.tags.topicBlocker",
Safety: "guardrails.gardenCatalog.tags.safety",
Bias: "guardrails.gardenCatalog.tags.bias",
"Prompt Injection": "guardrails.gardenCatalog.tags.promptInjection",
Toxicity: "guardrails.gardenCatalog.tags.toxicity",
PII: "guardrails.gardenCatalog.tags.pii",
Regex: "guardrails.gardenCatalog.tags.regex",
"Data Protection": "guardrails.gardenCatalog.tags.dataProtection",
Keywords: "guardrails.gardenCatalog.tags.keywords",
Blocklist: "guardrails.gardenCatalog.tags.blocklist",
Code: "guardrails.gardenCatalog.tags.code",
Competitor: "guardrails.gardenCatalog.tags.competitor",
"Decision Model": "guardrails.gardenCatalog.tags.decisionModel",
"Content Safety": "guardrails.gardenCatalog.tags.contentSafety",
Security: "guardrails.gardenCatalog.tags.security",
"Content Moderation": "guardrails.gardenCatalog.tags.contentModeration",
"Open Source": "guardrails.gardenCatalog.tags.openSource",
Validation: "guardrails.gardenCatalog.tags.validation",
Enterprise: "guardrails.gardenCatalog.tags.enterprise",
"Threat Detection": "guardrails.gardenCatalog.tags.threatDetection",
Hallucination: "guardrails.gardenCatalog.tags.hallucination",
Policy: "guardrails.gardenCatalog.tags.policy",
Compliance: "guardrails.gardenCatalog.tags.compliance",
Governance: "guardrails.gardenCatalog.tags.governance",
Gateway: "guardrails.gardenCatalog.tags.gateway",
Monitoring: "guardrails.gardenCatalog.tags.monitoring",
Grounding: "guardrails.gardenCatalog.tags.grounding",
RAG: "guardrails.gardenCatalog.tags.rag",
Firewall: "guardrails.gardenCatalog.tags.firewall",
Agentic: "guardrails.gardenCatalog.tags.agentic",
"Tool Misuse": "guardrails.gardenCatalog.tags.toolMisuse",
Skills: "guardrails.gardenCatalog.tags.skills",
Observability: "guardrails.gardenCatalog.tags.observability",
"Data Privacy": "guardrails.gardenCatalog.tags.dataPrivacy",
Streaming: "guardrails.gardenCatalog.tags.streaming",
Runtime: "guardrails.gardenCatalog.tags.runtime",
Secrets: "guardrails.gardenCatalog.tags.secrets",
Redaction: "guardrails.gardenCatalog.tags.redaction",
};
export const gardenCardName = (card: GuardrailCardInfo, t: TFunction): string =>
card.nameKey ? t(card.nameKey) : card.name;
export const gardenCardDescription = (card: GuardrailCardInfo, t: TFunction): string =>
card.descriptionKey ? t(card.descriptionKey) : card.description;
export const gardenTagLabel = (tag: string, t: TFunction): string => {
const key = GARDEN_TAG_LABEL_KEYS[tag];
return key ? t(key) : tag;
};
const litellmContentFilterLogo = guardrailLogoMap["LiteLLM Content Filter"];
export const LITELLM_CONTENT_FILTER_CARDS: GuardrailCardInfo[] = [
{
id: "cf_denied_financial",
name: "Denied Financial Advice",
nameKey: "guardrails.gardenCatalog.contentFilter.deniedFinancial.name",
description:
"Detects requests for personalized financial advice, investment recommendations, or financial planning.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.deniedFinancial.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Topic Blocker"],
eval: {
@ -42,9 +100,11 @@ export const LITELLM_CONTENT_FILTER_CARDS: GuardrailCardInfo[] = [
{
id: "cf_denied_insults",
name: "Insults & Personal Attacks",
nameKey: "guardrails.gardenCatalog.contentFilter.deniedInsults.name",
description: "Detects insults, name-calling, and personal attacks directed at the chatbot, staff, or other people.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.deniedInsults.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Topic Blocker"],
eval: {
@ -58,184 +118,224 @@ export const LITELLM_CONTENT_FILTER_CARDS: GuardrailCardInfo[] = [
{
id: "cf_denied_legal",
name: "Denied Legal Advice",
nameKey: "guardrails.gardenCatalog.contentFilter.deniedLegal.name",
description: "Detects requests for unauthorized legal advice, case analysis, or legal recommendations.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.deniedLegal.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Topic Blocker"],
},
{
id: "cf_denied_medical",
name: "Denied Medical Advice",
nameKey: "guardrails.gardenCatalog.contentFilter.deniedMedical.name",
description: "Detects requests for medical diagnosis, treatment recommendations, or health advice.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.deniedMedical.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Topic Blocker"],
},
{
id: "cf_harmful_violence",
name: "Harmful Violence",
nameKey: "guardrails.gardenCatalog.contentFilter.harmfulViolence.name",
description: "Detects content related to violence, criminal planning, attacks, and violent threats.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.harmfulViolence.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Safety"],
},
{
id: "cf_harmful_self_harm",
name: "Harmful Self-Harm",
nameKey: "guardrails.gardenCatalog.contentFilter.harmfulSelfHarm.name",
description: "Detects content related to self-harm, suicide, and dangerous self-destructive behavior.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.harmfulSelfHarm.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Safety"],
},
{
id: "cf_harmful_child_safety",
name: "Harmful Child Safety",
nameKey: "guardrails.gardenCatalog.contentFilter.harmfulChildSafety.name",
description: "Detects content that could endanger child safety or exploit minors.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.harmfulChildSafety.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Safety"],
},
{
id: "cf_harmful_illegal_weapons",
name: "Harmful Illegal Weapons",
nameKey: "guardrails.gardenCatalog.contentFilter.harmfulIllegalWeapons.name",
description: "Detects content related to illegal weapons manufacturing, distribution, or acquisition.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.harmfulIllegalWeapons.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Safety"],
},
{
id: "cf_bias_gender",
name: "Bias: Gender",
nameKey: "guardrails.gardenCatalog.contentFilter.biasGender.name",
description: "Detects gender-based discrimination, stereotypes, and biased language.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.biasGender.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Bias"],
},
{
id: "cf_bias_racial",
name: "Bias: Racial",
nameKey: "guardrails.gardenCatalog.contentFilter.biasRacial.name",
description: "Detects racial discrimination, stereotypes, and racially biased content.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.biasRacial.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Bias"],
},
{
id: "cf_bias_religious",
name: "Bias: Religious",
nameKey: "guardrails.gardenCatalog.contentFilter.biasReligious.name",
description: "Detects religious discrimination, intolerance, and religiously biased content.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.biasReligious.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Bias"],
},
{
id: "cf_bias_sexual_orientation",
name: "Bias: Sexual Orientation",
nameKey: "guardrails.gardenCatalog.contentFilter.biasSexualOrientation.name",
description: "Detects discrimination based on sexual orientation and related biased content.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.biasSexualOrientation.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Bias"],
},
{
id: "cf_prompt_injection_jailbreak",
name: "Prompt Injection: Jailbreak",
nameKey: "guardrails.gardenCatalog.contentFilter.promptInjectionJailbreak.name",
description: "Detects jailbreak attempts designed to bypass AI safety guidelines and restrictions.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.promptInjectionJailbreak.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Prompt Injection"],
},
{
id: "cf_prompt_injection_data_exfil",
name: "Prompt Injection: Data Exfiltration",
nameKey: "guardrails.gardenCatalog.contentFilter.promptInjectionDataExfil.name",
description: "Detects attempts to extract sensitive data through prompt manipulation.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.promptInjectionDataExfil.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Prompt Injection"],
},
{
id: "cf_prompt_injection_sql",
name: "Prompt Injection: SQL",
nameKey: "guardrails.gardenCatalog.contentFilter.promptInjectionSql.name",
description: "Detects SQL injection attempts embedded in prompts.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.promptInjectionSql.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Prompt Injection"],
},
{
id: "cf_prompt_injection_malicious_code",
name: "Prompt Injection: Malicious Code",
nameKey: "guardrails.gardenCatalog.contentFilter.promptInjectionMaliciousCode.name",
description: "Detects attempts to inject malicious code through prompts.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.promptInjectionMaliciousCode.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Prompt Injection"],
},
{
id: "cf_prompt_injection_system_prompt",
name: "Prompt Injection: System Prompt",
nameKey: "guardrails.gardenCatalog.contentFilter.promptInjectionSystemPrompt.name",
description: "Detects attempts to extract or override system prompts.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.promptInjectionSystemPrompt.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Prompt Injection"],
},
{
id: "cf_toxic_abuse",
name: "Toxic & Abusive Language",
nameKey: "guardrails.gardenCatalog.contentFilter.toxicAbuse.name",
description: "Detects toxic, abusive, and hateful language across multiple languages (EN, AU, DE, ES, FR).",
descriptionKey: "guardrails.gardenCatalog.contentFilter.toxicAbuse.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Toxicity"],
},
{
id: "cf_patterns",
name: "Pattern Matching",
nameKey: "guardrails.gardenCatalog.contentFilter.patterns.name",
description:
"Detect and block sensitive data patterns like SSNs, credit card numbers, API keys, and custom regex patterns.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.patterns.description",
category: "litellm",
subcategory: "Patterns",
subcategoryKey: "guardrails.gardenCatalog.subcategories.patterns",
logo: litellmContentFilterLogo,
tags: ["PII", "Regex", "Data Protection"],
},
{
id: "cf_keywords",
name: "Keyword Blocking",
nameKey: "guardrails.gardenCatalog.contentFilter.keywords.name",
description:
"Block or mask content containing specific keywords or phrases. Upload custom word lists or add individual terms.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.keywords.description",
category: "litellm",
subcategory: "Keywords",
subcategoryKey: "guardrails.gardenCatalog.subcategories.keywords",
logo: litellmContentFilterLogo,
tags: ["Keywords", "Blocklist"],
},
{
id: "block_code_execution",
name: "Block Code Execution",
nameKey: "guardrails.gardenCatalog.contentFilter.blockCodeExecution.name",
description:
"Detects markdown fenced code blocks in requests and responses. Block or mask executable code (e.g. Python, JavaScript, Bash) by language with configurable confidence.",
descriptionKey: "guardrails.gardenCatalog.contentFilter.blockCodeExecution.description",
category: "litellm",
subcategory: "Code Safety",
subcategoryKey: "guardrails.gardenCatalog.subcategories.codeSafety",
logo: litellmContentFilterLogo,
tags: ["Code", "Safety", "Prompt Injection"],
},
{
id: "cf_competitor_intent",
name: "Competitor Name Blocking",
nameKey: "guardrails.gardenCatalog.contentFilter.competitorIntent.name",
description:
"Block or reframe competitor comparison and ranking intent. Detect when users ask to compare or recommend competitors (airline or generic competitor lists).",
descriptionKey: "guardrails.gardenCatalog.contentFilter.competitorIntent.description",
category: "litellm",
subcategory: "Content Category",
subcategoryKey: "guardrails.gardenCatalog.subcategories.contentCategory",
logo: litellmContentFilterLogo,
tags: ["Content Category", "Competitor", "Topic Blocker"],
},
@ -247,6 +347,7 @@ export const DECISION_MODEL_CARDS: GuardrailCardInfo[] = [
name: "TypeSafe Jev",
description:
"TypeSafe's Jev decision model answers your yes/no questions about each request, such as prompt injection or policy checks.",
descriptionKey: "guardrails.gardenCatalog.decisionModel.typesafeJev.description",
category: "decision_model",
logo: providerLogoMap[Providers.TypeSafe] ?? "",
tags: ["Decision Model", "TypeSafe", "Prompt Injection"],
@ -255,7 +356,9 @@ export const DECISION_MODEL_CARDS: GuardrailCardInfo[] = [
{
id: "dm_perplexity",
name: "Perplexity Decision",
nameKey: "guardrails.gardenCatalog.decisionModel.perplexity.name",
description: "Perplexity's pplx-decider model answers your yes/no questions about each request or response.",
descriptionKey: "guardrails.gardenCatalog.decisionModel.perplexity.description",
category: "decision_model",
logo: providerLogoMap[Providers.Perplexity] ?? "",
tags: ["Decision Model", "Perplexity"],
@ -264,8 +367,10 @@ export const DECISION_MODEL_CARDS: GuardrailCardInfo[] = [
{
id: "dm_microsoft",
name: "Microsoft Decision Model",
nameKey: "guardrails.gardenCatalog.decisionModel.microsoft.name",
description:
"Microsoft Foundry decision models, such as Microsoft-Decision-1, answer your yes/no questions about each request or response.",
descriptionKey: "guardrails.gardenCatalog.decisionModel.microsoft.description",
category: "decision_model",
logo: providerLogoMap[Providers.Azure_AI_Studio] ?? "",
tags: ["Decision Model", "Microsoft"],
@ -274,8 +379,10 @@ export const DECISION_MODEL_CARDS: GuardrailCardInfo[] = [
{
id: "dm_openai",
name: "OpenAI Decision Model",
nameKey: "guardrails.gardenCatalog.decisionModel.openai.name",
description:
"OpenAI decision models, such as gpt-6-luna, answer your yes/no questions about each request or response.",
descriptionKey: "guardrails.gardenCatalog.decisionModel.openai.description",
category: "decision_model",
logo: providerLogoMap[Providers.OpenAI] ?? "",
tags: ["Decision Model", "OpenAI"],
@ -284,8 +391,10 @@ export const DECISION_MODEL_CARDS: GuardrailCardInfo[] = [
{
id: "dm_databricks",
name: "Databricks Decision Model",
nameKey: "guardrails.gardenCatalog.decisionModel.databricks.name",
description:
"Decision models served on Databricks, such as OpenJev, answer your yes/no questions about each request or response.",
descriptionKey: "guardrails.gardenCatalog.decisionModel.databricks.description",
category: "decision_model",
logo: providerLogoMap[Providers.Databricks] ?? "",
tags: ["Decision Model", "Databricks"],
@ -299,6 +408,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
name: "Presidio PII",
description:
"Microsoft Presidio for PII detection and anonymization. Supports 30+ entity types with configurable actions.",
descriptionKey: "guardrails.gardenCatalog.partner.presidio.description",
category: "partner",
logo: guardrailLogoMap["Presidio PII"],
tags: ["PII", "Microsoft"],
@ -308,6 +418,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "bedrock",
name: "Bedrock Guardrail",
description: "AWS Bedrock Guardrails for content filtering, topic avoidance, and sensitive information detection.",
descriptionKey: "guardrails.gardenCatalog.partner.bedrock.description",
category: "partner",
logo: guardrailLogoMap["Bedrock Guardrail"],
tags: ["AWS", "Content Safety"],
@ -317,6 +428,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "lakera",
name: "Lakera",
description: "AI security platform protecting against prompt injections, data leakage, and harmful content.",
descriptionKey: "guardrails.gardenCatalog.partner.lakera.description",
category: "partner",
logo: guardrailLogoMap["Lakera"],
tags: ["Security", "Prompt Injection"],
@ -326,6 +438,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "openai_moderation",
name: "OpenAI Moderation",
description: "OpenAI's content moderation API for detecting harmful content across multiple categories.",
descriptionKey: "guardrails.gardenCatalog.partner.openaiModeration.description",
category: "partner",
logo: guardrailLogoMap["OpenAI Moderation"],
tags: ["Content Moderation", "OpenAI"],
@ -334,6 +447,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "google_model_armor",
name: "Google Cloud Model Armor",
description: "Google Cloud's model protection service for safe and responsible AI deployments.",
descriptionKey: "guardrails.gardenCatalog.partner.googleModelArmor.description",
category: "partner",
logo: guardrailLogoMap["Google Cloud Model Armor"],
tags: ["Google Cloud", "Safety"],
@ -342,6 +456,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "guardrails_ai",
name: "Guardrails AI",
description: "Open-source framework for adding structural, type, and quality guarantees to LLM outputs.",
descriptionKey: "guardrails.gardenCatalog.partner.guardrailsAi.description",
category: "partner",
logo: guardrailLogoMap["Guardrails AI"],
tags: ["Open Source", "Validation"],
@ -350,6 +465,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "zscaler",
name: "Zscaler AI Guard",
description: "Enterprise AI security from Zscaler for monitoring and protecting AI/ML workloads.",
descriptionKey: "guardrails.gardenCatalog.partner.zscaler.description",
category: "partner",
logo: guardrailLogoMap["Zscaler AI Guard"],
tags: ["Enterprise", "Security"],
@ -358,6 +474,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "panw",
name: "PANW Prisma AIRS",
description: "Palo Alto Networks Prisma AI Runtime Security for securing AI applications in production.",
descriptionKey: "guardrails.gardenCatalog.partner.panw.description",
category: "partner",
logo: guardrailLogoMap["PANW Prisma AIRS"],
tags: ["Enterprise", "Security"],
@ -367,6 +484,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
name: "Cisco AI Defense",
description:
"Cisco AI Defense Inspection API for runtime protection: prompt injection, PII/PCI/PHI, harassment, hate speech, profanity, violence, and code detection.",
descriptionKey: "guardrails.gardenCatalog.partner.ciscoAiDefense.description",
category: "partner",
logo: guardrailLogoMap["Cisco AI Defense"],
tags: ["Enterprise", "Security", "Prompt Injection", "PII"],
@ -376,6 +494,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "noma",
name: "Noma Security",
description: "AI security platform for detecting and preventing AI-specific threats and vulnerabilities.",
descriptionKey: "guardrails.gardenCatalog.partner.noma.description",
category: "partner",
logo: guardrailLogoMap["Noma Security"],
tags: ["Security", "Threat Detection"],
@ -384,6 +503,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "aporia",
name: "Aporia AI",
description: "Real-time AI guardrails for hallucination detection, topic control, and policy enforcement.",
descriptionKey: "guardrails.gardenCatalog.partner.aporia.description",
category: "partner",
logo: guardrailLogoMap["Aporia AI"],
tags: ["Hallucination", "Policy"],
@ -392,6 +512,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "aim",
name: "AIM Guardrail",
description: "AIM Security guardrails for comprehensive AI threat detection and mitigation.",
descriptionKey: "guardrails.gardenCatalog.partner.aim.description",
category: "partner",
logo: guardrailLogoMap["AIM Guardrail"],
tags: ["Security", "Threat Detection"],
@ -400,6 +521,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "cato_networks",
name: "Cato Networks Guardrail",
description: "Cato Networks guardrails for comprehensive AI threat detection and mitigation.",
descriptionKey: "guardrails.gardenCatalog.partner.catoNetworks.description",
category: "partner",
logo: guardrailLogoMap["Cato Networks Guardrail"],
tags: ["Security", "Threat Detection"],
@ -408,6 +530,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "prompt_security",
name: "Prompt Security",
description: "Protect against prompt injection attacks, data leakage, and other LLM security threats.",
descriptionKey: "guardrails.gardenCatalog.partner.promptSecurity.description",
category: "partner",
logo: guardrailLogoMap["Prompt Security"],
tags: ["Prompt Injection", "Security"],
@ -416,6 +539,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "lasso",
name: "Lasso Guardrail",
description: "Content moderation and safety guardrails for responsible AI deployments.",
descriptionKey: "guardrails.gardenCatalog.partner.lasso.description",
category: "partner",
logo: guardrailLogoMap["Lasso Guardrail"],
tags: ["Content Moderation"],
@ -424,6 +548,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "pangea",
name: "Pangea Guardrail",
description: "Pangea's AI guardrails for secure, compliant, and trustworthy AI applications.",
descriptionKey: "guardrails.gardenCatalog.partner.pangea.description",
category: "partner",
logo: guardrailLogoMap["Pangea Guardrail"],
tags: ["Compliance", "Security"],
@ -432,6 +557,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "enkryptai",
name: "EnkryptAI",
description: "AI security and governance platform for enterprise AI safety and compliance.",
descriptionKey: "guardrails.gardenCatalog.partner.enkryptai.description",
category: "partner",
logo: guardrailLogoMap["EnkryptAI"],
tags: ["Enterprise", "Governance"],
@ -440,6 +566,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "javelin",
name: "Javelin Guardrails",
description: "AI gateway with built-in guardrails for secure and compliant AI operations.",
descriptionKey: "guardrails.gardenCatalog.partner.javelin.description",
category: "partner",
logo: guardrailLogoMap["Javelin Guardrails"],
tags: ["Gateway", "Security"],
@ -448,6 +575,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "pillar",
name: "Pillar Guardrail",
description: "AI safety platform for monitoring, testing, and securing AI systems.",
descriptionKey: "guardrails.gardenCatalog.partner.pillar.description",
category: "partner",
logo: guardrailLogoMap["Pillar Guardrail"],
tags: ["Monitoring", "Safety"],
@ -456,6 +584,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
id: "akto",
name: "Akto Guardrail",
description: "AI security platform from Akto.io with automatic monitoring and guardrails for AI/ML applications.",
descriptionKey: "guardrails.gardenCatalog.partner.akto.description",
category: "partner",
logo: guardrailLogoMap["Akto"],
tags: ["Security", "Safety", "Monitoring"],
@ -465,6 +594,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
name: "PromptGuard",
description:
"AI security gateway with prompt injection detection, PII redaction, topic filtering, entity blocklists, and hallucination detection. Self-hostable with drop-in proxy integration.",
descriptionKey: "guardrails.gardenCatalog.partner.promptguard.description",
category: "partner",
logo: guardrailLogoMap["PromptGuard"],
tags: ["Security", "Prompt Injection", "PII"],
@ -482,6 +612,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
name: "XecGuard",
description:
"CyCraft XecGuard AI security gateway. Multi-policy scanning (prompt injection, harmful content, PII, system-prompt enforcement) plus RAG context grounding.",
descriptionKey: "guardrails.gardenCatalog.partner.xecguard.description",
category: "partner",
logo: guardrailLogoMap["XecGuard"],
tags: ["Security", "Policy", "Grounding", "RAG"],
@ -492,6 +623,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
name: "DeepKeep AI Firewall",
description:
"DeepKeep AI Firewall for comprehensive LLM security — prompt injection detection, PII protection, content moderation, and policy enforcement with configurable guardrail pipelines.",
descriptionKey: "guardrails.gardenCatalog.partner.deepkeep.description",
category: "partner",
logo: guardrailLogoMap["DeepKeep AI Firewall"],
tags: ["Security", "Prompt Injection", "PII", "Firewall"],
@ -502,6 +634,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
name: "RepelloAI Argus",
description:
"RepelloAI Argus scans prompts and responses against policies configured per asset in the Repello dashboard.",
descriptionKey: "guardrails.gardenCatalog.partner.repelloai.description",
category: "partner",
logo: guardrailLogoMap["RepelloAI Argus"],
tags: ["Security", "Policy", "Prompt Injection"],
@ -512,6 +645,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
name: "Straiker",
description:
"Defend AI Agentic Guardrails: Indirect/Direct Prompt Injection, Tool Misuse, Malicious MCP and Skills",
descriptionKey: "guardrails.gardenCatalog.partner.straiker.description",
category: "partner",
logo: guardrailLogoMap["Straiker"],
tags: ["Agentic", "Prompt Injection", "Tool Misuse", "MCP", "Skills"],
@ -522,6 +656,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
name: "Alice",
description:
"Policy-based guardrails for prompts and model responses, evaluated per application so one proxy can enforce a different policy set per team or product.",
descriptionKey: "guardrails.gardenCatalog.partner.alice.description",
category: "partner",
logo: guardrailLogoMap["Alice"],
tags: ["Content Moderation", "Prompt Injection", "PII", "Policy"],
@ -532,6 +667,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
name: "Microsoft Agent 365",
description:
"Microsoft Agent 365 tool-call governance: Defender threat evaluation and observability for MCP tool calls, acting on behalf of the signed-in user",
descriptionKey: "guardrails.gardenCatalog.partner.agent365.description",
category: "partner",
logo: guardrailLogoMap["Microsoft Agent 365"],
tags: ["Agentic", "MCP", "Tool Misuse", "Observability"],
@ -542,6 +678,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
name: "LLM Shield Proxy",
description:
"Self-hosted PII redaction that puts the original values back into the model's response, so the provider never receives personal data while the end user still sees it.",
descriptionKey: "guardrails.gardenCatalog.partner.llmShieldProxy.description",
category: "partner",
logo: guardrailLogoMap["LLM Shield Proxy"],
tags: ["PII", "Data Privacy", "Compliance", "Streaming"],
@ -552,6 +689,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
name: "Conduct Guard",
description:
"Conduct Guard evaluates prompts against workspace rules before the model call: prompt injection, PII, and custom policies, with block, warning, and approval verdicts.",
descriptionKey: "guardrails.gardenCatalog.partner.conduct.description",
category: "partner",
logo: guardrailLogoMap["Conduct Guard"],
tags: ["Security", "Prompt Injection", "PII", "Policy"],
@ -562,6 +700,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
name: "Wingback",
description:
"Wingback runtime security for LiteLLM — PII detection, prompt injection, toxic content, and policy enforcement via the Wingback connectors service.",
descriptionKey: "guardrails.gardenCatalog.partner.wingback.description",
category: "partner",
logo: guardrailLogoMap["Wingback"],
tags: ["Security", "PII", "Prompt Injection", "Runtime"],
@ -572,6 +711,7 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
name: "Airia Guardrail",
description:
"Airia AI security and governance platform. Runtime guardrails for prompt injection, data leakage prevention, and responsible AI, enforced on every prompt and response.",
descriptionKey: "guardrails.gardenCatalog.partner.airia.description",
category: "partner",
logo: guardrailLogoMap["Airia Guardrail"],
tags: ["Security", "Prompt Injection", "PII", "Secrets", "Redaction"],

View file

@ -1,17 +1,28 @@
import { useTerminology } from "@/contexts/TerminologyContext";
import React, { useState } from "react";
import type { ParseKeys } from "i18next";
import { useTranslation } from "react-i18next";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import AddGuardrailForm from "./add_guardrail_form";
import { Logo } from "@/components/molecules/logo/Logo";
import { GUARDRAIL_PRESETS } from "./guardrail_garden_configs";
import { GuardrailCardInfo } from "./guardrail_garden_data";
import { GuardrailCardInfo, gardenCardDescription, gardenCardName, gardenTagLabel } from "./guardrail_garden_data";
const CATEGORY_LABELS: Record<GuardrailCardInfo["category"], { provider: string; type: string }> = {
litellm: { provider: "LiteLLM Content Filter", type: "Content Filter" },
decision_model: { provider: "Decision Model", type: "Decision Model" },
partner: { provider: "Partner Guardrail", type: "Partner" },
const CATEGORY_LABEL_KEYS: Record<GuardrailCardInfo["category"], { provider: ParseKeys; type: ParseKeys }> = {
litellm: {
provider: "guardrails.gardenDetail.categories.litellm.provider",
type: "guardrails.gardenDetail.categories.litellm.type",
},
decision_model: {
provider: "guardrails.gardenDetail.categories.decisionModel.provider",
type: "guardrails.gardenDetail.categories.decisionModel.type",
},
partner: {
provider: "guardrails.gardenDetail.categories.partner.provider",
type: "guardrails.gardenDetail.categories.partner.type",
},
};
interface GuardrailDetailViewProps {
@ -22,34 +33,66 @@ interface GuardrailDetailViewProps {
}
const GuardrailDetailView: React.FC<GuardrailDetailViewProps> = ({ card, onBack, accessToken, onGuardrailCreated }) => {
const { t } = useTranslation();
const term = useTerminology();
const name = gardenCardName(card, t);
const description = term(gardenCardDescription(card, t));
const [isAddFormVisible, setIsAddFormVisible] = useState(false);
const [activeTab, setActiveTab] = useState("overview");
const detailRows = [
{ property: "Provider", value: CATEGORY_LABELS[card.category].provider },
...(card.subcategory ? [{ property: "Subcategory", value: card.subcategory }] : []),
...(card.category === "decision_model"
? [{ property: "Cost", value: "Billed per call by the decision model provider" }]
{
property: t("guardrails.gardenDetail.detailRows.provider"),
value: t(CATEGORY_LABEL_KEYS[card.category].provider),
},
...(card.subcategoryKey
? [{ property: t("guardrails.gardenDetail.detailRows.subcategory"), value: t(card.subcategoryKey) }]
: []),
...(card.category === "decision_model"
? [
{
property: t("guardrails.gardenDetail.detailRows.cost"),
value: t("guardrails.gardenDetail.detailValues.decisionModelCost"),
},
]
: []),
...(card.category === "litellm"
? [
{
property: t("guardrails.gardenDetail.detailRows.cost"),
value: t("guardrails.gardenDetail.detailValues.litellmCost"),
},
]
: []),
...(card.category === "litellm"
? [
{
property: t("guardrails.gardenDetail.detailRows.externalDependencies"),
value: t("guardrails.gardenDetail.detailValues.noExternalDependencies"),
},
]
: []),
...(card.category === "litellm"
? [{ property: t("guardrails.gardenDetail.detailRows.latency"), value: card.eval?.latency || "<1ms" }]
: []),
...(card.category === "litellm" ? [{ property: "Cost", value: "$0 / request" }] : []),
...(card.category === "litellm" ? [{ property: "External Dependencies", value: "None" }] : []),
...(card.category === "litellm" ? [{ property: "Latency", value: card.eval?.latency || "<1ms" }] : []),
];
const evalRows = card.eval
? [
{ metric: "Precision", value: `${card.eval.precision}%` },
{ metric: "Recall", value: `${card.eval.recall}%` },
{ metric: "F1 Score", value: `${card.eval.f1}%` },
{ metric: "Test Cases", value: String(card.eval.testCases) },
{ metric: "False Positives", value: "0" },
{ metric: "False Negatives", value: "0" },
{ metric: "Latency (p50)", value: card.eval.latency },
{ metric: t("guardrails.gardenDetail.evalMetrics.precision"), value: `${card.eval.precision}%` },
{ metric: t("guardrails.gardenDetail.evalMetrics.recall"), value: `${card.eval.recall}%` },
{ metric: t("guardrails.gardenDetail.evalMetrics.f1Score"), value: `${card.eval.f1}%` },
{ metric: t("guardrails.gardenDetail.evalMetrics.testCases"), value: String(card.eval.testCases) },
{ metric: t("guardrails.gardenDetail.evalMetrics.falsePositives"), value: "0" },
{ metric: t("guardrails.gardenDetail.evalMetrics.falseNegatives"), value: "0" },
{ metric: t("guardrails.gardenDetail.evalMetrics.latencyP50"), value: card.eval.latency },
]
: [];
const tabs = [{ key: "overview", label: "Overview" }, ...(card.eval ? [{ key: "eval", label: "Eval Results" }] : [])];
const tabs = [
{ key: "overview", label: t("guardrails.gardenDetail.tabs.overview") },
...(card.eval ? [{ key: "eval", label: t("guardrails.gardenDetail.tabs.evalResults") }] : []),
];
return (
<div className="mx-auto max-w-[960px]">
@ -59,21 +102,21 @@ const GuardrailDetailView: React.FC<GuardrailDetailViewProps> = ({ card, onBack,
className="mb-6 inline-flex cursor-pointer items-center gap-1.5 text-sm text-muted-foreground"
>
<ArrowLeft className="size-3" />
<span>{card.name}</span>
<span>{name}</span>
</div>
{/* ── Header block (Vertex-style) ── */}
<div className="mb-2 flex items-center gap-4">
<Logo src={card.logo} label={card.name} className="w-10 h-10 rounded-lg object-contain shrink-0" />
<h1 className="m-0 text-[28px] font-normal leading-tight text-foreground">{card.name}</h1>
<Logo src={card.logo} label={name} className="w-10 h-10 rounded-lg object-contain shrink-0" />
<h1 className="m-0 text-[28px] font-normal leading-tight text-foreground">{name}</h1>
</div>
<p className="m-0 mb-5 text-sm leading-relaxed text-muted-foreground">{term(card.description)}</p>
<p className="m-0 mb-5 text-sm leading-relaxed text-muted-foreground">{description}</p>
{/* Action buttons — outlined style like Vertex */}
<div className="mb-8 flex gap-2.5">
<Button variant="outline" className="rounded-full" onClick={() => setIsAddFormVisible(true)}>
Create Guardrail
{t("guardrails.gardenDetail.createGuardrail")}
</Button>
</div>
@ -102,17 +145,23 @@ const GuardrailDetailView: React.FC<GuardrailDetailViewProps> = ({ card, onBack,
<div className="flex gap-16">
{/* Left column — overview + details table */}
<div className="min-w-0 flex-1">
<h2 className="m-0 mb-3 text-lg font-normal text-foreground">Overview</h2>
<p className="m-0 mb-8 text-sm leading-[1.7] text-foreground">{term(card.description)}</p>
<h2 className="m-0 mb-3 text-lg font-normal text-foreground">
{t("guardrails.gardenDetail.overviewHeading")}
</h2>
<p className="m-0 mb-8 text-sm leading-[1.7] text-foreground">{description}</p>
<h2 className="m-0 mb-1 text-lg font-normal text-foreground">Guardrail Details</h2>
<p className="m-0 mb-4 text-[13px] text-muted-foreground">Details are as follows</p>
<h2 className="m-0 mb-1 text-lg font-normal text-foreground">
{t("guardrails.gardenDetail.detailsHeading")}
</h2>
<p className="m-0 mb-4 text-[13px] text-muted-foreground">{t("guardrails.gardenDetail.detailsSubtitle")}</p>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-border">
<th className="w-50 py-3 text-left font-medium text-muted-foreground">Property</th>
<th className="py-3 text-left font-medium text-muted-foreground">{card.name}</th>
<th className="w-50 py-3 text-left font-medium text-muted-foreground">
{t("guardrails.gardenDetail.propertyColumn")}
</th>
<th className="py-3 text-left font-medium text-muted-foreground">{name}</th>
</tr>
</thead>
<tbody>
@ -130,27 +179,27 @@ const GuardrailDetailView: React.FC<GuardrailDetailViewProps> = ({ card, onBack,
<div className="w-60 shrink-0">
{/* Guardrail ID */}
<div className="mb-7">
<div className="mb-1 text-xs text-muted-foreground">Guardrail ID</div>
<div className="mb-1 text-xs text-muted-foreground">{t("guardrails.gardenDetail.guardrailIdLabel")}</div>
<div className="break-all text-[13px] text-foreground">litellm/{card.id}</div>
</div>
{/* Type */}
<div className="mb-7">
<div className="mb-1 text-xs text-muted-foreground">Type</div>
<div className="text-[13px] text-foreground">{CATEGORY_LABELS[card.category].type}</div>
<div className="mb-1 text-xs text-muted-foreground">{t("guardrails.gardenDetail.typeLabel")}</div>
<div className="text-[13px] text-foreground">{t(CATEGORY_LABEL_KEYS[card.category].type)}</div>
</div>
{/* Tags — pill style like Vertex */}
{card.tags.length > 0 && (
<div className="mb-7">
<div className="mb-2 text-xs text-muted-foreground">Tags</div>
<div className="mb-2 text-xs text-muted-foreground">{t("guardrails.gardenDetail.tagsLabel")}</div>
<div className="flex flex-wrap gap-1.5">
{card.tags.map((tag) => (
<span
key={tag}
className="rounded-2xl border border-border bg-card px-3 py-1 text-xs text-foreground"
>
{tag}
{gardenTagLabel(tag, t)}
</span>
))}
</div>
@ -162,12 +211,18 @@ const GuardrailDetailView: React.FC<GuardrailDetailViewProps> = ({ card, onBack,
{activeTab === "eval" && (
<div>
<h2 className="m-0 mb-4 text-lg font-normal text-foreground">Eval Results</h2>
<h2 className="m-0 mb-4 text-lg font-normal text-foreground">
{t("guardrails.gardenDetail.evalResultsHeading")}
</h2>
<table className="w-full max-w-[560px] border-collapse text-sm">
<thead>
<tr className="border-b border-border bg-muted">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Metric</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Value</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t("guardrails.gardenDetail.metricColumn")}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t("guardrails.gardenDetail.valueColumn")}
</th>
</tr>
</thead>
<tbody>

View file

@ -10,8 +10,10 @@ import { ArrowLeft, Ban, CheckIcon, Code, CopyIcon, EyeOff, Info } from "lucide-
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { ParseKeys } from "i18next";
import React, { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "@/lib/toast";
import { Logo } from "@/components/molecules/logo/Logo";
import { FieldGroup } from "@/components/ui/field";
@ -58,9 +60,9 @@ 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 DEFAULT_ON_ITEMS: readonly { labelKey: ParseKeys; value: boolean }[] = [
{ labelKey: "guardrails.info.settings.defaultOnOptions.yes", value: true },
{ labelKey: "guardrails.info.settings.defaultOnOptions.no", value: false },
];
const SectionHeading: React.FC<{ children: React.ReactNode }> = ({ children }) => (
@ -79,6 +81,7 @@ export interface GuardrailInfoProps {
}
const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose, onDeleted, accessToken, isAdmin }) => {
const { t } = useTranslation();
const [guardrailData, setGuardrailData] = useState<any>(null);
const [guardrailProviderSpecificParams, setGuardrailProviderSpecificParams] = useState<any>(null);
const [loading, setLoading] = useState(true);
@ -193,7 +196,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
setSelectedPiiActions({});
}
} catch (error) {
toast.fromError("Failed to load guardrail information");
toast.fromError(t("guardrails.info.toasts.loadFailed"));
console.error("Error fetching guardrail info:", error);
} finally {
setLoading(false);
@ -486,19 +489,19 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
// Only proceed with update if there are actual changes
if (Object.keys(updateData).length === 0) {
toast.info("No changes detected");
toast.info(t("guardrails.info.toasts.noChanges"));
setIsEditing(false);
return;
}
await updateGuardrailCall(accessToken, guardrailId, updateData);
toast.success("Guardrail updated successfully");
toast.success(t("guardrails.info.toasts.updated"));
setHasUnsavedContentFilterChanges(false);
fetchGuardrailInfo();
setIsEditing(false);
} catch (error) {
console.error("Error updating guardrail:", error);
toast.fromError("Failed to update guardrail");
toast.fromError(t("guardrails.info.toasts.updateFailed"));
}
};
@ -511,18 +514,23 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
const submitLatest = useCallback((values: GuardrailFormValues) => submitRef.current(values), []);
if (loading) {
return <div className="p-4">Loading...</div>;
return <div className="p-4">{t("guardrails.info.loading")}</div>;
}
const backButton = (
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeft className="w-4 h-4" />
Back to Guardrails
{t("guardrails.info.back")}
</Button>
);
if (!guardrailData) {
return <div className="p-4">{backButton}Guardrail not found</div>;
return (
<div className="p-4">
{backButton}
{t("guardrails.info.notFound")}
</div>
);
}
const formatDate = (dateString?: string) => (dateString ? new Date(dateString).toLocaleString() : "-");
@ -546,7 +554,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
<div className="p-4">
<div>
{backButton}
<h1 className="text-2xl font-semibold">{guardrailData.guardrail_name || "Unnamed Guardrail"}</h1>
<h1 className="text-2xl font-semibold">{guardrailData.guardrail_name || t("guardrails.info.unnamed")}</h1>
<div className="flex items-center cursor-pointer">
<p className="text-muted-foreground font-mono">{guardrailData.guardrail_id}</p>
@ -568,11 +576,11 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
<Tabs defaultValue="overview">
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
Overview
{t("guardrails.info.tabs.overview")}
</TabsTrigger>
{isAdmin && (
<TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
Settings
{t("guardrails.info.tabs.settings")}
</TabsTrigger>
)}
</TabsList>
@ -582,7 +590,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
<TabsContent value="overview" keepMounted>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="block p-6">
<p>Provider</p>
<p>{t("guardrails.info.overview.provider")}</p>
<div className="mt-2 flex items-center space-x-2">
<Logo src={logo} label={displayName} className="w-6 h-6" />
<h3 className="text-lg font-medium">{displayName}</h3>
@ -593,10 +601,12 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
<GuardrailStreamScopeCaption raw={guardrailData.litellm_params?.stream_scope} />
<Card className="block p-6">
<p>Created At</p>
<p>{t("guardrails.info.overview.createdAt")}</p>
<div className="mt-2">
<h3 className="text-lg font-medium">{formatDate(guardrailData.created_at)}</h3>
<p>Last Updated: {formatDate(guardrailData.updated_at)}</p>
<p>
{t("guardrails.info.overview.lastUpdated")} {formatDate(guardrailData.updated_at)}
</p>
</div>
</Card>
</div>
@ -605,9 +615,12 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
Object.keys(guardrailData.litellm_params.pii_entities_config).length > 0 && (
<Card className="block mt-6 p-6">
<div className="flex justify-between items-center">
<p className="font-medium">PII Protection</p>
<p className="font-medium">{t("guardrails.info.overview.piiProtection")}</p>
<Badge variant="secondary">
{Object.keys(guardrailData.litellm_params.pii_entities_config).length} PII entities configured
{Object.keys(guardrailData.litellm_params.pii_entities_config).length}{" "}
{t("guardrails.info.overview.piiEntitiesConfigured", {
count: Object.keys(guardrailData.litellm_params.pii_entities_config).length,
})}
</Badge>
</div>
</Card>
@ -616,11 +629,13 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
{guardrailData.litellm_params?.pii_entities_config &&
Object.keys(guardrailData.litellm_params.pii_entities_config).length > 0 && (
<Card className="block mt-6 p-6">
<p className="mb-4 text-lg font-semibold">PII Entity Configuration</p>
<p className="mb-4 text-lg font-semibold">{t("guardrails.info.overview.piiEntityConfiguration")}</p>
<div className="border rounded-lg overflow-hidden shadow-xs">
<div className="bg-muted px-5 py-3 border-b flex">
<p className="flex-1 font-semibold text-foreground">Entity Type</p>
<p className="flex-1 font-semibold text-foreground">Configuration</p>
<p className="flex-1 font-semibold text-foreground">{t("guardrails.info.overview.entityType")}</p>
<p className="flex-1 font-semibold text-foreground">
{t("guardrails.info.overview.configuration")}
</p>
</div>
<div className="max-h-[400px] overflow-y-auto">
{Object.entries(guardrailData.litellm_params?.pii_entities_config).map(([key, value]) => (
@ -655,12 +670,12 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-2">
<Code className="text-info" />
<p className="font-medium text-lg">Custom Code</p>
<p className="font-medium text-lg">{t("guardrails.info.overview.customCode")}</p>
</div>
{isAdmin && !isConfigGuardrail && (
<Button variant="outline" size="sm" onClick={() => setCustomCodeModalVisible(true)}>
<Code />
Edit Code
{t("guardrails.info.overview.editCode")}
</Button>
)}
</div>
@ -689,10 +704,14 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
<TabsContent value="settings" keepMounted>
<Card className="block p-6">
<div className="flex items-center gap-2 mb-4">
<h3 className="mr-auto text-lg font-medium">Guardrail Settings</h3>
<h3 className="mr-auto text-lg font-medium">{t("guardrails.info.settings.heading")}</h3>
{isConfigGuardrail && (
<SimpleTooltip content="Guardrail is defined in the config file and cannot be edited.">
<Info role="img" aria-label="Config guardrail details" className="size-4 text-muted-foreground" />
<SimpleTooltip content={t("guardrails.info.settings.configTooltip")}>
<Info
role="img"
aria-label={t("guardrails.info.settings.configDetailsLabel")}
className="size-4 text-muted-foreground"
/>
</SimpleTooltip>
)}
<GuardrailSettingsActions
@ -713,18 +732,27 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
<GuardrailField
control={form.control}
name="guardrail_name"
label="Guardrail Name"
rules={requiredRule("Please input a guardrail name")}
label={t("guardrails.info.settings.nameLabel")}
rules={requiredRule(t("guardrails.info.settings.nameRequired"))}
>
{({ ref, value, ...field }) => (
<Input {...field} ref={ref} value={asText(value)} placeholder="Enter guardrail name" />
<Input
{...field}
ref={ref}
value={asText(value)}
placeholder={t("guardrails.info.settings.namePlaceholder")}
/>
)}
</GuardrailField>
<GuardrailField control={form.control} name="default_on" label="Default On">
<GuardrailField
control={form.control}
name="default_on"
label={t("guardrails.info.settings.defaultOnLabel")}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": describedBy }) => (
<Select
items={DEFAULT_ON_ITEMS}
items={DEFAULT_ON_ITEMS.map(({ labelKey, value }) => ({ label: t(labelKey), value }))}
value={typeof value === "boolean" ? value : null}
onValueChange={(next: boolean | null) => onChange(next)}
>
@ -734,11 +762,15 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
aria-describedby={describedBy}
className="w-full"
>
<SelectValue placeholder="Select an option" />
<SelectValue placeholder={t("guardrails.info.settings.defaultOnPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={true}>Yes</SelectItem>
<SelectItem value={false}>No</SelectItem>
<SelectItem value={true}>
{t("guardrails.info.settings.defaultOnOptions.yes")}
</SelectItem>
<SelectItem value={false}>
{t("guardrails.info.settings.defaultOnOptions.no")}
</SelectItem>
</SelectContent>
</Select>
)}
@ -753,8 +785,8 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
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.",
t("guardrails.info.settings.skipSystemMessagesLabel"),
t("guardrails.info.settings.skipSystemMessagesHint"),
)}
>
{(fieldControl) => <SkipMessageSelect control={fieldControl} />}
@ -764,8 +796,8 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
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.",
t("guardrails.info.settings.skipToolMessagesLabel"),
t("guardrails.info.settings.skipToolMessagesHint"),
)}
>
{(fieldControl) => <SkipMessageSelect control={fieldControl} />}
@ -777,7 +809,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
/>
{guardrailData.litellm_params?.guardrail === "presidio" && (
<>
<SectionHeading>PII Protection</SectionHeading>
<SectionHeading>{t("guardrails.info.settings.sections.piiProtection")}</SectionHeading>
<div className="mb-6">
{guardrailSettings && (
<PiiConfiguration
@ -804,7 +836,9 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
/>
{(guardrailData.litellm_params?.guardrail === "tool_permission" ||
guardrailProviderSpecificParams) && <SectionHeading>Provider Settings</SectionHeading>}
guardrailProviderSpecificParams) && (
<SectionHeading>{t("guardrails.info.settings.sections.providerSettings")}</SectionHeading>
)}
{guardrailData.litellm_params?.guardrail === "tool_permission" ? (
<ToolPermissionRulesEditor value={toolPermissionConfig} onChange={setToolPermissionConfig} />
@ -848,8 +882,12 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
</>
)}
<SectionHeading>Advanced Settings</SectionHeading>
<GuardrailField control={form.control} name="guardrail_info" label="Guardrail Information">
<SectionHeading>{t("guardrails.info.settings.sections.advancedSettings")}</SectionHeading>
<GuardrailField
control={form.control}
name="guardrail_info"
label={t("guardrails.info.settings.guardrailInfoLabel")}
>
{({ ref, value, ...field }) => (
<Textarea {...field} ref={ref} value={asText(value)} rows={5} />
)}
@ -865,9 +903,9 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
resetToolPermissionEditor();
}}
>
Cancel
{t("guardrails.info.settings.cancel")}
</Button>
<Button type="submit">Save Changes</Button>
<Button type="submit">{t("guardrails.info.settings.save")}</Button>
</div>
</FieldGroup>
</form>

View file

@ -1,3 +1,5 @@
import type { ParseKeys, TFunction } from "i18next";
import i18n from "@/i18n/i18n";
import airiaLogo from "../../../../../public/assets/logos/airia.svg";
import aimSecurityLogo from "../../../../../public/assets/logos/aim_security.jpeg";
import aktoLogo from "../../../../../public/assets/logos/akto.svg";
@ -133,10 +135,10 @@ export const normalizeLoggingOnlyScopeChoice = (
directionalScopeSupported: boolean,
): LoggingOnlyScopeChoice => (directionalScopeSupported || choice === "default" ? choice : "default");
const LOGGING_ONLY_SCOPE_OPTIONS: LoggingOnlyScopeOption[] = [
{ label: "Default (request and response)", value: "default" },
{ label: "Input only (request)", value: "input" },
{ label: "Output only (response)", value: "output" },
const LOGGING_ONLY_SCOPE_OPTIONS: { labelKey: ParseKeys; value: LoggingOnlyScopeChoice }[] = [
{ labelKey: "guardrails.modeOptions.loggingOnlyScope.default", value: "default" },
{ labelKey: "guardrails.modeOptions.loggingOnlyScope.input", value: "input" },
{ labelKey: "guardrails.modeOptions.loggingOnlyScope.output", value: "output" },
];
export const loggingOnlyScopeToChoice = (v: string | null | undefined): LoggingOnlyScopeChoice =>
@ -185,10 +187,10 @@ export const getLoggingOnlyScopeUpdate = (
};
};
export const formatLoggingOnlyScope = (v: string | null | undefined): string => {
if (v === "input") return "Input only (request)";
if (v === "output") return "Output only (response)";
return "Default (request and response)";
export const formatLoggingOnlyScope = (v: string | null | undefined, t: TFunction = i18n.t.bind(i18n)): string => {
if (v === "input") return t("guardrails.modeOptions.loggingOnlyScope.input");
if (v === "output") return t("guardrails.modeOptions.loggingOnlyScope.output");
return t("guardrails.modeOptions.loggingOnlyScope.default");
};
export const modeIncludesLoggingOnly = (raw: unknown): boolean => {
@ -203,10 +205,13 @@ export const modeIncludesLoggingOnly = (raw: unknown): boolean => {
return toModeArray(fallback).includes("logging_only") || taggedModes;
};
export const getLoggingOnlyScopeOptions = (directionalScopeSupported: boolean): LoggingOnlyScopeOption[] =>
directionalScopeSupported
? LOGGING_ONLY_SCOPE_OPTIONS
: LOGGING_ONLY_SCOPE_OPTIONS.filter((option) => option.value === "default");
export const getLoggingOnlyScopeOptions = (
directionalScopeSupported: boolean,
t: TFunction = i18n.t.bind(i18n),
): LoggingOnlyScopeOption[] =>
LOGGING_ONLY_SCOPE_OPTIONS.filter((option) => directionalScopeSupported || option.value === "default").map(
({ labelKey, value }) => ({ label: t(labelKey), value }),
);
export const supportsDirectionalLoggingOnlyScope = (
settings: { providers_without_directional_logging_only_scope?: string[] } | null,
@ -392,10 +397,10 @@ export function choiceToSkipToolForCreate(choice: SkipToolMessageChoice | undefi
export const GUARDRAIL_STREAM_SCOPES = ["both", "streaming", "non_streaming"] as const;
export type GuardrailStreamScope = (typeof GUARDRAIL_STREAM_SCOPES)[number];
export const STREAM_SCOPE_OPTIONS: { value: GuardrailStreamScope; label: string }[] = [
{ value: "both", label: "Streaming and non-streaming" },
{ value: "streaming", label: "Streaming only" },
{ value: "non_streaming", label: "Non-streaming only" },
export const STREAM_SCOPE_OPTIONS: { value: GuardrailStreamScope; labelKey: ParseKeys }[] = [
{ value: "both", labelKey: "guardrails.modeOptions.streamScope.both" },
{ value: "streaming", labelKey: "guardrails.modeOptions.streamScope.streaming" },
{ value: "non_streaming", labelKey: "guardrails.modeOptions.streamScope.nonStreaming" },
];
export const isGuardrailStreamScope = (value: unknown): value is GuardrailStreamScope =>
@ -427,16 +432,17 @@ export const streamScopePayload = (
return Object.fromEntries(Object.entries(perMode).filter((entry) => entry[1] !== "both"));
};
export const formatGuardrailStreamScope = (raw: unknown): string => {
export const formatGuardrailStreamScope = (raw: unknown, t: TFunction = i18n.t.bind(i18n)): string => {
if (isGuardrailStreamScope(raw)) {
return STREAM_SCOPE_OPTIONS.find((option) => option.value === raw)?.label ?? raw;
const option = STREAM_SCOPE_OPTIONS.find((candidate) => candidate.value === raw);
return option ? t(option.labelKey) : raw;
}
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
const entries = Object.entries(raw as Record<string, unknown>).filter(
(entry): entry is [string, GuardrailStreamScope] => isGuardrailStreamScope(entry[1]),
);
if (entries.length === 0) return "";
return entries.map(([mode, scope]) => `${mode}: ${formatGuardrailStreamScope(scope)}`).join(", ");
return entries.map(([mode, scope]) => `${mode}: ${formatGuardrailStreamScope(scope, t)}`).join(", ");
}
return "";
};

View file

@ -1,4 +1,6 @@
import type { ParseKeys } from "i18next";
import React from "react";
import { useTranslation } from "react-i18next";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { PasswordInput } from "@/components/shared/PasswordInput";
import NumericalInput from "@/components/shared/numerical_input";
@ -41,9 +43,9 @@ interface DictFieldProps {
value: unknown;
}
const BOOLEAN_ITEMS = [
{ label: "True", value: true },
{ label: "False", value: false },
const BOOLEAN_ITEMS: { labelKey: ParseKeys; value: boolean }[] = [
{ labelKey: "guardrails.optionalParams.boolean.true", value: true },
{ labelKey: "guardrails.optionalParams.boolean.false", value: false },
];
const isSecretKey = (fieldKey: string): boolean =>
@ -59,11 +61,13 @@ const BooleanSelect: React.FC<{ control: GuardrailFieldControlProps; placeholder
control,
placeholder,
}) => {
const { t } = useTranslation();
const { id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy } = control;
const items = BOOLEAN_ITEMS.map(({ labelKey, value: itemValue }) => ({ label: t(labelKey), value: itemValue }));
return (
<Select
items={BOOLEAN_ITEMS}
items={items}
value={typeof value === "boolean" ? value : null}
onValueChange={(next: boolean | null) => onChange(next)}
>
@ -71,14 +75,15 @@ const BooleanSelect: React.FC<{ control: GuardrailFieldControlProps; placeholder
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={true}>True</SelectItem>
<SelectItem value={false}>False</SelectItem>
<SelectItem value={true}>{t("guardrails.optionalParams.boolean.true")}</SelectItem>
<SelectItem value={false}>{t("guardrails.optionalParams.boolean.false")}</SelectItem>
</SelectContent>
</Select>
);
};
const DictField: React.FC<DictFieldProps> = ({ field, fullFieldKey, control, value }) => {
const { t } = useTranslation();
const [selectedEntries, setSelectedEntries] = React.useState<Array<{ key: string; id: string }>>([]);
const [availableKeys, setAvailableKeys] = React.useState<string[]>(field.dict_key_options || []);
@ -133,7 +138,7 @@ const DictField: React.FC<DictFieldProps> = ({ field, fullFieldKey, control, val
id={fieldControl.id}
name={fieldControl.name}
step={1}
placeholder={`Enter ${entry.key} value`}
placeholder={t("guardrails.optionalParams.dict.enterValue", { name: entry.key })}
value={asText(fieldControl.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
fieldControl.onChange(toNumberValue(event.target.value))
@ -146,7 +151,12 @@ const DictField: React.FC<DictFieldProps> = ({ field, fullFieldKey, control, val
}
if (field.dict_value_type === "boolean") {
return <BooleanSelect control={fieldControl} placeholder={`Select ${entry.key} value`} />;
return (
<BooleanSelect
control={fieldControl}
placeholder={t("guardrails.optionalParams.dict.selectValue", { name: entry.key })}
/>
);
}
return (
@ -154,7 +164,7 @@ const DictField: React.FC<DictFieldProps> = ({ field, fullFieldKey, control, val
id={fieldControl.id}
name={fieldControl.name}
ref={fieldControl.ref}
placeholder={`Enter ${entry.key} value`}
placeholder={t("guardrails.optionalParams.dict.enterValue", { name: entry.key })}
value={asText(fieldControl.value)}
onChange={fieldControl.onChange}
onBlur={fieldControl.onBlur}
@ -170,7 +180,7 @@ const DictField: React.FC<DictFieldProps> = ({ field, fullFieldKey, control, val
className="text-destructive hover:text-destructive/80"
onClick={() => removeEntry(entry.id, entry.key)}
>
Remove
{t("guardrails.optionalParams.dict.remove")}
</Button>
</div>
))}
@ -184,7 +194,7 @@ const DictField: React.FC<DictFieldProps> = ({ field, fullFieldKey, control, val
onValueChange={(next: string | null) => next && addEntry(next)}
>
<SelectTrigger className="w-50">
<SelectValue placeholder="Select category to configure" />
<SelectValue placeholder={t("guardrails.optionalParams.dict.keyPlaceholder")} />
</SelectTrigger>
<SelectContent>
{availableKeys.map((key) => (
@ -194,7 +204,7 @@ const DictField: React.FC<DictFieldProps> = ({ field, fullFieldKey, control, val
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">Select a category to add threshold configuration</span>
<span className="text-sm text-muted-foreground">{t("guardrails.optionalParams.dict.addHint")}</span>
</div>
)}
</div>
@ -297,6 +307,7 @@ const GuardrailOptionalParams: React.FC<GuardrailOptionalParamsProps> = ({
control,
values,
}) => {
const { t } = useTranslation();
const renderField = (fieldKey: string, field: ProviderParam) => {
const fullFieldKey = `${parentFieldKey}.${fieldKey}`;
const value = values?.[fieldKey];
@ -318,7 +329,11 @@ const GuardrailOptionalParams: React.FC<GuardrailOptionalParamsProps> = ({
name={fullFieldKey}
label={<span className="text-base">{fieldKey}</span>}
description={field.description}
rules={field.required ? requiredRule(`${fieldKey} is required`) : undefined}
rules={
field.required
? requiredRule(t("guardrails.optionalParams.errors.required", { field: fieldKey }))
: undefined
}
defaultValue={value !== undefined ? value : field.default_value}
>
{(fieldControl) => <OptionalParamInput descriptor={field} fieldKey={fieldKey} control={fieldControl} />}
@ -334,9 +349,9 @@ const GuardrailOptionalParams: React.FC<GuardrailOptionalParamsProps> = ({
return (
<div className="guardrail-optional-params">
<div className="mb-8 border-b border-border pb-4">
<h3 className="mb-2 text-lg font-semibold text-foreground">Optional Parameters</h3>
<h3 className="mb-2 text-lg font-semibold text-foreground">{t("guardrails.optionalParams.title")}</h3>
<p className="text-sm text-muted-foreground">
{optionalParams.description || "Configure additional settings for this guardrail provider"}
{optionalParams.description || t("guardrails.optionalParams.defaultDescription")}
</p>
</div>

View file

@ -1,4 +1,6 @@
import type { ParseKeys, TFunction } from "i18next";
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
guardrail_provider_map,
populateGuardrailProviders,
@ -55,9 +57,9 @@ interface ProviderParamsResponse {
[provider: string]: { [key: string]: ProviderParam };
}
const BOOLEAN_ITEMS = [
{ label: "True", value: true },
{ label: "False", value: false },
const BOOLEAN_ITEMS: { labelKey: ParseKeys; value: boolean }[] = [
{ labelKey: "guardrails.providerFields.boolean.true", value: true },
{ labelKey: "guardrails.providerFields.boolean.false", value: false },
];
const isSecretKey = (fieldKey: string): boolean =>
@ -68,14 +70,16 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
// Object fields hold the raw text while the user types, so submission must be
// blocked until the value parses to a plain JSON object (or is cleared).
const jsonObjectRule = (fieldKey: string): GuardrailFieldRules => ({
const jsonObjectRule = (fieldKey: string, t: TFunction): GuardrailFieldRules => ({
validate: (value: unknown) =>
value === undefined || isPlainObject(value) ? true : `${fieldKey} must be a valid JSON object`,
value === undefined || isPlainObject(value)
? true
: t("guardrails.providerFields.errors.invalidJsonObject", { field: fieldKey }),
});
// Commits a parsed object (or undefined for a cleared field) to the form on
// blur; anything else stays as raw text so jsonObjectRule blocks submission.
const commitObjectField = (raw: string, onChange: (value: unknown) => void): void => {
const commitObjectField = (raw: string, onChange: (value: unknown) => void, t: TFunction): void => {
const next = raw.trim();
if (next === "") {
onChange(undefined);
@ -90,15 +94,15 @@ const commitObjectField = (raw: string, onChange: (value: unknown) => void): voi
if (isPlainObject(parsed)) {
onChange(parsed);
} else {
toast.error("Enter a valid JSON object for this configuration");
toast.error(t("guardrails.providerFields.toasts.invalidJsonObject"));
}
};
const fieldRules = (field: ProviderParam, fieldKey: string): GuardrailFieldRules | undefined => {
const fieldRules = (field: ProviderParam, fieldKey: string, t: TFunction): GuardrailFieldRules | undefined => {
if (field.type === "object") {
return jsonObjectRule(fieldKey);
return jsonObjectRule(fieldKey, t);
}
return field.required ? requiredRule(`${fieldKey} is required`) : undefined;
return field.required ? requiredRule(t("guardrails.providerFields.errors.required", { field: fieldKey })) : undefined;
};
interface ProviderFieldInputProps {
@ -108,6 +112,7 @@ interface ProviderFieldInputProps {
}
const ProviderFieldInput: React.FC<ProviderFieldInputProps> = ({ descriptor, fieldKey, control }) => {
const { t } = useTranslation();
const { id, value, onChange, onBlur, ref, name, ...aria } = control;
if (descriptor.type === "select" && descriptor.options) {
@ -146,7 +151,7 @@ const ProviderFieldInput: React.FC<ProviderFieldInputProps> = ({ descriptor, fie
if (descriptor.type === "bool" || descriptor.type === "boolean") {
return (
<Select
items={BOOLEAN_ITEMS}
items={BOOLEAN_ITEMS.map(({ labelKey, value: itemValue }) => ({ label: t(labelKey), value: itemValue }))}
value={typeof value === "boolean" ? value : null}
onValueChange={(next: boolean | null) => onChange(next)}
>
@ -154,8 +159,8 @@ const ProviderFieldInput: React.FC<ProviderFieldInputProps> = ({ descriptor, fie
<SelectValue placeholder={descriptor.description} />
</SelectTrigger>
<SelectContent>
<SelectItem value={true}>True</SelectItem>
<SelectItem value={false}>False</SelectItem>
<SelectItem value={true}>{t("guardrails.providerFields.boolean.true")}</SelectItem>
<SelectItem value={false}>{t("guardrails.providerFields.boolean.false")}</SelectItem>
</SelectContent>
</Select>
);
@ -193,7 +198,7 @@ const ProviderFieldInput: React.FC<ProviderFieldInputProps> = ({ descriptor, fie
value={objectValue}
onChange={(event) => onChange(event.target.value)}
onBlur={(event) => {
commitObjectField(event.target.value, onChange);
commitObjectField(event.target.value, onChange, t);
onBlur();
}}
{...aria}
@ -252,9 +257,10 @@ const GuardrailProviderFields: React.FC<GuardrailProviderFieldsProps> = ({
providerParams: providerParamsProp = null,
value = null,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [providerParams, setProviderParams] = useState<ProviderParamsResponse | null>(providerParamsProp);
const [error, setError] = useState<string | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
// Fetch provider-specific parameters when component mounts
useEffect(() => {
@ -268,7 +274,7 @@ const GuardrailProviderFields: React.FC<GuardrailProviderFieldsProps> = ({
if (!accessToken) return;
setLoading(true);
setError(null);
setLoadFailed(false);
try {
const data = await getGuardrailProviderSpecificParams(accessToken);
@ -279,7 +285,7 @@ const GuardrailProviderFields: React.FC<GuardrailProviderFieldsProps> = ({
populateGuardrailProviderMap(data);
} catch (error) {
console.error("Error fetching provider params:", error);
setError("Failed to load provider parameters");
setLoadFailed(true);
} finally {
setLoading(false);
}
@ -301,14 +307,14 @@ const GuardrailProviderFields: React.FC<GuardrailProviderFieldsProps> = ({
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<UiLoadingSpinner className="size-4" />
Loading provider parameters...
{t("guardrails.providerFields.loading")}
</div>
);
}
// Show error state
if (error) {
return <div className="text-destructive">{error}</div>;
if (loadFailed) {
return <div className="text-destructive">{t("guardrails.providerFields.loadFailed")}</div>;
}
// Get the provider key matching the selected provider in the guardrail_provider_map
@ -318,7 +324,7 @@ const GuardrailProviderFields: React.FC<GuardrailProviderFieldsProps> = ({
const providerFields = providerParams && providerParams[providerKey];
if (!providerFields || Object.keys(providerFields).length === 0) {
return <div>No configuration fields available for this provider.</div>;
return <div>{t("guardrails.providerFields.noFields")}</div>;
}
// Fields to skip for content filter provider (handled in dedicated steps)
@ -376,7 +382,7 @@ const GuardrailProviderFields: React.FC<GuardrailProviderFieldsProps> = ({
control={control}
name={fullFieldKey}
label={labelWithHint(fieldKey, field.description)}
rules={fieldRules(field, fieldKey)}
rules={fieldRules(field, fieldKey, t)}
defaultValue={resolvedInitialValue}
>
{(fieldControl) => <ProviderFieldInput descriptor={field} fieldKey={fieldKey} control={fieldControl} />}

View file

@ -3,6 +3,7 @@
import { SortingState } from "@tanstack/react-table";
import { Inbox } from "lucide-react";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { DataTable } from "@/components/shared/DataTable";
import { Guardrail } from "@/components/guardrails/types";
@ -19,13 +20,14 @@ interface GuardrailTableProps {
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
function EmptyState() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No guardrails yet</div>
<div className="text-sm text-muted-foreground">Add a guardrail to start filtering requests and responses.</div>
<div className="text-sm font-medium text-foreground">{t("guardrails.table.empty.title")}</div>
<div className="text-sm text-muted-foreground">{t("guardrails.table.empty.description")}</div>
</div>
);
}
@ -36,11 +38,12 @@ const GuardrailTable: React.FC<GuardrailTableProps> = ({
onDeleteClick,
onGuardrailClick,
}) => {
const { t } = useTranslation();
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo(
() => getGuardrailTableColumns({ onGuardrailClick, onDeleteClick }),
[onGuardrailClick, onDeleteClick],
() => getGuardrailTableColumns({ onGuardrailClick, onDeleteClick, t }),
[onGuardrailClick, onDeleteClick, t],
);
return (
@ -53,7 +56,7 @@ const GuardrailTable: React.FC<GuardrailTableProps> = ({
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading guardrails…"
loadingMessage={t("guardrails.table.loading")}
noDataMessage={<EmptyState />}
size="compact"
/>

View file

@ -1,8 +1,10 @@
"use client";
import type { ParseKeys } from "i18next";
import { Plus, X } from "lucide-react";
import React from "react";
import { useController } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Button } from "@/components/ui/button";
import {
@ -33,9 +35,9 @@ interface LLMJudgeFieldsProps {
const DEFAULT_CRITERIA: GuardrailCriterion[] = [{ name: "", weight: 100, description: "" }];
const ON_FAILURE_ITEMS = [
{ label: "Block (return 422)", value: "block" },
{ label: "Log only", value: "log" },
const ON_FAILURE_ITEMS: { labelKey: ParseKeys; value: string }[] = [
{ labelKey: "guardrails.llmJudge.onFailure.options.block", value: "block" },
{ labelKey: "guardrails.llmJudge.onFailure.options.log", value: "log" },
];
const clampToRange = (value: unknown, min: number, max: number): number | null => {
@ -77,28 +79,26 @@ const BoundedNumberInput: React.FC<BoundedNumberInputProps> = ({ control, min, m
};
const LLMJudgeFields: React.FC<LLMJudgeFieldsProps> = ({ availableModels, control }) => {
const { t } = useTranslation();
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;
const onFailureItems = ON_FAILURE_ITEMS.map(({ labelKey, value }) => ({ label: t(labelKey), value }));
return (
<FieldGroup>
<div className="rounded-md border border-success/20 bg-success/10 px-3.5 py-2.5 text-[13px] text-success">
The <strong>Judge Model</strong> scores the user request (pre_call, during_call) or the LLM response (post_call)
0–100 against your criteria. If the weighted average falls below the threshold, it is blocked (or logged).
<Trans i18nKey="guardrails.llmJudge.intro" components={{ strong: <strong /> }} />
</div>
<GuardrailField
control={control}
name="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")}
label={labelWithHint(t("guardrails.llmJudge.judgeModel.label"), t("guardrails.llmJudge.judgeModel.hint"))}
rules={requiredRule(t("guardrails.llmJudge.judgeModel.required"))}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Combobox items={availableModels} value={asText(value) || null} onValueChange={onChange}>
@ -106,11 +106,11 @@ const LLMJudgeFields: React.FC<LLMJudgeFieldsProps> = ({ availableModels, contro
id={id}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
placeholder="Select a model"
placeholder={t("guardrails.llmJudge.judgeModel.placeholder")}
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>No matching models</ComboboxEmpty>
<ComboboxEmpty>{t("guardrails.llmJudge.judgeModel.empty")}</ComboboxEmpty>
<ComboboxList>
{(model: string) => (
<ComboboxItem key={model} value={model} title={model}>
@ -126,10 +126,7 @@ const LLMJudgeFields: React.FC<LLMJudgeFieldsProps> = ({ availableModels, contro
<GuardrailField
control={control}
name="overall_threshold"
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.",
)}
label={labelWithHint(t("guardrails.llmJudge.threshold.label"), t("guardrails.llmJudge.threshold.hint"))}
defaultValue={80}
>
{(fieldControl) => <BoundedNumberInput control={fieldControl} min={0} max={100} suffix="/ 100" />}
@ -138,19 +135,16 @@ const LLMJudgeFields: React.FC<LLMJudgeFieldsProps> = ({ availableModels, contro
<GuardrailField
control={control}
name="on_failure"
label={labelWithHint(
"On Failure",
"Block: return HTTP 422 when the score is too low. Log: record the result but let the response through.",
)}
label={labelWithHint(t("guardrails.llmJudge.onFailure.label"), t("guardrails.llmJudge.onFailure.hint"))}
defaultValue="block"
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select items={ON_FAILURE_ITEMS} value={asText(value) || null} onValueChange={onChange}>
<Select items={onFailureItems} value={asText(value) || null} onValueChange={onChange}>
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy} className="w-full">
<SelectValue placeholder="Select an action" />
<SelectValue placeholder={t("guardrails.llmJudge.onFailure.placeholder")} />
</SelectTrigger>
<SelectContent>
{ON_FAILURE_ITEMS.map((item) => (
{onFailureItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -162,10 +156,7 @@ const LLMJudgeFields: React.FC<LLMJudgeFieldsProps> = ({ availableModels, contro
<Field>
<FieldLabel>
{labelWithHint(
"Evaluation Criteria",
"Each criterion is something the judge checks. Weights must add up to 100%.",
)}
{labelWithHint(t("guardrails.llmJudge.criteria.label"), t("guardrails.llmJudge.criteria.hint"))}
</FieldLabel>
{criteria.map((_, index) => (
@ -174,7 +165,7 @@ const LLMJudgeFields: React.FC<LLMJudgeFieldsProps> = ({ availableModels, contro
<GuardrailField
control={control}
name={`criteria.${index}.name`}
rules={requiredRule("Enter criterion name")}
rules={requiredRule(t("guardrails.llmJudge.criteria.nameRequired"))}
className="flex-2"
>
{({ ref, value, ...field }) => (
@ -182,7 +173,7 @@ const LLMJudgeFields: React.FC<LLMJudgeFieldsProps> = ({ availableModels, contro
{...field}
ref={ref}
value={asText(value)}
placeholder="Criterion name (e.g. Policy accuracy)"
placeholder={t("guardrails.llmJudge.criteria.namePlaceholder")}
/>
)}
</GuardrailField>
@ -190,20 +181,28 @@ const LLMJudgeFields: React.FC<LLMJudgeFieldsProps> = ({ availableModels, contro
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%.",
<span className="text-xs text-muted-foreground">
{t("guardrails.llmJudge.criteria.weightLabel")}
</span>,
t("guardrails.llmJudge.criteria.weightHint"),
)}
rules={requiredRule("Enter weight")}
rules={requiredRule(t("guardrails.llmJudge.criteria.weightRequired"))}
className="flex-1"
>
{(fieldControl) => (
<BoundedNumberInput control={fieldControl} min={0} max={100} suffix="%" placeholder="e.g. 50" />
<BoundedNumberInput
control={fieldControl}
min={0}
max={100}
suffix="%"
placeholder={t("guardrails.llmJudge.criteria.weightPlaceholder")}
/>
)}
</GuardrailField>
<Button
variant="ghost"
size="sm"
aria-label="Remove criterion"
aria-label={t("guardrails.llmJudge.criteria.remove")}
className="mb-1 text-destructive hover:text-destructive/80"
onClick={() => setCriteria(criteria.filter((_, position) => position !== index))}
>
@ -213,7 +212,7 @@ const LLMJudgeFields: React.FC<LLMJudgeFieldsProps> = ({ availableModels, contro
<GuardrailField
control={control}
name={`criteria.${index}.description`}
rules={requiredRule("Describe what to check")}
rules={requiredRule(t("guardrails.llmJudge.criteria.descriptionRequired"))}
className="mt-2"
>
{({ ref, value, ...field }) => (
@ -221,7 +220,7 @@ const LLMJudgeFields: React.FC<LLMJudgeFieldsProps> = ({ availableModels, contro
{...field}
ref={ref}
value={asText(value)}
placeholder="What should the judge check for this criterion?"
placeholder={t("guardrails.llmJudge.criteria.descriptionPlaceholder")}
/>
)}
</GuardrailField>
@ -234,12 +233,14 @@ const LLMJudgeFields: React.FC<LLMJudgeFieldsProps> = ({ availableModels, contro
onClick={() => setCriteria([...criteria, { name: "", weight: 0, description: "" }])}
>
<Plus className="size-4" />
Add Criterion
{t("guardrails.llmJudge.criteria.add")}
</Button>
{criteria.length > 0 && (
<div className={`mt-1.5 text-xs ${weightOk ? "text-success" : "text-warning"}`}>
Weights total: {weightTotal}%{weightOk ? " ✓" : " — must add up to 100%"}
{weightOk
? t("guardrails.llmJudge.criteria.weightsTotalOk", { total: weightTotal })
: t("guardrails.llmJudge.criteria.weightsTotalOff", { total: weightTotal })}
</div>
)}
</Field>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { EyeOff, Filter, Info, Ban, X } from "lucide-react";
import { PiiEntityCategory } from "@/components/guardrails/types";
import { Badge } from "@/components/ui/badge";
@ -42,6 +43,7 @@ export interface CategoryFilterProps {
}
export const CategoryFilter: React.FC<CategoryFilterProps> = ({ categories, selectedCategories, onChange }) => {
const { t } = useTranslation();
const anchor = useComboboxAnchor();
const categoryNames = categories.map((cat) => cat.category);
@ -49,7 +51,7 @@ export const CategoryFilter: React.FC<CategoryFilterProps> = ({ categories, sele
<div>
<div className="mb-2 flex items-center">
<Filter className="mr-1 size-4 text-muted-foreground" />
<span className="font-medium text-muted-foreground">Filter by category</span>
<span className="font-medium text-muted-foreground">{t("guardrails.pii.categoryFilter.title")}</span>
</div>
<Combobox items={categoryNames} value={selectedCategories} onValueChange={onChange} multiple>
<ComboboxChips render={<div ref={anchor} />} className="mb-4 w-full">
@ -59,11 +61,11 @@ export const CategoryFilter: React.FC<CategoryFilterProps> = ({ categories, sele
</ComboboxChip>
))}
<ComboboxChipsInput
placeholder={selectedCategories.length === 0 ? "Select categories to filter by" : undefined}
placeholder={selectedCategories.length === 0 ? t("guardrails.pii.categoryFilter.placeholder") : undefined}
/>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No matching categories</ComboboxEmpty>
<ComboboxEmpty>{t("guardrails.pii.categoryFilter.empty")}</ComboboxEmpty>
<ComboboxList>
{(category: string) => (
<ComboboxItem key={category} value={category}>
@ -85,11 +87,12 @@ export interface QuickActionsProps {
}
export const QuickActions: React.FC<QuickActionsProps> = ({ onSelectAll, onUnselectAll, hasSelectedEntities }) => {
const { t } = useTranslation();
return (
<div className="mb-6 rounded-lg border border-border bg-muted/40 p-5 shadow-xs">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center">
<span className="text-base font-semibold">Quick Actions</span>
<span className="text-base font-semibold">{t("guardrails.pii.quickActions.title")}</span>
<Tooltip>
<TooltipTrigger
render={
@ -98,22 +101,22 @@ export const QuickActions: React.FC<QuickActionsProps> = ({ onSelectAll, onUnsel
</span>
}
/>
<TooltipContent>Apply action to all PII types at once</TooltipContent>
<TooltipContent>{t("guardrails.pii.quickActions.hint")}</TooltipContent>
</Tooltip>
</div>
<Button variant="outline" onClick={onUnselectAll} disabled={!hasSelectedEntities}>
<X />
Unselect All
{t("guardrails.pii.quickActions.unselectAll")}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<Button variant="outline" className="h-10 w-full" onClick={() => onSelectAll("MASK")}>
<EyeOff />
Select All &amp; Mask
{t("guardrails.pii.quickActions.selectAllMask")}
</Button>
<Button variant="outline" className="h-10 w-full" onClick={() => onSelectAll("BLOCK")}>
<Ban />
Select All &amp; Block
{t("guardrails.pii.quickActions.selectAllBlock")}
</Button>
</div>
</div>
@ -140,15 +143,16 @@ export const PiiEntityList: React.FC<PiiEntityListProps> = ({
onActionSelect,
entityToCategoryMap,
}) => {
const { t } = useTranslation();
return (
<div className="overflow-hidden rounded-lg border border-border shadow-xs">
<div className="flex border-b border-border bg-muted/40 px-5 py-3">
<span className="flex-1 font-semibold">PII Type</span>
<span className="w-32 text-right font-semibold">Action</span>
<span className="flex-1 font-semibold">{t("guardrails.pii.entityList.typeColumn")}</span>
<span className="w-32 text-right font-semibold">{t("guardrails.pii.entityList.actionColumn")}</span>
</div>
<div className="max-h-[400px] overflow-y-auto">
{entities.length === 0 ? (
<div className="py-10 text-center text-muted-foreground">No PII types match your filter criteria</div>
<div className="py-10 text-center text-muted-foreground">{t("guardrails.pii.entityList.empty")}</div>
) : (
entities.map((entity) => {
const isSelected = selectedEntities.includes(entity);
@ -176,7 +180,10 @@ export const PiiEntityList: React.FC<PiiEntityListProps> = ({
onValueChange={(value: string | null) => value && onActionSelect(entity, value)}
disabled={!isSelected}
>
<SelectTrigger className={`w-[120px] ${isSelected ? "" : "opacity-50"}`} aria-label="Action">
<SelectTrigger
className={`w-[120px] ${isSelected ? "" : "opacity-50"}`}
aria-label={t("guardrails.pii.entityList.actionSelect")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { CategoryFilter, PiiEntityList, QuickActions } from "./pii_components";
import { PiiConfigurationProps } from "@/components/guardrails/types";
@ -15,6 +16,7 @@ const PiiConfiguration: React.FC<PiiConfigurationProps> = ({
onActionSelect,
entityCategories = [],
}) => {
const { t } = useTranslation();
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
// Create a lookup map to quickly find an entity's category
@ -54,9 +56,11 @@ const PiiConfiguration: React.FC<PiiConfigurationProps> = ({
<div className="pii-configuration">
<div className="flex justify-between items-center mb-5">
<div className="flex items-center">
<h4 className="m-0 text-lg font-semibold text-foreground">Configure PII Protection</h4>
<h4 className="m-0 text-lg font-semibold text-foreground">{t("guardrails.pii.configuration.title")}</h4>
</div>
<span className="text-muted-foreground">{selectedEntities.length} items selected</span>
<span className="text-muted-foreground">
{t("guardrails.pii.configuration.selectedCount", { count: selectedEntities.length })}
</span>
</div>
<div className="mb-6">

View file

@ -1,4 +1,6 @@
import type { ParseKeys } from "i18next";
import React from "react";
import { useTranslation } from "react-i18next";
import { Info, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@ -33,15 +35,17 @@ interface ToolPermissionRulesEditorProps {
disabled?: boolean;
}
const DECISION_ITEMS = [
{ value: "allow", label: "Allow" },
{ value: "deny", label: "Deny" },
] as const;
const DECISION_ITEMS: readonly { value: ToolPermissionDecision; labelKey: ParseKeys }[] = [
{ value: "allow", labelKey: "guardrails.toolPermission.decisionOptions.allow" },
{ value: "deny", labelKey: "guardrails.toolPermission.decisionOptions.deny" },
];
const ON_DISALLOWED_ITEMS = [
{ value: "block", label: "Block" },
{ value: "rewrite", label: "Rewrite" },
] as const;
const ON_DISALLOWED_ITEMS: readonly { value: ToolPermissionOnDisallowedAction; labelKey: ParseKeys }[] = [
{ value: "block", labelKey: "guardrails.toolPermission.onDisallowedOptions.block" },
{ value: "rewrite", labelKey: "guardrails.toolPermission.onDisallowedOptions.rewrite" },
];
const TOOL_NAME_EXAMPLE = "^mcp__github_.*$";
const DEFAULT_CONFIG: ToolPermissionConfig = {
rules: [],
@ -57,7 +61,10 @@ const ensureConfig = (config?: ToolPermissionConfig): ToolPermissionConfig => ({
});
const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ value, onChange, disabled = false }) => {
const { t } = useTranslation();
const config = ensureConfig(value);
const decisionItems = DECISION_ITEMS.map((item) => ({ value: item.value, label: t(item.labelKey) }));
const onDisallowedItems = ON_DISALLOWED_ITEMS.map((item) => ({ value: item.value, label: t(item.labelKey) }));
const updateConfig = (partial: Partial<ToolPermissionConfig>) => {
const nextConfig: ToolPermissionConfig = {
@ -135,14 +142,14 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
size="sm"
onClick={() => updateRule(index, { allowed_param_patterns: { "": "" } })}
>
+ Restrict tool arguments (optional)
{t("guardrails.toolPermission.constraints.restrict")}
</Button>
);
}
return (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Argument constraints (dot or array paths)</p>
<p className="text-sm text-muted-foreground">{t("guardrails.toolPermission.constraints.title")}</p>
{entries.map(([path, pattern], patternIndex) => (
<div key={`${rule.id || index}-${patternIndex}`} className="flex items-start gap-2">
<Input
@ -160,7 +167,7 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
<Button
variant="outline"
size="icon"
aria-label="Remove constraint"
aria-label={t("guardrails.toolPermission.constraints.remove")}
disabled={disabled}
onClick={() =>
updateAllowedParamEntries(index, (entries) => {
@ -185,7 +192,7 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
})
}
>
+ Add another constraint
{t("guardrails.toolPermission.constraints.addAnother")}
</Button>
</div>
);
@ -196,16 +203,15 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-lg font-semibold">LiteLLM Tool Permission Guardrail</p>
<p className="text-lg font-semibold">{t("guardrails.toolPermission.title")}</p>
<p className="text-sm text-muted-foreground">
Provide regex patterns (e.g., ^mcp__github_.*$) for tool names or types and optionally constrain payload
fields.
{t("guardrails.toolPermission.description", { example: TOOL_NAME_EXAMPLE })}
</p>
</div>
{!disabled && (
<Button onClick={addRule}>
<Plus />
Add Rule
{t("guardrails.toolPermission.addRule")}
</Button>
)}
</div>
@ -213,22 +219,22 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
<Separator className="my-4" />
{config.rules.length === 0 ? (
<div className="py-10 text-center text-muted-foreground">No tool rules added yet</div>
<div className="py-10 text-center text-muted-foreground">{t("guardrails.toolPermission.empty")}</div>
) : (
<div className="space-y-4">
{config.rules.map((rule, index) => (
<Card key={rule.id || index} className="bg-muted/40">
<CardContent>
<div className="mb-3 flex items-center justify-between">
<p className="font-semibold">Rule {index + 1}</p>
<p className="font-semibold">{t("guardrails.toolPermission.rule.title", { number: index + 1 })}</p>
<Button variant="ghost" disabled={disabled} onClick={() => removeRule(index)}>
<Trash2 />
Remove
{t("guardrails.toolPermission.rule.remove")}
</Button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<p className="text-sm font-medium">Rule ID</p>
<p className="text-sm font-medium">{t("guardrails.toolPermission.rule.id")}</p>
<Input
disabled={disabled}
placeholder="unique_rule_id"
@ -237,10 +243,10 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
/>
</div>
<div>
<p className="text-sm font-medium">Tool Name (optional)</p>
<p className="text-sm font-medium">{t("guardrails.toolPermission.rule.toolName")}</p>
<Input
disabled={disabled}
placeholder="^mcp__github_.*$"
placeholder={TOOL_NAME_EXAMPLE}
value={rule.tool_name ?? ""}
onChange={(e) =>
updateRule(index, {
@ -253,7 +259,7 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<p className="text-sm font-medium">Tool Type (optional)</p>
<p className="text-sm font-medium">{t("guardrails.toolPermission.rule.toolType")}</p>
<Input
disabled={disabled}
placeholder="^function$"
@ -268,20 +274,20 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
</div>
<div className="mt-4 flex flex-col gap-2">
<p className="text-sm font-medium">Decision</p>
<p className="text-sm font-medium">{t("guardrails.toolPermission.rule.decision")}</p>
<Select
items={DECISION_ITEMS}
items={decisionItems}
disabled={disabled}
value={rule.decision}
onValueChange={(value: string | null) =>
value && updateRule(index, { decision: value as ToolPermissionDecision })
}
>
<SelectTrigger className="w-[200px]" aria-label="Decision">
<SelectTrigger className="w-[200px]" aria-label={t("guardrails.toolPermission.rule.decision")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DECISION_ITEMS.map((item) => (
{decisionItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -301,20 +307,20 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm font-medium">Default action</p>
<p className="text-sm font-medium">{t("guardrails.toolPermission.defaultAction")}</p>
<Select
items={DECISION_ITEMS}
items={decisionItems}
disabled={disabled}
value={config.default_action}
onValueChange={(value: string | null) =>
value && updateConfig({ default_action: value as ToolPermissionDefaultAction })
}
>
<SelectTrigger className="w-full" aria-label="Default action">
<SelectTrigger className="w-full" aria-label={t("guardrails.toolPermission.defaultAction")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DECISION_ITEMS.map((item) => (
{decisionItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -324,7 +330,7 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
</div>
<div>
<p className="flex items-center gap-1 text-sm font-medium">
On disallowed action
{t("guardrails.toolPermission.onDisallowed.label")}
<Tooltip>
<TooltipTrigger
render={
@ -333,25 +339,22 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
</span>
}
/>
<TooltipContent>
Block returns an error when a forbidden tool is invoked. Rewrite strips the tool call but lets the
rest of the response continue.
</TooltipContent>
<TooltipContent>{t("guardrails.toolPermission.onDisallowed.hint")}</TooltipContent>
</Tooltip>
</p>
<Select
items={ON_DISALLOWED_ITEMS}
items={onDisallowedItems}
disabled={disabled}
value={config.on_disallowed_action}
onValueChange={(value: string | null) =>
value && updateConfig({ on_disallowed_action: value as ToolPermissionOnDisallowedAction })
}
>
<SelectTrigger className="w-full" aria-label="On disallowed action">
<SelectTrigger className="w-full" aria-label={t("guardrails.toolPermission.onDisallowed.label")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ON_DISALLOWED_ITEMS.map((item) => (
{onDisallowedItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -362,12 +365,12 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
</div>
<div className="mt-4">
<p className="text-sm font-medium">Violation message (optional)</p>
<p className="text-sm font-medium">{t("guardrails.toolPermission.violationMessage.label")}</p>
<Textarea
className="field-sizing-fixed"
disabled={disabled}
rows={3}
placeholder="This violates our org policy..."
placeholder={t("guardrails.toolPermission.violationMessage.placeholder")}
value={config.violation_message_template}
onChange={(e) => updateConfig({ violation_message_template: e.target.value })}
/>

File diff suppressed because it is too large Load diff