[Feat] UI - Allow configuring LiteLLM Custom Guardrail (#16339)

* add ContentFilterAction

* store pre-built regex patterns

* add v0 of content filter guard

* add _filter_messages

* test content filter guard

* init ContentFilterGuardrail

* fix ContentFilterGuardrail enums

* rename folder

* fix litellm_content_filter

* refactor content filter guard

* test content filter

* add streaming for ContentFilterGuardrail

* test_streaming_hook_mask

* add litellm_content_filter

* docs show litellm content filter

* docs litellm content filter

* fix lnting

* use ENUM for PrebuiltPatternName

* fix validate_blocked_words_file

* add validateBlockedWordsFile

* LitellmContentFilterGuardrailConfigModel

* add get_config_model

* add litellm content filter

* refactored ui

* fix config

* tests for new components
This commit is contained in:
Ishaan Jaff
2025-11-06 16:03:55 -08:00
committed by GitHub
parent 18a5c4f75a
commit bdc4969526
18 changed files with 1373 additions and 31 deletions
@@ -693,7 +693,13 @@ async def get_guardrail_ui_settings():
- Supported entities for guardrails
- Supported modes for guardrails
- PII entity categories for UI organization
- Content filter settings (patterns and categories)
"""
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.patterns import (
PATTERN_CATEGORIES,
get_pattern_metadata,
)
# Convert the PII_ENTITY_CATEGORIES_MAP to the format expected by the UI
category_maps = []
for category, entities in PII_ENTITY_CATEGORIES_MAP.items():
@@ -707,9 +713,111 @@ async def get_guardrail_ui_settings():
supported_actions=[action.value for action in PiiAction],
supported_modes=[mode.value for mode in GuardrailEventHooks],
pii_entity_categories=category_maps,
content_filter_settings={
"prebuilt_patterns": get_pattern_metadata(),
"pattern_categories": list(PATTERN_CATEGORIES.keys()),
"supported_actions": ["BLOCK", "MASK"],
},
)
@router.post(
"/guardrails/validate_blocked_words_file",
tags=["Guardrails"],
dependencies=[Depends(user_api_key_auth)],
)
async def validate_blocked_words_file(request: Dict[str, str]):
"""
Validate a blocked_words YAML file content.
Args:
request: Dictionary with 'file_content' key containing the YAML string
Returns:
Dictionary with 'valid' boolean and either 'message'/'errors' depending on result
Example Request:
```json
{
"file_content": "blocked_words:\\n - keyword: \\"test\\"\\n action: \\"BLOCK\\""
}
```
Example Success Response:
```json
{
"valid": true,
"message": "Valid YAML file with 2 blocked words"
}
```
Example Error Response:
```json
{
"valid": false,
"errors": ["Entry 0: missing 'action' field"]
}
```
"""
import yaml
try:
file_content = request.get("file_content", "")
if not file_content:
return {
"valid": False,
"error": "No file content provided"
}
data = yaml.safe_load(file_content)
if not isinstance(data, dict) or "blocked_words" not in data:
return {
"valid": False,
"error": "Invalid format: file must contain 'blocked_words' key with a list"
}
blocked_words_list = data["blocked_words"]
if not isinstance(blocked_words_list, list):
return {
"valid": False,
"error": "'blocked_words' must be a list"
}
# Validate each entry
errors = []
for idx, word_data in enumerate(blocked_words_list):
if not isinstance(word_data, dict):
errors.append(f"Entry {idx}: must be an object")
continue
if "keyword" not in word_data:
errors.append(f"Entry {idx}: missing 'keyword' field")
elif not isinstance(word_data["keyword"], str):
errors.append(f"Entry {idx}: 'keyword' must be a string")
if "action" not in word_data:
errors.append(f"Entry {idx}: missing 'action' field")
elif word_data["action"] not in ["BLOCK", "MASK"]:
errors.append(f"Entry {idx}: action must be 'BLOCK' or 'MASK', got '{word_data['action']}'")
if "description" in word_data and not isinstance(word_data["description"], str):
errors.append(f"Entry {idx}: 'description' must be a string")
if errors:
return {"valid": False, "errors": errors}
return {
"valid": True,
"message": f"Valid YAML file with {len(blocked_words_list)} blocked word(s)"
}
except yaml.YAMLError as e:
return {"valid": False, "error": f"Invalid YAML syntax: {str(e)}"}
except Exception as e:
verbose_proxy_logger.exception("Error validating blocked words file")
return {"valid": False, "error": f"Validation error: {str(e)}"}
def _get_field_type_from_annotation(field_annotation: Any) -> str:
"""
Convert a Python type annotation to a UI-friendly type string
@@ -193,7 +193,7 @@ class ContentFilterGuardrail(CustomGuardrail):
if match:
matched_text = match.group(0)
verbose_proxy_logger.debug(
f"Pattern '{pattern_name}' matched."
f"Pattern '{pattern_name}' matched: {matched_text[:20]}..."
)
return (matched_text, pattern_name, action)
return None
@@ -365,4 +365,12 @@ class ContentFilterGuardrail(CustomGuardrail):
verbose_proxy_logger.debug(
"ContentFilterGuardrail: Streaming check completed"
)
@staticmethod
def get_config_model():
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
LitellmContentFilterGuardrailConfigModel,
)
return LitellmContentFilterGuardrailConfigModel
@@ -6,7 +6,8 @@ like SSNs, credit cards, API keys, etc.
"""
import re
from typing import Dict, Pattern
from enum import Enum
from typing import Dict, List, Pattern
# US Social Security Number patterns
US_SSN_PATTERN = r"\b\d{3}-\d{2}-\d{4}\b" # Format: 123-45-6789
@@ -39,33 +40,63 @@ IPV6_PATTERN = r"\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b"
URL_PATTERN = r"\b(?:https?://|www\.)[^\s/$.?#].[^\s]*\b"
PREBUILT_PATTERNS: Dict[str, str] = {
class PrebuiltPatternName(str, Enum):
"""Enum for prebuilt pattern names"""
# SSN patterns
"us_ssn": US_SSN_PATTERN,
"us_ssn_no_dash": US_SSN_NO_DASH_PATTERN,
US_SSN = "us_ssn"
US_SSN_NO_DASH = "us_ssn_no_dash"
# Credit card patterns
"visa": VISA_PATTERN,
"mastercard": MASTERCARD_PATTERN,
"amex": AMEX_PATTERN,
"discover": DISCOVER_PATTERN,
"credit_card": rf"(?:{VISA_PATTERN}|{MASTERCARD_PATTERN}|{AMEX_PATTERN}|{DISCOVER_PATTERN})",
VISA = "visa"
MASTERCARD = "mastercard"
AMEX = "amex"
DISCOVER = "discover"
CREDIT_CARD = "credit_card"
# Contact information
"email": EMAIL_PATTERN,
"us_phone": US_PHONE_PATTERN,
EMAIL = "email"
US_PHONE = "us_phone"
# API keys and secrets
"aws_access_key": AWS_ACCESS_KEY_PATTERN,
"aws_secret_key": AWS_SECRET_KEY_PATTERN,
"github_token": GITHUB_TOKEN_PATTERN,
"slack_token": SLACK_TOKEN_PATTERN,
"generic_api_key": GENERIC_API_KEY_PATTERN,
AWS_ACCESS_KEY = "aws_access_key"
AWS_SECRET_KEY = "aws_secret_key"
GITHUB_TOKEN = "github_token"
SLACK_TOKEN = "slack_token"
GENERIC_API_KEY = "generic_api_key"
# Network identifiers
"ipv4": IPV4_PATTERN,
"ipv6": IPV6_PATTERN,
"url": URL_PATTERN,
IPV4 = "ipv4"
IPV6 = "ipv6"
URL = "url"
PREBUILT_PATTERNS: Dict[str, str] = {
# SSN patterns
PrebuiltPatternName.US_SSN.value: US_SSN_PATTERN,
PrebuiltPatternName.US_SSN_NO_DASH.value: US_SSN_NO_DASH_PATTERN,
# Credit card patterns
PrebuiltPatternName.VISA.value: VISA_PATTERN,
PrebuiltPatternName.MASTERCARD.value: MASTERCARD_PATTERN,
PrebuiltPatternName.AMEX.value: AMEX_PATTERN,
PrebuiltPatternName.DISCOVER.value: DISCOVER_PATTERN,
PrebuiltPatternName.CREDIT_CARD.value: rf"(?:{VISA_PATTERN}|{MASTERCARD_PATTERN}|{AMEX_PATTERN}|{DISCOVER_PATTERN})",
# Contact information
PrebuiltPatternName.EMAIL.value: EMAIL_PATTERN,
PrebuiltPatternName.US_PHONE.value: US_PHONE_PATTERN,
# API keys and secrets
PrebuiltPatternName.AWS_ACCESS_KEY.value: AWS_ACCESS_KEY_PATTERN,
PrebuiltPatternName.AWS_SECRET_KEY.value: AWS_SECRET_KEY_PATTERN,
PrebuiltPatternName.GITHUB_TOKEN.value: GITHUB_TOKEN_PATTERN,
PrebuiltPatternName.SLACK_TOKEN.value: SLACK_TOKEN_PATTERN,
PrebuiltPatternName.GENERIC_API_KEY.value: GENERIC_API_KEY_PATTERN,
# Network identifiers
PrebuiltPatternName.IPV4.value: IPV4_PATTERN,
PrebuiltPatternName.IPV6.value: IPV6_PATTERN,
PrebuiltPatternName.URL.value: URL_PATTERN,
}
@@ -101,3 +132,76 @@ def get_all_pattern_names():
"""
return list(PREBUILT_PATTERNS.keys())
# Pattern categories for UI organization
PATTERN_CATEGORIES: Dict[str, List[str]] = {
"PII Patterns": [
PrebuiltPatternName.US_SSN.value,
PrebuiltPatternName.EMAIL.value,
PrebuiltPatternName.US_PHONE.value,
],
"Payment Card Patterns": [
PrebuiltPatternName.VISA.value,
PrebuiltPatternName.MASTERCARD.value,
PrebuiltPatternName.AMEX.value,
PrebuiltPatternName.DISCOVER.value,
PrebuiltPatternName.CREDIT_CARD.value,
],
"Credential Patterns": [
PrebuiltPatternName.AWS_ACCESS_KEY.value,
PrebuiltPatternName.AWS_SECRET_KEY.value,
PrebuiltPatternName.GITHUB_TOKEN.value,
PrebuiltPatternName.SLACK_TOKEN.value,
PrebuiltPatternName.GENERIC_API_KEY.value,
],
"Network Patterns": [
PrebuiltPatternName.IPV4.value,
PrebuiltPatternName.IPV6.value,
PrebuiltPatternName.URL.value,
],
}
# Pattern descriptions for UI display
PATTERN_DESCRIPTIONS: Dict[str, str] = {
PrebuiltPatternName.US_SSN.value: "Detects US Social Security Numbers (XXX-XX-XXXX format)",
PrebuiltPatternName.US_SSN_NO_DASH.value: "Detects US SSN without dashes (XXXXXXXXX format)",
PrebuiltPatternName.EMAIL.value: "Detects email addresses",
PrebuiltPatternName.US_PHONE.value: "Detects US phone numbers in various formats",
PrebuiltPatternName.VISA.value: "Detects Visa credit card numbers",
PrebuiltPatternName.MASTERCARD.value: "Detects Mastercard credit card numbers",
PrebuiltPatternName.AMEX.value: "Detects American Express credit card numbers",
PrebuiltPatternName.DISCOVER.value: "Detects Discover credit card numbers",
PrebuiltPatternName.CREDIT_CARD.value: "Detects any major credit card number",
PrebuiltPatternName.AWS_ACCESS_KEY.value: "Detects AWS access keys (AKIA...)",
PrebuiltPatternName.AWS_SECRET_KEY.value: "Detects AWS secret keys (40 characters)",
PrebuiltPatternName.GITHUB_TOKEN.value: "Detects GitHub personal access tokens",
PrebuiltPatternName.SLACK_TOKEN.value: "Detects Slack API tokens",
PrebuiltPatternName.GENERIC_API_KEY.value: "Detects generic API key patterns",
PrebuiltPatternName.IPV4.value: "Detects IPv4 addresses",
PrebuiltPatternName.IPV6.value: "Detects IPv6 addresses",
PrebuiltPatternName.URL.value: "Detects URLs (http/https)",
}
def get_pattern_metadata() -> List[Dict[str, str]]:
"""
Return pattern metadata for UI display.
Returns:
List of dictionaries containing pattern name, category, and description
"""
prebuilt_patterns = []
for category, pattern_names in PATTERN_CATEGORIES.items():
for pattern_name in pattern_names:
if pattern_name in PREBUILT_PATTERNS:
prebuilt_patterns.append({
"name": pattern_name,
"category": category,
"description": PATTERN_DESCRIPTIONS.get(
pattern_name,
f"Detects {pattern_name.replace('_', ' ').title()}"
),
})
return prebuilt_patterns
+5 -8
View File
@@ -1,10 +1,3 @@
general_settings:
key_management_system: "cyberark"
key_management_settings:
store_virtual_keys: true
prefix_for_stored_virtual_keys: "litellm/"
access_mode: "read_and_write"
model_list:
- model_name: aws/anthropic/bedrock-claude-3-5-sonnet-v1
litellm_params:
@@ -52,4 +45,8 @@ litellm_settings:
cache: True
cache_params:
type: local
drop_params: True
drop_params: True
general_settings:
store_prompts_in_spend_logs: True
+1
View File
@@ -676,6 +676,7 @@ class GuardrailUIAddGuardrailSettings(BaseModel):
supported_actions: List[str]
supported_modes: List[str]
pii_entity_categories: List[PiiEntityCategoryMap]
content_filter_settings: Optional[Dict[str, Any]] = None
class PresidioPerRequestConfig(BaseModel):
@@ -0,0 +1,7 @@
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
class LitellmContentFilterGuardrailConfigModel(GuardrailConfigModel):
@staticmethod
def ui_friendly_name() -> str:
return "LiteLLM Content Filter"
@@ -4,6 +4,7 @@ import { Button, TextInput } from "@tremor/react";
import {
guardrail_provider_map,
shouldRenderPIIConfigSettings,
shouldRenderContentFilterConfigSettings,
guardrailLogoMap,
populateGuardrailProviders,
populateGuardrailProviderMap,
@@ -14,6 +15,7 @@ import PiiConfiguration from "./pii_configuration";
import GuardrailProviderFields from "./guardrail_provider_fields";
import GuardrailOptionalParams from "./guardrail_optional_params";
import NotificationsManager from "../molecules/notifications_manager";
import ContentFilterConfiguration from "./content_filter/ContentFilterConfiguration";
const { Title, Text, Link } = Typography;
const { Option } = Select;
@@ -44,6 +46,15 @@ interface GuardrailSettings {
category: string;
entities: string[];
}>;
content_filter_settings?: {
prebuilt_patterns: Array<{
name: string;
category: string;
description: string;
}>;
pattern_categories: string[];
supported_actions: string[];
};
}
interface LiteLLMParams {
@@ -85,6 +96,10 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
const [globalSeverityThreshold, setGlobalSeverityThreshold] = useState<number>(2);
const [categorySpecificThresholds, setCategorySpecificThresholds] = useState<{ [key: string]: number }>({});
// Content Filter state
const [selectedPatterns, setSelectedPatterns] = useState<any[]>([]);
const [blockedWords, setBlockedWords] = useState<any[]>([]);
// Fetch guardrail UI settings + provider params on mount / accessToken change
useEffect(() => {
if (!accessToken) return;
@@ -266,6 +281,26 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
guardrailData.litellm_params.presidio_anonymizer_api_base = values.presidio_anonymizer_api_base;
}
}
// For Content Filter, add patterns and blocked words
if (shouldRenderContentFilterConfigSettings(values.provider)) {
if (selectedPatterns.length > 0) {
guardrailData.litellm_params.patterns = selectedPatterns.map((p) => ({
pattern_type: p.type === "prebuilt" ? "prebuilt" : "regex",
pattern_name: p.type === "prebuilt" ? p.name : undefined,
pattern: p.type === "custom" ? p.pattern : undefined,
name: p.name,
action: p.action,
}));
}
if (blockedWords.length > 0) {
guardrailData.litellm_params.blocked_words = blockedWords.map((w) => ({
keyword: w.keyword,
action: w.action,
description: w.description,
}));
}
}
// Add config values to the guardrail_info if provided
else if (values.config) {
try {
@@ -524,6 +559,38 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
);
};
const renderContentFilterConfiguration = (step: "patterns" | "keywords") => {
if (!guardrailSettings || !shouldRenderContentFilterConfigSettings(selectedProvider)) return null;
const contentFilterSettings = guardrailSettings.content_filter_settings;
if (!contentFilterSettings) return null;
return (
<ContentFilterConfiguration
prebuiltPatterns={contentFilterSettings.prebuilt_patterns || []}
categories={contentFilterSettings.pattern_categories || []}
selectedPatterns={selectedPatterns}
blockedWords={blockedWords}
onPatternAdd={(pattern) => setSelectedPatterns([...selectedPatterns, pattern])}
onPatternRemove={(id) => setSelectedPatterns(selectedPatterns.filter((p) => p.id !== id))}
onPatternActionChange={(id, action) => {
setSelectedPatterns(
selectedPatterns.map((p) => (p.id === id ? { ...p, action } : p))
);
}}
onBlockedWordAdd={(word) => setBlockedWords([...blockedWords, word])}
onBlockedWordRemove={(id) => setBlockedWords(blockedWords.filter((w) => w.id !== id))}
onBlockedWordUpdate={(id, field, value) => {
setBlockedWords(
blockedWords.map((w) => (w.id === id ? { ...w, [field]: value } : w))
);
}}
accessToken={accessToken}
showStep={step}
/>
);
};
const renderOptionalParams = () => {
if (!selectedProvider || !providerParams) return null;
@@ -545,13 +612,24 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
if (shouldRenderPIIConfigSettings(selectedProvider)) {
return renderPiiConfiguration();
}
if (shouldRenderContentFilterConfigSettings(selectedProvider)) {
return renderContentFilterConfiguration("patterns");
}
return renderOptionalParams();
case 2:
if (shouldRenderContentFilterConfigSettings(selectedProvider)) {
return renderContentFilterConfiguration("keywords");
}
return null;
default:
return null;
}
};
const renderStepButtons = () => {
const totalSteps = shouldRenderContentFilterConfigSettings(selectedProvider) ? 3 : 2;
const isLastStep = currentStep === totalSteps - 1;
return (
<div className="flex justify-end space-x-2 mt-4">
{currentStep > 0 && (
@@ -559,8 +637,8 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
Previous
</Button>
)}
{currentStep < 2 && <Button onClick={nextStep}>Next</Button>}
{currentStep === 2 && (
{!isLastStep && <Button onClick={nextStep}>Next</Button>}
{isLastStep && (
<Button onClick={handleSubmit} loading={loading}>
Create Guardrail
</Button>
@@ -585,8 +663,17 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
<Steps current={currentStep} className="mb-6">
<Step title="Basic Info" />
<Step
title={shouldRenderPIIConfigSettings(selectedProvider) ? "PII Configuration" : "Provider Configuration"}
title={
shouldRenderPIIConfigSettings(selectedProvider)
? "PII Configuration"
: shouldRenderContentFilterConfigSettings(selectedProvider)
? "Pattern Detection"
: "Provider Configuration"
}
/>
{shouldRenderContentFilterConfigSettings(selectedProvider) && (
<Step title="Blocked Keywords" />
)}
</Steps>
{renderStepContent()}
@@ -0,0 +1,295 @@
import React, { useState } from "react";
import { Typography, Space, Upload, Card } from "antd";
import { PlusOutlined, UploadOutlined } from "@ant-design/icons";
import { Button } from "@tremor/react";
import { validateBlockedWordsFile } from "../../networking";
import NotificationsManager from "../../molecules/notifications_manager";
import PatternModal from "./PatternModal";
import CustomPatternModal from "./CustomPatternModal";
import KeywordModal from "./KeywordModal";
import PatternTable from "./PatternTable";
import KeywordTable from "./KeywordTable";
const { Title, Text } = Typography;
interface PrebuiltPattern {
name: string;
category: string;
description: string;
}
interface Pattern {
id: string;
type: "prebuilt" | "custom";
name: string;
pattern?: string;
action: "BLOCK" | "MASK";
}
interface BlockedWord {
id: string;
keyword: string;
action: "BLOCK" | "MASK";
description?: string;
}
interface ContentFilterConfigurationProps {
prebuiltPatterns: PrebuiltPattern[];
categories: string[];
selectedPatterns: Pattern[];
blockedWords: BlockedWord[];
onPatternAdd: (pattern: Pattern) => void;
onPatternRemove: (id: string) => void;
onPatternActionChange: (id: string, action: "BLOCK" | "MASK") => void;
onBlockedWordAdd: (word: BlockedWord) => void;
onBlockedWordRemove: (id: string) => void;
onBlockedWordUpdate: (id: string, field: string, value: any) => void;
onFileUpload?: (content: string) => void;
accessToken: string | null;
showStep?: "patterns" | "keywords";
}
const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
prebuiltPatterns,
categories,
selectedPatterns,
blockedWords,
onPatternAdd,
onPatternRemove,
onPatternActionChange,
onBlockedWordAdd,
onBlockedWordRemove,
onBlockedWordUpdate,
onFileUpload,
accessToken,
showStep,
}) => {
const [patternModalVisible, setPatternModalVisible] = useState(false);
const [keywordModalVisible, setKeywordModalVisible] = useState(false);
const [customPatternModalVisible, setCustomPatternModalVisible] = useState(false);
const [selectedPatternName, setSelectedPatternName] = useState<string>("");
const [patternAction, setPatternAction] = useState<"BLOCK" | "MASK">("BLOCK");
const [customPatternName, setCustomPatternName] = useState<string>("");
const [customPatternRegex, setCustomPatternRegex] = useState<string>("");
const [customPatternAction, setCustomPatternAction] = useState<"BLOCK" | "MASK">("BLOCK");
const [newKeyword, setNewKeyword] = useState<string>("");
const [newKeywordAction, setNewKeywordAction] = useState<"BLOCK" | "MASK">("BLOCK");
const [newKeywordDescription, setNewKeywordDescription] = useState<string>("");
const [uploadValidating, setUploadValidating] = useState(false);
const handleAddPrebuiltPattern = () => {
if (!selectedPatternName) {
NotificationsManager.error("Please select a pattern");
return;
}
onPatternAdd({
id: `pattern-${Date.now()}`,
type: "prebuilt",
name: selectedPatternName,
action: patternAction,
});
setPatternModalVisible(false);
setSelectedPatternName("");
setPatternAction("BLOCK");
};
const handleAddCustomPattern = () => {
if (!customPatternName || !customPatternRegex) {
NotificationsManager.error("Please provide pattern name and regex");
return;
}
onPatternAdd({
id: `custom-${Date.now()}`,
type: "custom",
name: customPatternName,
pattern: customPatternRegex,
action: customPatternAction,
});
setCustomPatternModalVisible(false);
setCustomPatternName("");
setCustomPatternRegex("");
setCustomPatternAction("BLOCK");
};
const handleAddKeyword = () => {
if (!newKeyword) {
NotificationsManager.error("Please enter a keyword");
return;
}
onBlockedWordAdd({
id: `word-${Date.now()}`,
keyword: newKeyword,
action: newKeywordAction,
description: newKeywordDescription || undefined,
});
setKeywordModalVisible(false);
setNewKeyword("");
setNewKeywordDescription("");
setNewKeywordAction("BLOCK");
};
const handleFileUpload = async (file: File) => {
setUploadValidating(true);
try {
const content = await file.text();
if (accessToken) {
const result = await validateBlockedWordsFile(accessToken, content);
if (result.valid) {
if (onFileUpload) {
onFileUpload(content);
}
NotificationsManager.success(result.message || "File uploaded successfully");
} else {
const errorMessage = result.error || (result.errors && result.errors.join(", ")) || "Invalid file";
NotificationsManager.error(`Validation failed: ${errorMessage}`);
}
}
} catch (error) {
NotificationsManager.error(`Failed to upload file: ${error}`);
} finally {
setUploadValidating(false);
}
return false;
};
const showPatterns = !showStep || showStep === "patterns";
const showKeywords = !showStep || showStep === "keywords";
return (
<div className="space-y-6">
{!showStep && (
<div>
<Text type="secondary">
Configure patterns and keywords to detect and filter sensitive information in requests and responses.
</Text>
</div>
)}
{showPatterns && (
<Card
title={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0 }}>
Pattern Detection
</Title>
<Text type="secondary" style={{ fontSize: 14, fontWeight: 400 }}>
Detect sensitive information using regex patterns (SSN, credit cards, API keys, etc.)
</Text>
</div>
}
size="small"
>
<div style={{ marginBottom: 16 }}>
<Space>
<Button onClick={() => setPatternModalVisible(true)} icon={PlusOutlined}>
Add prebuilt pattern
</Button>
<Button onClick={() => setCustomPatternModalVisible(true)} variant="secondary" icon={PlusOutlined}>
Add custom regex
</Button>
</Space>
</div>
<PatternTable
patterns={selectedPatterns}
onActionChange={onPatternActionChange}
onRemove={onPatternRemove}
/>
</Card>
)}
{showKeywords && (
<Card
title={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0 }}>
Blocked Keywords
</Title>
<Text type="secondary" style={{ fontSize: 14, fontWeight: 400 }}>
Block or mask specific sensitive terms and phrases
</Text>
</div>
}
size="small"
>
<div style={{ marginBottom: 16 }}>
<Space>
<Button onClick={() => setKeywordModalVisible(true)} icon={PlusOutlined}>
Add keyword
</Button>
<Upload beforeUpload={handleFileUpload} accept=".yaml,.yml" showUploadList={false}>
<Button variant="secondary" icon={UploadOutlined} loading={uploadValidating}>
Upload YAML file
</Button>
</Upload>
</Space>
</div>
<KeywordTable
keywords={blockedWords}
onActionChange={onBlockedWordUpdate}
onRemove={onBlockedWordRemove}
/>
</Card>
)}
<PatternModal
visible={patternModalVisible}
prebuiltPatterns={prebuiltPatterns}
categories={categories}
selectedPatternName={selectedPatternName}
patternAction={patternAction}
onPatternNameChange={setSelectedPatternName}
onActionChange={(value) => setPatternAction(value as "BLOCK" | "MASK")}
onAdd={handleAddPrebuiltPattern}
onCancel={() => {
setPatternModalVisible(false);
setSelectedPatternName("");
setPatternAction("BLOCK");
}}
/>
<CustomPatternModal
visible={customPatternModalVisible}
patternName={customPatternName}
patternRegex={customPatternRegex}
patternAction={customPatternAction}
onNameChange={setCustomPatternName}
onRegexChange={setCustomPatternRegex}
onActionChange={(value) => setCustomPatternAction(value as "BLOCK" | "MASK")}
onAdd={handleAddCustomPattern}
onCancel={() => {
setCustomPatternModalVisible(false);
setCustomPatternName("");
setCustomPatternRegex("");
setCustomPatternAction("BLOCK");
}}
/>
<KeywordModal
visible={keywordModalVisible}
keyword={newKeyword}
action={newKeywordAction}
description={newKeywordDescription}
onKeywordChange={setNewKeyword}
onActionChange={(value) => setNewKeywordAction(value as "BLOCK" | "MASK")}
onDescriptionChange={setNewKeywordDescription}
onAdd={handleAddKeyword}
onCancel={() => {
setKeywordModalVisible(false);
setNewKeyword("");
setNewKeywordDescription("");
setNewKeywordAction("BLOCK");
}}
/>
</div>
);
};
export default ContentFilterConfiguration;
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import CustomPatternModal from "./CustomPatternModal";
describe("CustomPatternModal", () => {
const mockOnAdd = vi.fn();
const mockOnCancel = vi.fn();
const mockOnNameChange = vi.fn();
const mockOnRegexChange = vi.fn();
const mockOnActionChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("should allow entering regex and pattern name and call onAdd when clicking add button", async () => {
/**
* Tests that user can enter a pattern name and regex, then clicking Add
* calls the onAdd callback. This is the core functionality of adding custom patterns.
*/
const user = userEvent.setup();
render(
<CustomPatternModal
visible={true}
patternName=""
patternRegex=""
patternAction="BLOCK"
onNameChange={mockOnNameChange}
onRegexChange={mockOnRegexChange}
onActionChange={mockOnActionChange}
onAdd={mockOnAdd}
onCancel={mockOnCancel}
/>
);
// Wait for modal to be visible
await waitFor(() => {
expect(screen.getByText("Add custom regex pattern")).toBeInTheDocument();
});
// Find and fill the pattern name input
const nameInput = screen.getByPlaceholderText("e.g., internal_id, employee_code");
await user.type(nameInput, "employee_id");
// Find and fill the regex pattern input - use paste instead of type to avoid special char issues
const regexInput = screen.getByPlaceholderText("e.g., ID-[0-9]{6}");
await user.click(regexInput);
await user.paste("EMP-[0-9]{5}");
// Verify the change handlers were called
expect(mockOnNameChange).toHaveBeenCalled();
expect(mockOnRegexChange).toHaveBeenCalled();
// Find and click the Add button
const addButton = screen.getByRole("button", { name: /add/i });
await user.click(addButton);
// Verify onAdd was called
expect(mockOnAdd).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,92 @@
import React from "react";
import { Typography, Select, Modal, Space } from "antd";
import { Button, TextInput } from "@tremor/react";
const { Text } = Typography;
const { Option } = Select;
interface CustomPatternModalProps {
visible: boolean;
patternName: string;
patternRegex: string;
patternAction: "BLOCK" | "MASK";
onNameChange: (name: string) => void;
onRegexChange: (regex: string) => void;
onActionChange: (action: "BLOCK" | "MASK") => void;
onAdd: () => void;
onCancel: () => void;
}
const CustomPatternModal: React.FC<CustomPatternModalProps> = ({
visible,
patternName,
patternRegex,
patternAction,
onNameChange,
onRegexChange,
onActionChange,
onAdd,
onCancel,
}) => {
return (
<Modal
title="Add custom regex pattern"
open={visible}
onCancel={onCancel}
footer={null}
width={800}
>
<Space direction="vertical" style={{ width: "100%" }} size="large">
<div>
<Text strong>Pattern name</Text>
<TextInput
placeholder="e.g., internal_id, employee_code"
value={patternName}
onValueChange={onNameChange}
style={{ marginTop: 8 }}
/>
</div>
<div>
<Text strong>Regex pattern</Text>
<TextInput
placeholder="e.g., ID-[0-9]{6}"
value={patternRegex}
onValueChange={onRegexChange}
style={{ marginTop: 8 }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
Enter a valid regular expression to match sensitive data
</Text>
</div>
<div>
<Text strong>Action</Text>
<Text type="secondary" style={{ display: "block", marginTop: 4, marginBottom: 8 }}>
Choose what action the guardrail should take when this pattern is detected
</Text>
<Select
value={patternAction}
onChange={onActionChange}
style={{ width: "100%" }}
>
<Option value="BLOCK">Block</Option>
<Option value="MASK">Mask</Option>
</Select>
</div>
</Space>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "24px" }}>
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onAdd}>
Add
</Button>
</div>
</Modal>
);
};
export default CustomPatternModal;
@@ -0,0 +1,90 @@
import React from "react";
import { Typography, Select, Modal, Space } from "antd";
import { Button, TextInput, Textarea } from "@tremor/react";
const { Text } = Typography;
const { Option } = Select;
interface KeywordModalProps {
visible: boolean;
keyword: string;
action: "BLOCK" | "MASK";
description: string;
onKeywordChange: (keyword: string) => void;
onActionChange: (action: "BLOCK" | "MASK") => void;
onDescriptionChange: (description: string) => void;
onAdd: () => void;
onCancel: () => void;
}
const KeywordModal: React.FC<KeywordModalProps> = ({
visible,
keyword,
action,
description,
onKeywordChange,
onActionChange,
onDescriptionChange,
onAdd,
onCancel,
}) => {
return (
<Modal
title="Add blocked keyword"
open={visible}
onCancel={onCancel}
footer={null}
width={800}
>
<Space direction="vertical" style={{ width: "100%" }} size="large">
<div>
<Text strong>Keyword</Text>
<TextInput
placeholder="Enter sensitive keyword or phrase"
value={keyword}
onValueChange={onKeywordChange}
style={{ marginTop: 8 }}
/>
</div>
<div>
<Text strong>Action</Text>
<Text type="secondary" style={{ display: "block", marginTop: 4, marginBottom: 8 }}>
Choose what action the guardrail should take when this keyword is detected
</Text>
<Select
value={action}
onChange={onActionChange}
style={{ width: "100%" }}
>
<Option value="BLOCK">Block</Option>
<Option value="MASK">Mask</Option>
</Select>
</div>
<div>
<Text strong>Description (optional)</Text>
<Textarea
placeholder="Explain why this keyword is sensitive"
value={description}
onValueChange={onDescriptionChange}
rows={3}
style={{ marginTop: 8 }}
/>
</div>
</Space>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "24px" }}>
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onAdd}>
Add
</Button>
</div>
</Modal>
);
};
export default KeywordModal;
@@ -0,0 +1,94 @@
import React from "react";
import { Typography, Select, Table } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { Button } from "@tremor/react";
const { Text } = Typography;
const { Option } = Select;
interface BlockedWord {
id: string;
keyword: string;
action: "BLOCK" | "MASK";
description?: string;
}
interface KeywordTableProps {
keywords: BlockedWord[];
onActionChange: (id: string, field: string, value: any) => void;
onRemove: (id: string) => void;
}
const KeywordTable: React.FC<KeywordTableProps> = ({
keywords,
onActionChange,
onRemove,
}) => {
const columns = [
{
title: "Keyword",
dataIndex: "keyword",
key: "keyword",
},
{
title: "Action",
dataIndex: "action",
key: "action",
width: 150,
render: (action: string, record: BlockedWord) => (
<Select
value={action}
onChange={(value) => onActionChange(record.id, "action", value)}
style={{ width: 120 }}
size="small"
>
<Option value="BLOCK">Block</Option>
<Option value="MASK">Mask</Option>
</Select>
),
},
{
title: "Description",
dataIndex: "description",
key: "description",
render: (desc: string) => desc || "-",
},
{
title: "",
key: "actions",
width: 100,
render: (_: any, record: BlockedWord) => (
<Button
variant="light"
color="red"
size="xs"
icon={DeleteOutlined}
onClick={() => onRemove(record.id)}
>
Delete
</Button>
),
},
];
if (keywords.length === 0) {
return (
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>
No keywords added.
</div>
);
}
return (
<Table
dataSource={keywords}
columns={columns}
rowKey="id"
pagination={false}
size="small"
/>
);
};
export default KeywordTable;
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import PatternModal from "./PatternModal";
describe("PatternModal", () => {
const mockOnAdd = vi.fn();
const mockOnCancel = vi.fn();
const mockOnPatternNameChange = vi.fn();
const mockOnActionChange = vi.fn();
const mockPrebuiltPatterns = [
{ name: "us_ssn", category: "PII Patterns", description: "US Social Security Number" },
{ name: "email", category: "PII Patterns", description: "Email addresses" },
{ name: "visa", category: "Financial Patterns", description: "Visa credit card numbers" },
{ name: "aws_access_key", category: "Credential Patterns", description: "AWS Access Keys" },
];
const mockCategories = ["PII Patterns", "Financial Patterns", "Credential Patterns"];
beforeEach(() => {
vi.clearAllMocks();
});
it("should show dropdown with prebuilt pattern options grouped by category", async () => {
/**
* Tests that the modal displays a dropdown with prebuilt patterns
* organized by category. This verifies the pattern selection UI is working.
*/
const user = userEvent.setup();
render(
<PatternModal
visible={true}
prebuiltPatterns={mockPrebuiltPatterns}
categories={mockCategories}
selectedPatternName=""
patternAction="BLOCK"
onPatternNameChange={mockOnPatternNameChange}
onActionChange={mockOnActionChange}
onAdd={mockOnAdd}
onCancel={mockOnCancel}
/>
);
// Wait for modal to be visible
await waitFor(() => {
expect(screen.getByText("Add prebuilt pattern")).toBeInTheDocument();
});
// Find the pattern type dropdown by looking for the first combobox input
const comboboxes = screen.getAllByRole("combobox");
const dropdown = comboboxes[0]; // First combobox is the pattern selector
expect(dropdown).toBeInTheDocument();
// Click to open the dropdown
await user.click(dropdown);
// Verify that pattern options are available in the dropdown
// Ant Design renders Select options in a portal, so we need to query the whole document
await waitFor(() => {
const options = document.querySelectorAll('.ant-select-item-option');
expect(options.length).toBeGreaterThan(0);
});
// Verify categories are shown as group labels
await waitFor(() => {
expect(document.body).toHaveTextContent("PII Patterns");
expect(document.body).toHaveTextContent("Financial Patterns");
expect(document.body).toHaveTextContent("Credential Patterns");
});
// Verify pattern options are available
expect(document.body).toHaveTextContent("us_ssn");
expect(document.body).toHaveTextContent("email");
expect(document.body).toHaveTextContent("visa");
expect(document.body).toHaveTextContent("aws_access_key");
// Select a pattern by clicking on its option element
const ssnOption = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
el => el.textContent === "us_ssn"
) as HTMLElement;
await user.click(ssnOption);
// Verify the change handler was called with the pattern name
// Note: Ant Design Select calls onChange with (value, option), so we check if it was called
expect(mockOnPatternNameChange).toHaveBeenCalled();
const callArgs = mockOnPatternNameChange.mock.calls[0];
expect(callArgs[0]).toBe("us_ssn");
});
});
@@ -0,0 +1,107 @@
import React from "react";
import { Typography, Select, Modal, Space } from "antd";
import { Button } from "@tremor/react";
const { Text } = Typography;
const { Option } = Select;
interface PrebuiltPattern {
name: string;
category: string;
description: string;
}
interface PatternModalProps {
visible: boolean;
prebuiltPatterns: PrebuiltPattern[];
categories: string[];
selectedPatternName: string;
patternAction: "BLOCK" | "MASK";
onPatternNameChange: (name: string) => void;
onActionChange: (action: "BLOCK" | "MASK") => void;
onAdd: () => void;
onCancel: () => void;
}
const PatternModal: React.FC<PatternModalProps> = ({
visible,
prebuiltPatterns,
categories,
selectedPatternName,
patternAction,
onPatternNameChange,
onActionChange,
onAdd,
onCancel,
}) => {
return (
<Modal
title="Add prebuilt pattern"
open={visible}
onCancel={onCancel}
footer={null}
width={800}
>
<Space direction="vertical" style={{ width: "100%" }} size="large">
<div>
<Text strong>Pattern type</Text>
<Select
placeholder="Choose pattern type"
value={selectedPatternName}
onChange={onPatternNameChange}
style={{ width: "100%", marginTop: 8 }}
showSearch
filterOption={(input, option) => {
if (typeof option?.value === 'string') {
return option.value.toLowerCase().includes(input.toLowerCase());
}
return false;
}}
>
{categories.map((category) => {
const categoryPatterns = prebuiltPatterns.filter((p) => p.category === category);
if (categoryPatterns.length === 0) return null;
return (
<Select.OptGroup key={category} label={category}>
{categoryPatterns.map((pattern) => (
<Option key={pattern.name} value={pattern.name}>
{pattern.name}
</Option>
))}
</Select.OptGroup>
);
})}
</Select>
</div>
<div>
<Text strong>Action</Text>
<Text type="secondary" style={{ display: "block", marginTop: 4, marginBottom: 8 }}>
Choose what action the guardrail should take when this pattern is detected
</Text>
<Select
value={patternAction}
onChange={onActionChange}
style={{ width: "100%" }}
>
<Option value="BLOCK">Block</Option>
<Option value="MASK">Mask</Option>
</Select>
</div>
</Space>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "24px" }}>
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onAdd}>
Add
</Button>
</div>
</Modal>
);
};
export default PatternModal;
@@ -0,0 +1,106 @@
import React from "react";
import { Typography, Select, Table, Tag } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { Button } from "@tremor/react";
const { Text } = Typography;
const { Option } = Select;
interface Pattern {
id: string;
type: "prebuilt" | "custom";
name: string;
pattern?: string;
action: "BLOCK" | "MASK";
}
interface PatternTableProps {
patterns: Pattern[];
onActionChange: (id: string, action: "BLOCK" | "MASK") => void;
onRemove: (id: string) => void;
}
const PatternTable: React.FC<PatternTableProps> = ({
patterns,
onActionChange,
onRemove,
}) => {
const columns = [
{
title: "Type",
dataIndex: "type",
key: "type",
width: 100,
render: (type: string) => (
<Tag color={type === "prebuilt" ? "blue" : "green"}>
{type === "prebuilt" ? "Prebuilt" : "Custom"}
</Tag>
),
},
{
title: "Pattern name",
dataIndex: "name",
key: "name",
},
{
title: "Regex pattern",
dataIndex: "pattern",
key: "pattern",
render: (pattern: string) => (pattern ? <Text code style={{ fontSize: 12 }}>{pattern.substring(0, 40)}...</Text> : "-"),
},
{
title: "Action",
dataIndex: "action",
key: "action",
width: 150,
render: (action: string, record: Pattern) => (
<Select
value={action}
onChange={(value) => onActionChange(record.id, value as "BLOCK" | "MASK")}
style={{ width: 120 }}
size="small"
>
<Option value="BLOCK">Block</Option>
<Option value="MASK">Mask</Option>
</Select>
),
},
{
title: "",
key: "actions",
width: 100,
render: (_: any, record: Pattern) => (
<Button
variant="light"
color="red"
size="xs"
icon={DeleteOutlined}
onClick={() => onRemove(record.id)}
>
Delete
</Button>
),
},
];
if (patterns.length === 0) {
return (
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>
No patterns added.
</div>
);
}
return (
<Table
dataSource={patterns}
columns={columns}
rowKey="id"
pagination={false}
size="small"
/>
);
};
export default PatternTable;
@@ -0,0 +1,47 @@
/**
* Type definitions for Content Filter Configuration
*/
export interface PrebuiltPattern {
name: string;
category: string;
description: string;
}
export interface Pattern {
id: string;
type: "prebuilt" | "custom";
name: string;
pattern?: string;
action: "BLOCK" | "MASK";
}
export interface BlockedWord {
id: string;
keyword: string;
action: "BLOCK" | "MASK";
description?: string;
}
export interface ContentFilterSettings {
prebuilt_patterns: PrebuiltPattern[];
pattern_categories: string[];
supported_actions: string[];
}
export interface ContentFilterConfigurationProps {
prebuiltPatterns: PrebuiltPattern[];
categories: string[];
selectedPatterns: Pattern[];
blockedWords: BlockedWord[];
blockedWordsFile?: string;
onPatternAdd: (pattern: Pattern) => void;
onPatternRemove: (id: string) => void;
onPatternActionChange: (id: string, action: "BLOCK" | "MASK") => void;
onBlockedWordAdd: (word: BlockedWord) => void;
onBlockedWordRemove: (id: string) => void;
onBlockedWordUpdate: (id: string, field: string, value: any) => void;
onFileUpload: (content: string) => void;
accessToken: string | null;
}
@@ -45,6 +45,7 @@ export const guardrail_provider_map: Record<string, string> = {
PresidioPII: "presidio",
Bedrock: "bedrock",
Lakera: "lakera_v2",
LitellmContentFilter: "litellm_content_filter",
};
// Function to populate provider map from API response - updates the original map
@@ -88,6 +89,17 @@ export const shouldRenderAzureTextModerationConfigSettings = (provider: string |
return providerEnum === "Azure Content Safety Text Moderation";
};
// Decides if we should render the Content Filter config settings for a given provider
export const shouldRenderContentFilterConfigSettings = (provider: string | null) => {
if (!provider) {
return false;
}
// Check both dynamic and legacy providers
const currentProviders = getGuardrailProviders();
const providerEnum = currentProviders[provider as keyof typeof currentProviders];
return providerEnum === "LiteLLM Content Filter";
};
const asset_logos_folder = "../ui/assets/logos/";
export const guardrailLogoMap: Record<string, string> = {
@@ -108,6 +120,7 @@ export const guardrailLogoMap: Record<string, string> = {
"AIM Guardrail": `${asset_logos_folder}aim_security.jpeg`,
"OpenAI Moderation": `${asset_logos_folder}openai_small.svg`,
EnkryptAI: `${asset_logos_folder}enkrypt_ai.avif`,
"LiteLLM Content Filter": `${asset_logos_folder}litellm_logo.svg`,
};
export const getGuardrailLogoAndName = (guardrailValue: string): { logo: string; displayName: string } => {
@@ -6618,6 +6618,36 @@ export const applyGuardrail = async (
}
};
export const validateBlockedWordsFile = async (accessToken: string, fileContent: string) => {
try {
const url = proxyBaseUrl
? `${proxyBaseUrl}/guardrails/validate_blocked_words_file`
: `/guardrails/validate_blocked_words_file`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ file_content: fileContent }),
});
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error("Failed to validate blocked words file");
}
const data = await response.json();
console.log("Validate blocked words file response:", data);
return data;
} catch (error) {
console.error("Failed to validate blocked words file:", error);
throw error;
}
};
export const getSSOSettings = async (accessToken: string) => {
try {
// Construct base URL