diff --git a/ui/litellm-dashboard/.eslintrc.json b/ui/litellm-dashboard/.eslintrc.json index 3345bf83a9f..efd7f8d8607 100644 --- a/ui/litellm-dashboard/.eslintrc.json +++ b/ui/litellm-dashboard/.eslintrc.json @@ -104,6 +104,8 @@ "src/components/guardrails/content_filter/PatternTable.tsx", "src/components/guardrails/content_filter/ContentFilterDisplay.tsx", "src/components/guardrails/content_filter/CategoryTable.tsx", + "src/components/guardrails/content_filter/ContentCategoryConfiguration.tsx", + "src/components/guardrails/custom_code/CustomCodeModal.tsx", "src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx", "src/components/prompts/variable_textarea.tsx", "src/components/chat/MCPCredentialsTab.tsx", diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/CompetitorIntentConfiguration.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/CompetitorIntentConfiguration.tsx index f475efea367..8ed614358d0 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/CompetitorIntentConfiguration.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/CompetitorIntentConfiguration.tsx @@ -1,18 +1,19 @@ import React, { useEffect, useState } from "react"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { - Card, - Typography, Select, - Switch, - Form, - Space, - InputNumber, -} from "antd"; + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { X } from "lucide-react"; import { getMajorAirlines } from "../../networking"; -const { Title, Text } = Typography; -const { Option } = Select; - export interface MajorAirline { id: string; match: string; @@ -53,6 +54,80 @@ const DEFAULT_CONFIG: CompetitorIntentConfig = { threshold_low: 0.3, }; +/** + * Simple chip-style tag input used by the competitor-intent form. Mirrors + * the behavior of antd's `Select mode="tags"` but without using antd. + */ +function TagInput({ + value, + onChange, + placeholder, + disabled = false, +}: { + value: string[]; + onChange: (values: string[]) => void; + placeholder?: string; + disabled?: boolean; +}) { + const [draft, setDraft] = useState(""); + + const commit = () => { + const trimmed = draft.trim(); + if (!trimmed) return; + const parts = trimmed + .split(",") + .map((p) => p.trim()) + .filter(Boolean); + const next = [...value]; + for (const p of parts) { + if (!next.includes(p)) next.push(p); + } + onChange(next); + setDraft(""); + }; + + return ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + commit(); + } else if (e.key === "Backspace" && !draft && value.length > 0) { + onChange(value.slice(0, -1)); + } + }} + onBlur={commit} + /> + {value.length > 0 && ( +
+ {value.map((v) => ( + + {v} + + + ))} +
+ )} +
+ ); +} + const CompetitorIntentConfiguration: React.FC< CompetitorIntentConfigurationProps > = ({ enabled, config, onChange, accessToken }) => { @@ -89,7 +164,10 @@ const CompetitorIntentConfiguration: React.FC< }); }; - const handleNestedArrayChange = (field: "brand_self" | "locations" | "competitors", values: string[]) => { + const handleNestedArrayChange = ( + field: "brand_self" | "locations" | "competitors", + values: string[], + ) => { onChange(enabled, { ...effectiveConfig, [field]: values.filter(Boolean) }); }; @@ -103,7 +181,10 @@ const CompetitorIntentConfiguration: React.FC< return primary === v.toLowerCase(); }); if (airline) { - for (const variant of airline.match.split("|").map((s) => s.trim().toLowerCase()).filter(Boolean)) { + for (const variant of airline.match + .split("|") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean)) { if (!seen.has(variant)) { seen.add(variant); expanded.push(variant); @@ -117,219 +198,329 @@ const CompetitorIntentConfiguration: React.FC< onChange(enabled, { ...effectiveConfig, brand_self: expanded }); }; + const header = ( +
+
Competitor Intent Filter
+ +
+ ); if (!enabled) { return ( - - - Competitor Intent Filter - - - - } - size="small" - > - - Block or reframe competitor comparison questions. When enabled, airline type - auto-loads competitors from IATA; generic type requires manual competitor list. - + + {header} +

+ Block or reframe competitor comparison questions. When enabled, airline + type auto-loads competitors from IATA; generic type requires manual + competitor list. +

); } - return ( - - - Competitor Intent Filter - - - - } - size="small" - > - - Block or reframe competitor comparison questions. Airline type uses major airlines - (excluding your brand); generic requires manual competitor list. - -
- - - + const isAirline = effectiveConfig.competitor_intent_type === "airline"; + const useAirlineSelect = isAirline && airlineOptions.length > 0; - + {header} +

+ Block or reframe competitor comparison questions. Airline type uses + major airlines (excluding your brand); generic requires manual + competitor list. +

+ +
+ + + + + + + Airline (auto-load competitors from IATA) + + + Generic (specify competitors manually) + + + +
+ +
+ + {useAirlineSelect ? ( + handleBrandSelfChange(values)} + /> + ) : ( + handleNestedArrayChange("brand_self", v)} placeholder={ loadingAirlines ? "Loading airlines..." - : effectiveConfig.competitor_intent_type === "airline" - ? "Search or select airline, or type to add custom" - : "Type and press Enter to add" - } - value={effectiveConfig.brand_self} - onChange={(v) => - effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0 - ? handleBrandSelfChange(v ?? []) - : handleNestedArrayChange("brand_self", v ?? []) - } - tokenSeparators={[","]} - loading={loadingAirlines} - showSearch - filterOption={(input, option) => - (option?.label?.toString().toLowerCase() ?? "").includes( - input.toLowerCase() - ) - } - optionFilterProp="label" - options={ - effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0 - ? airlineOptions.map((a) => { - const primary = a.match.split("|")[0]?.trim() ?? a.id; - const variants = a.match - .split("|") - .map((s) => s.trim().toLowerCase()) - .filter(Boolean); - return { - value: primary.toLowerCase(), - label: `${primary}${variants.length > 1 ? ` (${variants.slice(1).join(", ")})` : ""}`, - }; - }) - : undefined + : "Type and press Enter to add" } /> - - - {effectiveConfig.competitor_intent_type === "airline" && ( - - handleNestedArrayChange("competitors", v ?? [])} - tokenSeparators={[","]} - /> - - )} + {isAirline && ( +
+ + handleNestedArrayChange("locations", v)} + placeholder="Type and press Enter to add" + /> +

+ Countries, cities, airports for disambiguation (e.g. qatar, doha) +

+
+ )} - - - + {effectiveConfig.competitor_intent_type === "generic" && ( +
+ + handleNestedArrayChange("competitors", v)} + placeholder="Type and press Enter to add" + /> +

+ Competitor names to detect (required for generic type) +

+
+ )} - - - +
+ + +
- - Classify competitor intent by confidence (0–1). Higher confidence → stronger intent. -
    -
  • - High (≥): Treat as full competitor comparison → uses "Competitor comparison" policy -
  • -
  • - Medium (≥): Treat as possible comparison → uses "Possible competitor comparison" policy -
  • -
  • - Low (≥): Log only; allow request. Below Low → allow with no action -
  • -
