From bdc496952626ac8f8ff5ac9bbd2b9cf2fe069abd Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Thu, 6 Nov 2025 16:03:55 -0800 Subject: [PATCH] [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 --- .../proxy/guardrails/guardrail_endpoints.py | 108 +++++++ .../litellm_content_filter/content_filter.py | 10 +- .../litellm_content_filter/patterns.py | 142 +++++++-- litellm/proxy/proxy_config.yaml | 13 +- litellm/types/guardrails.py | 1 + .../guardrail_hooks/litellm_content_filter.py | 7 + .../guardrails/add_guardrail_form.tsx | 93 +++++- .../ContentFilterConfiguration.tsx | 295 ++++++++++++++++++ .../CustomPatternModal.test.tsx | 64 ++++ .../content_filter/CustomPatternModal.tsx | 92 ++++++ .../content_filter/KeywordModal.tsx | 90 ++++++ .../content_filter/KeywordTable.tsx | 94 ++++++ .../content_filter/PatternModal.test.tsx | 92 ++++++ .../content_filter/PatternModal.tsx | 107 +++++++ .../content_filter/PatternTable.tsx | 106 +++++++ .../guardrails/content_filter/types.ts | 47 +++ .../guardrails/guardrail_info_helpers.tsx | 13 + .../src/components/networking.tsx | 30 ++ 18 files changed, 1373 insertions(+), 31 deletions(-) create mode 100644 litellm/types/proxy/guardrails/guardrail_hooks/litellm_content_filter.py create mode 100644 ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.test.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/content_filter/KeywordModal.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/content_filter/KeywordTable.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/content_filter/PatternModal.test.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/content_filter/PatternModal.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/content_filter/PatternTable.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/content_filter/types.ts diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index a09185c6e3..d7b37f8a3f 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -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 diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index 428b26ee16..bad716643e 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -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 diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.py index 89b3e5465d..cfdb269411 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.py @@ -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 + diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index 089de860e6..040db4aa42 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -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 \ No newline at end of file + drop_params: True + + +general_settings: + store_prompts_in_spend_logs: True \ No newline at end of file diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index a6359edc29..931d9d9d14 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -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): diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/litellm_content_filter.py b/litellm/types/proxy/guardrails/guardrail_hooks/litellm_content_filter.py new file mode 100644 index 0000000000..4ccab3718e --- /dev/null +++ b/litellm/types/proxy/guardrails/guardrail_hooks/litellm_content_filter.py @@ -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" \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx b/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx index c416cdd7f7..95aa398f43 100644 --- a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx @@ -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 = ({ visible, onClose, a const [globalSeverityThreshold, setGlobalSeverityThreshold] = useState(2); const [categorySpecificThresholds, setCategorySpecificThresholds] = useState<{ [key: string]: number }>({}); + // Content Filter state + const [selectedPatterns, setSelectedPatterns] = useState([]); + const [blockedWords, setBlockedWords] = useState([]); + // Fetch guardrail UI settings + provider params on mount / accessToken change useEffect(() => { if (!accessToken) return; @@ -266,6 +281,26 @@ const AddGuardrailForm: React.FC = ({ 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 = ({ 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 ( + 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 = ({ 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 (
{currentStep > 0 && ( @@ -559,8 +637,8 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a Previous )} - {currentStep < 2 && } - {currentStep === 2 && ( + {!isLastStep && } + {isLastStep && ( @@ -585,8 +663,17 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a + {shouldRenderContentFilterConfigSettings(selectedProvider) && ( + + )} {renderStepContent()} diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx new file mode 100644 index 0000000000..9e5a2ffe35 --- /dev/null +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx @@ -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 = ({ + 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(""); + const [patternAction, setPatternAction] = useState<"BLOCK" | "MASK">("BLOCK"); + const [customPatternName, setCustomPatternName] = useState(""); + const [customPatternRegex, setCustomPatternRegex] = useState(""); + const [customPatternAction, setCustomPatternAction] = useState<"BLOCK" | "MASK">("BLOCK"); + const [newKeyword, setNewKeyword] = useState(""); + const [newKeywordAction, setNewKeywordAction] = useState<"BLOCK" | "MASK">("BLOCK"); + const [newKeywordDescription, setNewKeywordDescription] = useState(""); + 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 ( +
+ {!showStep && ( +
+ + Configure patterns and keywords to detect and filter sensitive information in requests and responses. + +
+ )} + + {showPatterns && ( + + + Pattern Detection + + + Detect sensitive information using regex patterns (SSN, credit cards, API keys, etc.) + +
+ } + size="small" + > +
+ + + + +
+ + + )} + + {showKeywords && ( + + + Blocked Keywords + + + Block or mask specific sensitive terms and phrases + +
+ } + size="small" + > +
+ + + + + + +
+ + + )} + + setPatternAction(value as "BLOCK" | "MASK")} + onAdd={handleAddPrebuiltPattern} + onCancel={() => { + setPatternModalVisible(false); + setSelectedPatternName(""); + setPatternAction("BLOCK"); + }} + /> + + setCustomPatternAction(value as "BLOCK" | "MASK")} + onAdd={handleAddCustomPattern} + onCancel={() => { + setCustomPatternModalVisible(false); + setCustomPatternName(""); + setCustomPatternRegex(""); + setCustomPatternAction("BLOCK"); + }} + /> + + setNewKeywordAction(value as "BLOCK" | "MASK")} + onDescriptionChange={setNewKeywordDescription} + onAdd={handleAddKeyword} + onCancel={() => { + setKeywordModalVisible(false); + setNewKeyword(""); + setNewKeywordDescription(""); + setNewKeywordAction("BLOCK"); + }} + /> + + ); +}; + +export default ContentFilterConfiguration; diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.test.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.test.tsx new file mode 100644 index 0000000000..fa813688d7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.test.tsx @@ -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( + + ); + + // 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); + }); +}); + diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.tsx new file mode 100644 index 0000000000..c69a7a0e33 --- /dev/null +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.tsx @@ -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 = ({ + visible, + patternName, + patternRegex, + patternAction, + onNameChange, + onRegexChange, + onActionChange, + onAdd, + onCancel, +}) => { + return ( + + +
+ Pattern name + +
+ +
+ Regex pattern + + + Enter a valid regular expression to match sensitive data + +
+ +
+ Action + + Choose what action the guardrail should take when this pattern is detected + + +
+
+ +
+ + +
+
+ ); +}; + +export default CustomPatternModal; + diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/KeywordModal.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/KeywordModal.tsx new file mode 100644 index 0000000000..2a57a3d0db --- /dev/null +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/KeywordModal.tsx @@ -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 = ({ + visible, + keyword, + action, + description, + onKeywordChange, + onActionChange, + onDescriptionChange, + onAdd, + onCancel, +}) => { + return ( + + +
+ Keyword + +
+ +
+ Action + + Choose what action the guardrail should take when this keyword is detected + + +
+ +
+ Description (optional) +