- Raise thresholds to be more permissive; lower them to be stricter. - +
+ + +
+ +
+ +
+
+ + + handleConfigChange( + "threshold_high", + e.target.value === "" ? 0.7 : Number(e.target.value), + ) + } + className="w-24" + /> +
+
+ + + handleConfigChange( + "threshold_medium", + e.target.value === "" ? 0.45 : Number(e.target.value), + ) + } + className="w-24" + /> +
+
+ + + handleConfigChange( + "threshold_low", + e.target.value === "" ? 0.3 : Number(e.target.value), + ) + } + className="w-24" + /> +
+
+
+ Classify competitor intent by confidence (0–1). Higher confidence → + stronger intent. +
    +
  • + High (≥): Treat as full competitor comparison → + uses "Competitor comparison" policy +
  • +
  • + Medium (≥): Treat as possible comparison → uses + "Possible competitor comparison" policy +
  • +
  • + Low (≥): Log only; allow request. Below Low → + allow with no action +
  • +
+ Raise thresholds to be more permissive; lower them to be stricter. +
+
); }; +/** + * Select + chip display for airlines. Adds on pick; supports custom entries + * via the parallel TagInput below. + */ +function AirlineBrandSelect({ + selected, + options, + loading, + onChange, +}: { + selected: string[]; + options: MajorAirline[]; + loading: boolean; + onChange: (values: string[]) => void; +}) { + const selectedSet = new Set(selected.map((s) => s.toLowerCase())); + const unselected = options.filter((a) => { + const primary = a.match.split("|")[0]?.trim().toLowerCase(); + return primary && !selectedSet.has(primary); + }); + + return ( +
+
+ +
+
+ {selected.map((v) => ( + + {v} + + + ))} +
+
+ ); +} + export default CompetitorIntentConfiguration; diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentCategoryConfiguration.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentCategoryConfiguration.tsx index fce448106f4..4d13e8f5fac 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentCategoryConfiguration.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentCategoryConfiguration.tsx @@ -1,11 +1,35 @@ import React from "react"; -import { Card, Typography, Select, Table, Tag, Collapse, Button } from "antd"; -import { Trash2 as DeleteOutlined, Plus as PlusOutlined, FileText as FileTextOutlined } from "lucide-react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + FileText as FileTextOutlined, + Plus as PlusOutlined, + Trash2 as DeleteOutlined, +} from "lucide-react"; import { getCategoryYaml } from "../../networking"; -const { Title, Text } = Typography; -const { Option } = Select; - interface ContentCategory { name: string; display_name: string; @@ -42,28 +66,40 @@ const ContentCategoryConfiguration: React.FC pendingSelection, onPendingSelectionChange, }) => { - // Use controlled state if parent provides it, otherwise use local state - const [localSelectedCategoryName, setLocalSelectedCategoryName] = React.useState(""); - const selectedCategoryName = pendingSelection !== undefined ? pendingSelection : localSelectedCategoryName; - const setSelectedCategoryName = onPendingSelectionChange || setLocalSelectedCategoryName; - const [categoryYaml, setCategoryYaml] = React.useState<{ [key: string]: string }>({}); - const [categoryFileTypes, setCategoryFileTypes] = React.useState<{ [key: string]: string }>({}); - const [loadingYaml, setLoadingYaml] = React.useState<{ [key: string]: boolean }>({}); - const [expandedYamlCategories, setExpandedYamlCategories] = React.useState([]); + const [localSelectedCategoryName, setLocalSelectedCategoryName] = + React.useState(""); + const selectedCategoryName = + pendingSelection !== undefined ? pendingSelection : localSelectedCategoryName; + const setSelectedCategoryName = + onPendingSelectionChange || setLocalSelectedCategoryName; + const [categoryYaml, setCategoryYaml] = React.useState<{ + [key: string]: string; + }>({}); + const [categoryFileTypes, setCategoryFileTypes] = React.useState<{ + [key: string]: string; + }>({}); + const [loadingYaml, setLoadingYaml] = React.useState<{ + [key: string]: boolean; + }>({}); + const [expandedYamlCategories, setExpandedYamlCategories] = React.useState< + string[] + >([]); const [previewYaml, setPreviewYaml] = React.useState(""); - const [loadingPreviewYaml, setLoadingPreviewYaml] = React.useState(false); + const [loadingPreviewYaml, setLoadingPreviewYaml] = + React.useState(false); const handleAddCategory = () => { if (!selectedCategoryName) { return; } - const category = availableCategories.find((c) => c.name === selectedCategoryName); + const category = availableCategories.find( + (c) => c.name === selectedCategoryName, + ); if (!category) { return; } - // Check if already added if (selectedCategories.some((c) => c.category === selectedCategoryName)) { return; } @@ -77,15 +113,14 @@ const ContentCategoryConfiguration: React.FC }); setSelectedCategoryName(""); - setPreviewYaml(""); // Clear preview when category is added + setPreviewYaml(""); }; const fetchCategoryYaml = async (categoryName: string) => { if (!accessToken) { - return; // No access token + return; } - // Check if already loaded if (categoryYaml[categoryName]) { return; } @@ -94,62 +129,71 @@ const ContentCategoryConfiguration: React.FC try { const data = await getCategoryYaml(accessToken, categoryName); let content = data.yaml_content; - - // Format JSON content for better readability - if (data.file_type === 'json') { + + if (data.file_type === "json") { try { const parsed = JSON.parse(content); content = JSON.stringify(parsed, null, 2); } catch (e) { - // If parsing fails, use original content console.warn(`Failed to format JSON for ${categoryName}:`, e); } } - + setCategoryYaml((prev) => ({ ...prev, [categoryName]: content })); - setCategoryFileTypes((prev) => ({ ...prev, [categoryName]: data.file_type || 'yaml' })); + setCategoryFileTypes((prev) => ({ + ...prev, + [categoryName]: data.file_type || "yaml", + })); } catch (error) { - console.error(`Failed to fetch content for category ${categoryName}:`, error); + console.error( + `Failed to fetch content for category ${categoryName}:`, + error, + ); } finally { setLoadingYaml((prev) => ({ ...prev, [categoryName]: false })); } }; - // Fetch preview YAML/JSON when a category is selected in dropdown React.useEffect(() => { if (selectedCategoryName && accessToken) { - // Check if we already have this content cached const cachedContent = categoryYaml[selectedCategoryName]; if (cachedContent) { setPreviewYaml(cachedContent); return; } - // Fetch the content for preview setLoadingPreviewYaml(true); - console.log(`Fetching content for category: ${selectedCategoryName}`, { accessToken: accessToken ? "present" : "missing" }); getCategoryYaml(accessToken, selectedCategoryName) .then((data) => { - console.log(`Successfully fetched content for ${selectedCategoryName}:`, data); let content = data.yaml_content; - - // Format JSON content for better readability - if (data.file_type === 'json') { + + if (data.file_type === "json") { try { const parsed = JSON.parse(content); content = JSON.stringify(parsed, null, 2); } catch (e) { - console.warn(`Failed to format JSON for ${selectedCategoryName}:`, e); + console.warn( + `Failed to format JSON for ${selectedCategoryName}:`, + e, + ); } } - + setPreviewYaml(content); - // Also cache it for later use - setCategoryYaml((prev) => ({ ...prev, [selectedCategoryName]: content })); - setCategoryFileTypes((prev) => ({ ...prev, [selectedCategoryName]: data.file_type || 'yaml' })); + setCategoryYaml((prev) => ({ + ...prev, + [selectedCategoryName]: content, + })); + setCategoryFileTypes((prev) => ({ + ...prev, + [selectedCategoryName]: data.file_type || "yaml", + })); }) .catch((error) => { - console.error(`Failed to fetch preview content for category ${selectedCategoryName}:`, error); + console.error( + `Failed to fetch preview content for category ${selectedCategoryName}:`, + error, + ); setPreviewYaml(""); }) .finally(() => { @@ -162,173 +206,81 @@ const ContentCategoryConfiguration: React.FC // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedCategoryName, accessToken]); - const columns = [ - { - title: "Category", - dataIndex: "display_name", - key: "display_name", - render: (text: string, record: SelectedCategory) => { - const category = availableCategories.find((c) => c.name === record.category); - return ( -
-
{text}
- {category?.description && ( -
- {category.description} -
- )} -
- ); - }, - }, - { - title: "Action", - dataIndex: "action", - key: "action", - width: 150, - render: (action: string, record: SelectedCategory) => ( - - ), - }, - { - title: "Severity Threshold", - dataIndex: "severity_threshold", - key: "severity_threshold", - width: 180, - render: (threshold: string, record: SelectedCategory) => ( - - ), - }, - { - title: "", - key: "actions", - width: 80, - render: (_: any, record: SelectedCategory) => ( - - ), - }, - ]; - const unselectedCategories = availableCategories.filter( - (cat) => !selectedCategories.some((sel) => sel.category === cat.name) + (cat) => !selectedCategories.some((sel) => sel.category === cat.name), ); return ( - - - Blocked topics - - - Select topics to block using keyword and semantic analysis - -
- } - size="small" - > -
- setSelectedCategoryName(v)} + > + + + + + {unselectedCategories.length === 0 ? ( +
+ No categories available
-
- - ))} - + ) : ( + unselectedCategories.map((cat) => ( + +
+
{cat.display_name}
+
+ {cat.description} +
+
+
+ )) + )} + + + - {/* Preview box - shown when category is selected but not yet added */} {selectedCategoryName && ( -
-
- Preview: {availableCategories.find((c) => c.name === selectedCategoryName)?.display_name} +
+
+ Preview:{" "} + { + availableCategories.find((c) => c.name === selectedCategoryName) + ?.display_name + } {categoryFileTypes[selectedCategoryName] && ( - + ({categoryFileTypes[selectedCategoryName]?.toUpperCase()}) )}
{loadingPreviewYaml ? ( -
+
Loading content...
) : previewYaml ? ( -
+            
               {previewYaml}
             
) : ( -
+
Unable to load category content
)} @@ -337,83 +289,147 @@ const ContentCategoryConfiguration: React.FC {selectedCategories.length > 0 ? ( <> - -
- { - const keyArray = Array.isArray(keys) ? keys : keys ? [keys] : []; - const oldExpanded = new Set(expandedYamlCategories); - - // Find newly expanded categories and fetch their YAML - keyArray.forEach((key) => { - const categoryName = key as string; - if (!oldExpanded.has(categoryName) && !categoryYaml[categoryName]) { - fetchCategoryYaml(categoryName); - } - }); - - setExpandedYamlCategories(keyArray as string[]); - }} - ghost - items={selectedCategories.map((category) => { - const fileType = categoryFileTypes[category.category] || 'yaml'; - const fileTypeLabel = fileType.toUpperCase(); - - return { - key: category.category, - label: ( -
- - View {fileTypeLabel} for {category.display_name} -
- ), - children: loadingYaml[category.category] ? ( -
- Loading content... -
- ) : categoryYaml[category.category] ? ( -
-                      {categoryYaml[category.category]}
-                    
- ) : ( -
- Content will load when expanded -
- ), - }; +
+ + + Category + Action + Severity Threshold + + + + + {selectedCategories.map((record) => { + const category = availableCategories.find( + (c) => c.name === record.category, + ); + return ( + + +
+
{record.display_name}
+ {category?.description && ( +
+ {category.description} +
+ )} +
+
+ + + + + + + + + +
+ ); })} - /> - +
+
+ + { + const oldExpanded = new Set(expandedYamlCategories); + values.forEach((key) => { + if (!oldExpanded.has(key) && !categoryYaml[key]) { + fetchCategoryYaml(key); + } + }); + setExpandedYamlCategories(values); + }} + > + {selectedCategories.map((category) => { + const fileType = categoryFileTypes[category.category] || "yaml"; + const fileTypeLabel = fileType.toUpperCase(); + return ( + + +
+ + + View {fileTypeLabel} for {category.display_name} + +
+
+ + {loadingYaml[category.category] ? ( +
+ Loading content... +
+ ) : categoryYaml[category.category] ? ( +
+                        {categoryYaml[category.category]}
+                      
+ ) : ( +
+ Content will load when expanded +
+ )} +
+
+ ); + })} +
) : ( -
- No blocked topics selected. Add topics to detect and block harmful content. +
+ No blocked topics selected. Add topics to detect and block harmful + content.
)} @@ -421,4 +437,3 @@ const ContentCategoryConfiguration: React.FC }; export default ContentCategoryConfiguration; - diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx index 7851f57c998..40249a2fc01 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx @@ -1,5 +1,6 @@ -import React, { useState } from "react"; -import { Typography, Space, Upload, Card, Button } from "antd"; +import React, { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; import { Plus as PlusOutlined, Upload as UploadOutlined } from "lucide-react"; import { validateBlockedWordsFile } from "../../networking"; import NotificationsManager from "../../molecules/notifications_manager"; @@ -13,8 +14,6 @@ import CompetitorIntentConfiguration, { CompetitorIntentConfig, } from "./CompetitorIntentConfiguration"; -const { Title, Text } = Typography; - interface PrebuiltPattern { name: string; display_name: string; @@ -78,7 +77,7 @@ interface ContentFilterConfigurationProps { competitorIntentConfig?: CompetitorIntentConfig | null; onCompetitorIntentChange?: ( enabled: boolean, - config: CompetitorIntentConfig | null + config: CompetitorIntentConfig | null, ) => void; } @@ -109,17 +108,24 @@ const ContentFilterConfiguration: React.FC = ({ }) => { const [patternModalVisible, setPatternModalVisible] = useState(false); const [keywordModalVisible, setKeywordModalVisible] = useState(false); - const [customPatternModalVisible, setCustomPatternModalVisible] = 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 [customPatternAction, setCustomPatternAction] = useState< + "BLOCK" | "MASK" + >("BLOCK"); const [newKeyword, setNewKeyword] = useState(""); - const [newKeywordAction, setNewKeywordAction] = useState<"BLOCK" | "MASK">("BLOCK"); - const [newKeywordDescription, setNewKeywordDescription] = useState(""); + const [newKeywordAction, setNewKeywordAction] = useState<"BLOCK" | "MASK">( + "BLOCK", + ); + const [newKeywordDescription, setNewKeywordDescription] = + useState(""); const [uploadValidating, setUploadValidating] = useState(false); + const fileInputRef = useRef(null); const handleAddPrebuiltPattern = () => { if (!selectedPatternName) { @@ -127,7 +133,9 @@ const ContentFilterConfiguration: React.FC = ({ return; } - const selectedPattern = prebuiltPatterns.find((p) => p.name === selectedPatternName); + const selectedPattern = prebuiltPatterns.find( + (p) => p.name === selectedPatternName, + ); onPatternAdd({ id: `pattern-${Date.now()}`, @@ -185,16 +193,21 @@ const ContentFilterConfiguration: React.FC = ({ 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"); + NotificationsManager.success( + result.message || "File uploaded successfully", + ); } else { - const errorMessage = result.error || (result.errors && result.errors.join(", ")) || "Invalid file"; + const errorMessage = + result.error || + (result.errors && result.errors.join(", ")) || + "Invalid file"; NotificationsManager.error(`Validation failed: ${errorMessage}`); } } @@ -203,48 +216,48 @@ const ContentFilterConfiguration: React.FC = ({ } finally { setUploadValidating(false); } - return false; }; const showPatterns = !showStep || showStep === "patterns"; const showKeywords = !showStep || showStep === "keywords"; const showCategories = !showStep || showStep === "categories"; const showCompetitorIntent = - !showStep || showStep === "competitor_intent" || showStep === "categories"; + !showStep || + showStep === "competitor_intent" || + showStep === "categories"; return (
{!showStep && (
- - Configure patterns, keywords, and content categories to detect and filter sensitive information in requests and responses. - +

+ Configure patterns, keywords, and content categories 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" - > -
- - - - + +
+
Pattern Detection
+ + Detect sensitive information using regex patterns (SSN, credit + cards, API keys, etc.) + +
+
+ +
= ({ )} {showKeywords && ( - - - Blocked Keywords - - - Block or mask specific sensitive terms and phrases - -
- } - size="small" - > -
- - - - - - + +
+
Blocked Keywords
+ + Block or mask specific sensitive terms and phrases + +
+
+ + { + const file = e.target.files?.[0]; + if (file) { + void handleFileUpload(file); + } + if (e.target) { + e.target.value = ""; + } + }} + /> +
= ({
)} - {showCompetitorIntent && - onCompetitorIntentChange && ( - - )} - - {showCategories && contentCategories.length > 0 && onContentCategoryAdd && onContentCategoryRemove && onContentCategoryUpdate && ( - )} + {showCategories && + contentCategories.length > 0 && + onContentCategoryAdd && + onContentCategoryRemove && + onContentCategoryUpdate && ( + + )} + = ({ patternAction={customPatternAction} onNameChange={setCustomPatternName} onRegexChange={setCustomPatternRegex} - onActionChange={(value) => setCustomPatternAction(value as "BLOCK" | "MASK")} + onActionChange={(value) => + setCustomPatternAction(value as "BLOCK" | "MASK") + } onAdd={handleAddCustomPattern} onCancel={() => { setCustomPatternModalVisible(false); @@ -350,7 +379,9 @@ const ContentFilterConfiguration: React.FC = ({ action={newKeywordAction} description={newKeywordDescription} onKeywordChange={setNewKeyword} - onActionChange={(value) => setNewKeywordAction(value as "BLOCK" | "MASK")} + onActionChange={(value) => + setNewKeywordAction(value as "BLOCK" | "MASK") + } onDescriptionChange={setNewKeywordDescription} onAdd={handleAddKeyword} onCancel={() => { diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.test.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.test.tsx index 76ff788afb8..3dc4428edf0 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.test.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.test.tsx @@ -99,26 +99,10 @@ vi.mock("./ContentFilterDisplay", () => ({ ), })); -vi.mock("antd", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - Divider: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - Alert: ({ - message, - type, - }: { - message: React.ReactNode; - type: string; - }) => ( -
- {message} -
- ), - }; -}); +// Note: ContentFilterManager was migrated off antd Divider/Alert to a plain +// inline
section divider and a lucide AlertTriangle banner. The tests +// below therefore assert against the semantic text of those elements rather +// than the legacy `data-testid="divider"` / `"unsaved-alert"` hooks. describe("ContentFilterManager", () => { beforeEach(() => { @@ -139,9 +123,7 @@ describe("ContentFilterManager", () => { expect(screen.getByTestId("content-filter-config")).toBeInTheDocument(); }); - expect(screen.getByTestId("divider")).toHaveTextContent( - "Content Filter Configuration" - ); + expect(screen.getByText("Content Filter Configuration")).toBeInTheDocument(); }); it("should return null when guardrail is not litellm_content_filter", () => { @@ -299,16 +281,20 @@ describe("ContentFilterManager", () => { expect(screen.getByTestId("content-filter-config")).toBeInTheDocument(); }); - expect(screen.queryByTestId("unsaved-alert")).not.toBeInTheDocument(); + expect( + screen.queryByText(/You have unsaved changes/i), + ).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: /add pattern/i })); await waitFor(() => { - expect(screen.getByTestId("unsaved-alert")).toBeInTheDocument(); + expect( + screen.getByText(/You have unsaved changes/i), + ).toBeInTheDocument(); }); - expect(screen.getByTestId("unsaved-alert")).toHaveTextContent( - /unsaved changes.*Save Changes/i + expect(screen.getByText(/You have unsaved changes/i)).toHaveTextContent( + /unsaved changes.*Save Changes/i, ); }); @@ -399,7 +385,9 @@ describe("ContentFilterManager", () => { ); await waitFor(() => { - expect(screen.getByTestId("divider")).toBeInTheDocument(); + expect( + screen.getByText("Content Filter Configuration"), + ).toBeInTheDocument(); }); expect(screen.queryByTestId("content-filter-config")).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/guardrails/custom_code/CustomCodeModal.tsx b/ui/litellm-dashboard/src/components/guardrails/custom_code/CustomCodeModal.tsx index d6e96c4d0cd..b798b70ba87 100644 --- a/ui/litellm-dashboard/src/components/guardrails/custom_code/CustomCodeModal.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/custom_code/CustomCodeModal.tsx @@ -1,15 +1,49 @@ -import React, { useState, useRef, useEffect } from "react"; -import { Modal, Select, Switch, Collapse, Input, Divider } from "antd"; -// eslint-disable-next-line litellm-ui/no-banned-ui-imports -import { Button, TextInput } from "@tremor/react"; -import { Code as CodeOutlined, PlayCircle as PlayCircleOutlined, CheckCircle2 as CheckCircleOutlined, XCircle as CloseCircleOutlined, ChevronRight as CaretRightOutlined, Save as SaveOutlined, UserRoundPlus as UsergroupAddOutlined, Upload as ExportOutlined } from "lucide-react"; -import { createGuardrailCall, updateGuardrailCall, testCustomCodeGuardrail } from "../../networking"; +import React, { useEffect, useRef, useState } from "react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { + Check as CheckCircleOutlined, + Code as CodeOutlined, + ExternalLink as ExportOutlined, + PlayCircle as PlayCircleOutlined, + Save as SaveOutlined, + UserRoundPlus as UsergroupAddOutlined, + X as CloseCircleOutlined, + X, +} from "lucide-react"; +import { + createGuardrailCall, + testCustomCodeGuardrail, + updateGuardrailCall, +} from "../../networking"; import NotificationsManager from "../../molecules/notifications_manager"; -const { Panel } = Collapse; -const { TextArea } = Input; - -// Code templates const CODE_TEMPLATES = { empty: { name: "Empty Template", @@ -85,45 +119,79 @@ const CODE_TEMPLATES = { }, }; - -// Available primitives organized by category const PRIMITIVES = { "Return Values": [ { name: "allow()", desc: "Let request/response through" }, { name: "block(reason)", desc: "Reject with message" }, - { name: "modify(texts=[], images=[], tool_calls=[])", desc: "Transform content" }, + { + 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_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" }, + { + 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_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" }, + { + 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" }, + { + 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" }, + { + 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: "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" }, + { + name: "lower(text) / upper(text) / trim(text)", + desc: "String transforms", + }, ], }; @@ -137,7 +205,6 @@ const MODE_OPTIONS = [ { value: "during_mcp_call", label: "during_mcp_call (During MCP Tool Call)" }, ]; -// Data for editing an existing guardrail export interface EditGuardrailData { guardrail_id: string; guardrail_name: string; @@ -154,10 +221,74 @@ interface CustomCodeModalProps { onClose: () => void; onSuccess: () => void; accessToken: string | null; - /** If provided, the modal will be in edit mode */ editData?: EditGuardrailData | null; } +/** + * Chip-style multi-select for Mode options. Mirrors the multi-select + * pattern used elsewhere in the migrated UI. + */ +function ModeMultiSelect({ + value, + onChange, +}: { + value: string[]; + onChange: (next: string[]) => void; +}) { + const remaining = MODE_OPTIONS.filter((o) => !value.includes(o.value)); + return ( +
+ + {value.length > 0 && ( +
+ {value.map((v) => { + const opt = MODE_OPTIONS.find((o) => o.value === v); + return ( + + {opt?.label ?? v} + + + ); + })} +
+ )} +
+ ); +} + const CustomCodeModal: React.FC = ({ visible, onClose, @@ -174,8 +305,7 @@ const CustomCodeModal: React.FC = ({ const [isSaving, setIsSaving] = useState(false); const [isTesting, setIsTesting] = useState(false); const [testExpanded, setTestExpanded] = useState(false); - - // Test input examples for pre_call and post_call + const TEST_INPUT_EXAMPLES = { pre_call: { name: "Pre-call (Request)", @@ -191,20 +321,20 @@ const CustomCodeModal: React.FC = ({ parameters: { type: "object", properties: { - location: { type: "string", description: "City name" } + location: { type: "string", description: "City name" }, }, - required: ["location"] - } - } - } + required: ["location"], + }, + }, + }, ], tool_calls: [], structured_messages: [ { role: "system", content: "You are a helpful assistant." }, - { role: "user", content: "Hello, my SSN is 123-45-6789" } + { role: "user", content: "Hello, my SSN is 123-45-6789" }, ], - model: "gpt-4" - } + model: "gpt-4", + }, }, post_call: { name: "Post-call (Response)", @@ -218,19 +348,19 @@ const CustomCodeModal: React.FC = ({ type: "function", function: { name: "get_weather", - arguments: "{\"location\": \"San Francisco\"}" - } - } + arguments: '{"location": "San Francisco"}', + }, + }, ], structured_messages: [], - model: "gpt-4" - } + model: "gpt-4", + }, }, pre_mcp_call: { name: "Pre MCP (MCP tool as OpenAI tool)", data: { texts: [ - "Tool: read_wiki_structure\nArguments: {\"repoName\": \"BerriAI/litellm\"}" + 'Tool: read_wiki_structure\nArguments: {"repoName": "BerriAI/litellm"}', ], images: [], tools: [ @@ -238,16 +368,20 @@ const CustomCodeModal: React.FC = ({ type: "function", function: { name: "read_wiki_structure", - description: "Read the structure of a GitHub repository (MCP tool passed as OpenAI tool)", + description: + "Read the structure of a GitHub repository (MCP tool passed as OpenAI tool)", parameters: { type: "object", properties: { - repoName: { type: "string", description: "Repository name, e.g. BerriAI/litellm" } + repoName: { + type: "string", + description: "Repository name, e.g. BerriAI/litellm", + }, }, - required: ["repoName"] - } - } - } + required: ["repoName"], + }, + }, + }, ], tool_calls: [ { @@ -255,50 +389,53 @@ const CustomCodeModal: React.FC = ({ type: "function", function: { name: "read_wiki_structure", - arguments: "{\"repoName\": \"BerriAI/litellm\"}" - } - } + arguments: '{"repoName": "BerriAI/litellm"}', + }, + }, ], structured_messages: [ - { role: "user", content: "Tool: read_wiki_structure\nArguments: {\"repoName\": \"BerriAI/litellm\"}" } + { + role: "user", + content: + 'Tool: read_wiki_structure\nArguments: {"repoName": "BerriAI/litellm"}', + }, ], - model: "mcp-tool-call" - } - } + model: "mcp-tool-call", + }, + }, }; - - const [testInput, setTestInput] = useState(JSON.stringify(TEST_INPUT_EXAMPLES.pre_call.data, null, 2)); + + const [testInput, setTestInput] = useState( + JSON.stringify(TEST_INPUT_EXAMPLES.pre_call.data, null, 2), + ); const [testResult, setTestResult] = useState(null); const [copiedPrimitive, setCopiedPrimitive] = useState(null); const textareaRef = useRef(null); - // Handle template change const handleTemplateChange = (templateKey: string) => { setSelectedTemplate(templateKey); - - // Check if it's a standard template - setCode(CODE_TEMPLATES[templateKey as keyof typeof CODE_TEMPLATES].code); + setCode( + CODE_TEMPLATES[templateKey as keyof typeof CODE_TEMPLATES].code, + ); }; - // Normalize mode from API (string or string[]) to string[] const normalizeMode = (m: string | string[] | undefined): string[] => { if (m === undefined || m === null) return ["pre_call"]; if (Array.isArray(m)) return m.length ? m : ["pre_call"]; return [m]; }; - // Reset form when modal opens or editData changes useEffect(() => { if (visible) { if (editData) { - // Edit mode: populate with existing data setGuardrailName(editData.guardrail_name || ""); setMode(normalizeMode(editData.litellm_params?.mode)); setDefaultOn(editData.litellm_params?.default_on || false); - setCode(editData.litellm_params?.custom_code || CODE_TEMPLATES.empty.code); - setSelectedTemplate(""); // No template selected in edit mode + setCode( + editData.litellm_params?.custom_code || CODE_TEMPLATES.empty.code, + ); + setSelectedTemplate(""); } else { - // Create mode: reset to defaults setGuardrailName(""); setMode(["pre_call"]); setDefaultOn(false); @@ -310,7 +447,6 @@ const CustomCodeModal: React.FC = ({ } }, [visible, editData]); - // Copy primitive to clipboard const copyPrimitive = async (primitive: string) => { try { await navigator.clipboard.writeText(primitive); @@ -321,14 +457,14 @@ const CustomCodeModal: React.FC = ({ } }; - // Handle tab key in textarea const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Tab") { e.preventDefault(); const textarea = e.currentTarget; const start = textarea.selectionStart; const end = textarea.selectionEnd; - const newValue = code.substring(0, start) + " " + code.substring(end); + const newValue = + code.substring(0, start) + " " + code.substring(end); setCode(newValue); setTimeout(() => { textarea.selectionStart = textarea.selectionEnd = start + 4; @@ -336,7 +472,6 @@ const CustomCodeModal: React.FC = ({ } }; - // Save guardrail (create or update) const handleSave = async () => { if (!guardrailName.trim()) { NotificationsManager.fromBackend("Please enter a guardrail name"); @@ -354,14 +489,12 @@ const CustomCodeModal: React.FC = ({ setIsSaving(true); try { if (isEditMode && editData) { - // Update existing guardrail const updateData: any = { litellm_params: { custom_code: code, }, }; - // Only include changed fields if (guardrailName !== editData.guardrail_name) { updateData.guardrail_name = guardrailName; } @@ -377,9 +510,10 @@ const CustomCodeModal: React.FC = ({ } await updateGuardrailCall(accessToken, editData.guardrail_id, updateData); - NotificationsManager.success("Custom code guardrail updated successfully"); + NotificationsManager.success( + "Custom code guardrail updated successfully", + ); } else { - // Create new guardrail const guardrailData = { guardrail_name: guardrailName, litellm_params: { @@ -392,21 +526,23 @@ const CustomCodeModal: React.FC = ({ }; await createGuardrailCall(accessToken, guardrailData); - NotificationsManager.success("Custom code guardrail created successfully"); + NotificationsManager.success( + "Custom code guardrail created successfully", + ); } onSuccess(); onClose(); } catch (error) { console.error("Failed to save guardrail:", error); NotificationsManager.fromBackend( - `Failed to ${isEditMode ? "update" : "create"} guardrail: ` + (error instanceof Error ? error.message : String(error)) + `Failed to ${isEditMode ? "update" : "create"} guardrail: ` + + (error instanceof Error ? error.message : String(error)), ); } finally { setIsSaving(false); } }; - // Test guardrail using backend endpoint const handleTest = async () => { if (!accessToken) { setTestResult({ error: "No access token available" }); @@ -417,7 +553,6 @@ const CustomCodeModal: React.FC = ({ setTestResult(null); try { - // Parse test input JSON let parsedInput; try { parsedInput = JSON.parse(testInput); @@ -427,20 +562,19 @@ const CustomCodeModal: React.FC = ({ return; } - // Ensure texts array exists if (!parsedInput.texts) { parsedInput.texts = []; } - // Use first request-like or response-like mode for test input_type const requestModes = ["pre_call", "pre_mcp_call"]; const responseModes = ["post_call", "post_mcp_call"]; - const testInputType: "request" | "response" = - mode.some((m) => requestModes.includes(m)) - ? "request" - : mode.some((m) => responseModes.includes(m)) - ? "response" - : "request"; + const testInputType: "request" | "response" = mode.some((m) => + requestModes.includes(m), + ) + ? "request" + : mode.some((m) => responseModes.includes(m)) + ? "response" + : "request"; const response = await testCustomCodeGuardrail(accessToken, { custom_code: code, @@ -465,7 +599,8 @@ const CustomCodeModal: React.FC = ({ } 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 : "Failed to test custom code", }); } finally { setIsTesting(false); @@ -475,350 +610,423 @@ const CustomCodeModal: React.FC = ({ const lineCount = code.split("\n").length; return ( - { + if (!open) onClose(); + }} > -
- {/* Header */} -
-

+ + + {isEditMode ? "Edit Custom Guardrail" : "Create Custom Guardrail"} -

-

Define custom logic using Python-like syntax

-
- - {/* Top Controls */} -
-
- - -
-
- - ( - <> - {menu} - -
{ - e.preventDefault(); - window.open('https://models.litellm.ai/guardrails', '_blank'); - }} - onMouseEnter={(e) => { - e.currentTarget.style.backgroundColor = '#f0f0f0'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = 'transparent'; - }} - > - - Browse Community templates - -
- - )} - > - - {Object.entries(CODE_TEMPLATES).map(([key, template]) => ( - - {template.name} - - ))} - - -
-
- Default On - -
-
- - {/* Main Content */} -
- {/* Code Editor */} -
-
- Python Logic - Restricted environment (no imports) -
-
- {/* Line numbers */} -
- {Array.from({ length: Math.max(lineCount, 20) }, (_, i) => ( -
{i + 1}
- ))} -
- {/* Code textarea */} -