diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx index 2b7399c456..74b2619f7f 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx @@ -200,6 +200,7 @@ export const ModelSelect = (props: ModelSelectProps) => { }, ]} mode="multiple" + placeholder="Select Models" allowClear maxTagCount="responsive" maxTagPlaceholder={(omittedValues) => ( diff --git a/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx b/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx index 71a3726398..8c7c15bc5a 100644 --- a/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx +++ b/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx @@ -6,6 +6,7 @@ import UsageIndicator from "./UsageIndicator"; vi.mock("./networking", () => ({ getRemainingUsers: vi.fn(), + getLicenseInfo: vi.fn().mockResolvedValue(null), })); vi.mock("@/app/(dashboard)/hooks/useDisableUsageIndicator", () => ({ 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 f4bfa30481..71b61904dd 100644 --- a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx @@ -1,21 +1,20 @@ -import React, { useState, useEffect, useMemo } from "react"; -import { Form, Typography, Select, Modal, Tag, Steps } from "antd"; -import { Button, TextInput } from "@tremor/react"; -import { - guardrail_provider_map, - shouldRenderPIIConfigSettings, - shouldRenderContentFilterConfigSettings, - guardrailLogoMap, - populateGuardrailProviders, - populateGuardrailProviderMap, - getGuardrailProviders, -} from "./guardrail_info_helpers"; -import { createGuardrailCall, getGuardrailUISettings, getGuardrailProviderSpecificParams } from "../networking"; -import PiiConfiguration from "./pii_configuration"; -import GuardrailProviderFields from "./guardrail_provider_fields"; -import GuardrailOptionalParams from "./guardrail_optional_params"; +import { Button, Form, Input, Modal, Select, Steps, Tag, Typography } from "antd"; +import React, { useEffect, useMemo, useState } from "react"; import NotificationsManager from "../molecules/notifications_manager"; +import { createGuardrailCall, getGuardrailProviderSpecificParams, getGuardrailUISettings } from "../networking"; import ContentFilterConfiguration from "./content_filter/ContentFilterConfiguration"; +import { + getGuardrailProviders, + guardrail_provider_map, + guardrailLogoMap, + populateGuardrailProviderMap, + populateGuardrailProviders, + shouldRenderContentFilterConfigSettings, + shouldRenderPIIConfigSettings, +} from "./guardrail_info_helpers"; +import GuardrailOptionalParams from "./guardrail_optional_params"; +import GuardrailProviderFields from "./guardrail_provider_fields"; +import PiiConfiguration from "./pii_configuration"; import ToolPermissionRulesEditor, { ToolPermissionConfig, } from "./tool_permission/ToolPermissionRulesEditor"; @@ -457,7 +456,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a label="Guardrail Name" rules={[{ required: true, message: "Please enter a guardrail name" }]} > - + = ({ visible, onClose, a )) || ( - <> - - - - - - )} + + + )} @@ -721,21 +720,21 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a const renderStepButtons = () => { const totalSteps = shouldRenderContentFilterConfigSettings(selectedProvider) ? 4 : 2; const isLastStep = currentStep === totalSteps - 1; - + return (
{currentStep > 0 && ( - )} - {!isLastStep && } + {!isLastStep && } {isLastStep && ( - )} -
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 7c8652a7b1..3b0b0acce8 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentCategoryConfiguration.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentCategoryConfiguration.tsx @@ -1,12 +1,10 @@ import React from "react"; -import { Card, Typography, Select, Table, Tag, Collapse } from "antd"; +import { Card, Typography, Select, Table, Tag, Collapse, Button } from "antd"; import { DeleteOutlined, PlusOutlined, FileTextOutlined } from "@ant-design/icons"; -import { Button } from "@tremor/react"; import { getCategoryYaml } from "../../networking"; const { Title, Text } = Typography; const { Option } = Select; -const { Panel } = Collapse; interface ContentCategory { name: string; @@ -191,10 +189,9 @@ const ContentCategoryConfiguration: React.FC width: 80, render: (_: any, record: SelectedCategory) => ( @@ -244,9 +241,10 @@ const ContentCategoryConfiguration: React.FC ))} @@ -308,7 +306,6 @@ const ContentCategoryConfiguration: React.FC activeKey={expandedYamlCategories} onChange={(keys) => { const keyArray = Array.isArray(keys) ? keys : keys ? [keys] : []; - const newExpanded = new Set(keyArray as string[]); const oldExpanded = new Set(expandedYamlCategories); // Find newly expanded categories and fetch their YAML @@ -322,44 +319,40 @@ const ContentCategoryConfiguration: React.FC setExpandedYamlCategories(keyArray as string[]); }} ghost - > - {selectedCategories.map((category) => ( - - - View YAML for {category.display_name} - - } - key={category.category} - > - {loadingYaml[category.category] ? ( -
- Loading YAML... -
- ) : categoryYaml[category.category] ? ( -
-                      {categoryYaml[category.category]}
-                    
- ) : ( -
- YAML will load when expanded -
- )} -
- ))} - + items={selectedCategories.map((category) => ({ + key: category.category, + label: ( +
+ + View YAML for {category.display_name} +
+ ), + children: loadingYaml[category.category] ? ( +
+ Loading YAML... +
+ ) : categoryYaml[category.category] ? ( +
+                    {categoryYaml[category.category]}
+                  
+ ) : ( +
+ YAML will load when expanded +
+ ), + }))} + /> ) : ( 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 bae95aac6c..882abc0b93 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx @@ -1,7 +1,6 @@ import React, { useState } from "react"; -import { Typography, Space, Upload, Card } from "antd"; +import { Typography, Space, Upload, Card, Button } 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"; @@ -221,10 +220,10 @@ const ContentFilterConfiguration: React.FC = ({ >
- - @@ -253,11 +252,11 @@ const ContentFilterConfiguration: React.FC = ({ >
- - 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 6d879e1c54..cbd7033237 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 @@ -1,58 +1,195 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; -import ContentFilterManager from "./ContentFilterManager"; +import userEvent from "@testing-library/user-event"; +import ContentFilterManager, { + formatContentFilterDataForAPI, +} from "./ContentFilterManager"; import React from "react"; +const CONTENT_FILTER_GUARDRAIL_DATA = { + litellm_params: { + guardrail: "litellm_content_filter", + patterns: [{ pattern_type: "prebuilt", pattern_name: "email", action: "BLOCK" }], + blocked_words: [{ keyword: "test", action: "BLOCK", description: null }], + }, +}; + +const GUARDRAIL_SETTINGS = { + content_filter_settings: { + prebuilt_patterns: [], + pattern_categories: ["PII"], + supported_actions: ["BLOCK", "MASK"], + }, +}; + vi.mock("./ContentFilterConfiguration", () => ({ - default: () =>
Mock Content Filter Configuration
+ default: ({ + onPatternAdd, + onPatternRemove, + onBlockedWordAdd, + onBlockedWordRemove, + selectedPatterns, + blockedWords, + }: { + onPatternAdd: (p: object) => void; + onPatternRemove: (id: string) => void; + onBlockedWordAdd: (w: object) => void; + onBlockedWordRemove: (id: string) => void; + selectedPatterns: { id: string }[]; + blockedWords: { id: string }[]; + }) => ( +
+ + + {selectedPatterns[0] && ( + + )} + {blockedWords[0] && ( + + )} +
+ ), })); vi.mock("./ContentFilterDisplay", () => ({ - default: () =>
Mock Content Filter Display
+ default: ({ + patterns, + blockedWords, + }: { + patterns: { name: string }[]; + blockedWords: { keyword: string }[]; + }) => ( +
+ Patterns: {patterns.map((p) => p.name).join(", ")} + Keywords: {blockedWords.map((w) => w.keyword).join(", ")} +
+ ), })); -vi.mock("antd", () => ({ - Divider: ({ children }: { children: React.ReactNode }) =>
{children}
-})); +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} +
+ ), + }; +}); -describe("ContentFilterManager - Unsaved Changes Detection", () => { +describe("ContentFilterManager", () => { beforeEach(() => { vi.clearAllMocks(); }); + it("should render when guardrail is content filter and isEditing is true", async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId("content-filter-config")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("divider")).toHaveTextContent( + "Content Filter Configuration" + ); + }); + + it("should return null when guardrail is not litellm_content_filter", () => { + const guardrailData = { + litellm_params: { guardrail: "presidio" }, + }; + + const { container } = render( + + ); + + expect(screen.queryByTestId("content-filter-config")).not.toBeInTheDocument(); + expect(screen.queryByTestId("content-filter-display")).not.toBeInTheDocument(); + expect(container.firstChild).toBeNull(); + }); + + it("should render read-only display when isEditing is false", async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId("content-filter-display")).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("content-filter-config")).not.toBeInTheDocument(); + expect(screen.getByText(/email/)).toBeInTheDocument(); + expect(screen.getByText(/test/)).toBeInTheDocument(); + }); + it("should call onUnsavedChanges with false when component initializes with matching data", async () => { - /** - * Tests that the ContentFilterManager correctly initializes the unsaved changes - * detection and calls onUnsavedChanges(false) when the current state matches - * the original loaded state (no changes yet). - */ const mockOnUnsavedChanges = vi.fn(); const mockOnDataChange = vi.fn(); - const guardrailData = { - litellm_params: { - guardrail: "litellm_content_filter", - patterns: [ - { pattern_type: "prebuilt", pattern_name: "email", action: "BLOCK" } - ], - blocked_words: [ - { keyword: "test", action: "BLOCK", description: null } - ] - } - }; - - const guardrailSettings = { - content_filter_settings: { - prebuilt_patterns: [], - pattern_categories: ["PII"], - supported_actions: ["BLOCK", "MASK"] - } - }; - render( { /> ); - // Wait for component to render in edit mode await waitFor(() => { expect(screen.getByTestId("content-filter-config")).toBeInTheDocument(); }); - // Verify onUnsavedChanges was called with false (no changes initially) await waitFor(() => { expect(mockOnUnsavedChanges).toHaveBeenCalledWith(false); }); - // Verify onDataChange was called with initial data expect(mockOnDataChange).toHaveBeenCalled(); }); + + it("should call onUnsavedChanges with true when user adds a pattern", async () => { + const mockOnUnsavedChanges = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId("content-filter-config")).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /add pattern/i })); + + await waitFor(() => { + expect(mockOnUnsavedChanges).toHaveBeenCalledWith(true); + }); + }); + + it("should call onUnsavedChanges with true when user adds a keyword", async () => { + const mockOnUnsavedChanges = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId("content-filter-config")).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /add keyword/i })); + + await waitFor(() => { + expect(mockOnUnsavedChanges).toHaveBeenCalledWith(true); + }); + }); + + it("should call onUnsavedChanges with true when user removes a pattern", async () => { + const mockOnUnsavedChanges = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /remove pattern/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /remove pattern/i })); + + await waitFor(() => { + expect(mockOnUnsavedChanges).toHaveBeenCalledWith(true); + }); + }); + + it("should show unsaved changes alert when data has changed", async () => { + const user = userEvent.setup(); + + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId("content-filter-config")).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("unsaved-alert")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /add pattern/i })); + + await waitFor(() => { + expect(screen.getByTestId("unsaved-alert")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("unsaved-alert")).toHaveTextContent( + /unsaved changes.*Save Changes/i + ); + }); + + it("should call onDataChange when patterns or keywords change", async () => { + const mockOnDataChange = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + await waitFor(() => { + expect(mockOnDataChange).toHaveBeenCalled(); + }); + + const initialCalls = mockOnDataChange.mock.calls.length; + await user.click(screen.getByRole("button", { name: /add keyword/i })); + + await waitFor(() => { + expect(mockOnDataChange.mock.calls.length).toBeGreaterThan(initialCalls); + }); + + const lastCall = mockOnDataChange.mock.calls[mockOnDataChange.mock.calls.length - 1]; + const blockedWords = lastCall[1]; + expect(blockedWords).toContainEqual( + expect.objectContaining({ keyword: "secret", action: "MASK" }) + ); + }); + + it("should not call onUnsavedChanges when isEditing is false", async () => { + const mockOnUnsavedChanges = vi.fn(); + + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId("content-filter-display")).toBeInTheDocument(); + }); + + expect(mockOnUnsavedChanges).not.toHaveBeenCalled(); + }); + + it("should initialize with empty data when guardrailData has no patterns or blocked_words", async () => { + const guardrailData = { + litellm_params: { + guardrail: "litellm_content_filter", + }, + }; + + const mockOnDataChange = vi.fn(); + + render( + + ); + + await waitFor(() => { + expect(mockOnDataChange).toHaveBeenCalledWith([], [], []); + }); + }); + + it("should not render ContentFilterConfiguration when guardrailSettings has no content_filter_settings", async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId("divider")).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("content-filter-config")).not.toBeInTheDocument(); + }); }); +describe("formatContentFilterDataForAPI", () => { + it("should format patterns and blocked words for API", () => { + const patterns = [ + { + id: "p1", + type: "prebuilt" as const, + name: "email", + action: "BLOCK" as const, + }, + { + id: "p2", + type: "custom" as const, + name: "custom", + pattern: "\\d+", + action: "MASK" as const, + }, + ]; + const blockedWords = [ + { + id: "w1", + keyword: "secret", + action: "MASK" as const, + description: "-sensitive", + }, + ]; + + const result = formatContentFilterDataForAPI(patterns, blockedWords); + + expect(result.patterns).toEqual([ + { + pattern_type: "prebuilt", + pattern_name: "email", + pattern: undefined, + name: "email", + action: "BLOCK", + }, + { + pattern_type: "regex", + pattern_name: undefined, + pattern: "\\d+", + name: "custom", + action: "MASK", + }, + ]); + expect(result.blocked_words).toEqual([ + { keyword: "secret", action: "MASK", description: "-sensitive" }, + ]); + expect(result.categories).toBeUndefined(); + }); + + it("should include categories when provided", () => { + const patterns: Parameters[0] = []; + const blockedWords: Parameters[1] = []; + const categories = [ + { + id: "c1", + category: "PII", + display_name: "PII", + action: "BLOCK" as const, + severity_threshold: "high" as const, + }, + ]; + + const result = formatContentFilterDataForAPI(patterns, blockedWords, categories); + + expect(result.categories).toEqual([ + { + category: "PII", + enabled: true, + action: "BLOCK", + severity_threshold: "high", + }, + ]); + }); + + it("should use medium as default severity_threshold when category has none", () => { + const patterns: Parameters[0] = []; + const blockedWords: Parameters[1] = []; + const categories = [ + { + id: "c1", + category: "PII", + display_name: "PII", + action: "MASK" as const, + severity_threshold: undefined as unknown as "high" | "medium" | "low", + }, + ]; + + const result = formatContentFilterDataForAPI(patterns, blockedWords, categories); + + expect(result.categories).toEqual([ + { + category: "PII", + enabled: true, + action: "MASK", + severity_threshold: "medium", + }, + ]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.tsx index 3fac7e1139..aa23c0e1db 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.tsx @@ -1,8 +1,10 @@ -import React, { useState, useEffect } from "react"; -import { Divider } from "antd"; +import { Alert, Divider, Typography } from "antd"; +import React, { useEffect, useState } from "react"; import ContentFilterConfiguration from "./ContentFilterConfiguration"; import ContentFilterDisplay from "./ContentFilterDisplay"; +const { Text } = Typography + interface Pattern { id: string; type: "prebuilt" | "custom"; @@ -19,6 +21,21 @@ interface BlockedWord { description?: string; } +interface SelectedContentCategory { + id: string; + category: string; + display_name: string; + action: "BLOCK" | "MASK"; + severity_threshold: "high" | "medium" | "low"; +} + +interface ContentCategory { + name: string; + display_name: string; + description: string; + default_action: string; +} + interface GuardrailSettings { content_filter_settings?: { prebuilt_patterns: Array<{ @@ -29,6 +46,7 @@ interface GuardrailSettings { }>; pattern_categories: string[]; supported_actions: string[]; + content_categories?: ContentCategory[]; }; } @@ -37,7 +55,7 @@ interface ContentFilterManagerProps { guardrailSettings: GuardrailSettings | null; isEditing: boolean; accessToken: string | null; - onDataChange?: (patterns: Pattern[], blockedWords: BlockedWord[]) => void; + onDataChange?: (patterns: Pattern[], blockedWords: BlockedWord[], categories: SelectedContentCategory[]) => void; onUnsavedChanges?: (hasChanges: boolean) => void; } @@ -51,8 +69,10 @@ const ContentFilterManager: React.FC = ({ }) => { const [selectedPatterns, setSelectedPatterns] = useState([]); const [blockedWords, setBlockedWords] = useState([]); + const [selectedContentCategories, setSelectedContentCategories] = useState([]); const [originalPatterns, setOriginalPatterns] = useState([]); const [originalBlockedWords, setOriginalBlockedWords] = useState([]); + const [originalContentCategories, setOriginalContentCategories] = useState([]); // Load data from guardrail on mount or when guardrailData changes useEffect(() => { @@ -85,21 +105,45 @@ const ContentFilterManager: React.FC = ({ setBlockedWords([]); setOriginalBlockedWords([]); } - }, [guardrailData]); + + if (guardrailData?.litellm_params?.categories?.length > 0) { + const contentCategoriesMap = guardrailSettings?.content_filter_settings?.content_categories + ? Object.fromEntries( + guardrailSettings.content_filter_settings.content_categories.map((c) => [c.name, c]) + ) + : {}; + const categories = guardrailData.litellm_params.categories.map((c: any, index: number) => { + const meta = contentCategoriesMap[c.category]; + return { + id: `category-${index}`, + category: c.category, + display_name: meta?.display_name ?? c.category, + action: (c.action || "BLOCK") as "BLOCK" | "MASK", + severity_threshold: (c.severity_threshold || "medium") as "high" | "medium" | "low", + }; + }); + setSelectedContentCategories(categories); + setOriginalContentCategories(categories); + } else { + setSelectedContentCategories([]); + setOriginalContentCategories([]); + } + }, [guardrailData, guardrailSettings?.content_filter_settings?.content_categories]); // Notify parent component when data changes useEffect(() => { if (onDataChange) { - onDataChange(selectedPatterns, blockedWords); + onDataChange(selectedPatterns, blockedWords, selectedContentCategories); } - }, [selectedPatterns, blockedWords, onDataChange]); + }, [selectedPatterns, blockedWords, selectedContentCategories, onDataChange]); // Detect unsaved changes const hasUnsavedChanges = React.useMemo(() => { const hasPatternChanges = JSON.stringify(selectedPatterns) !== JSON.stringify(originalPatterns); const hasWordChanges = JSON.stringify(blockedWords) !== JSON.stringify(originalBlockedWords); - return hasPatternChanges || hasWordChanges; - }, [selectedPatterns, blockedWords, originalPatterns, originalBlockedWords]); + const hasCategoryChanges = JSON.stringify(selectedContentCategories) !== JSON.stringify(originalContentCategories); + return hasPatternChanges || hasWordChanges || hasCategoryChanges; + }, [selectedPatterns, blockedWords, selectedContentCategories, originalPatterns, originalBlockedWords, originalContentCategories]); useEffect(() => { if (isEditing && onUnsavedChanges) { @@ -122,12 +166,17 @@ const ContentFilterManager: React.FC = ({ <> Content Filter Configuration {hasUnsavedChanges && ( -
-

- ⚠️ You have unsaved changes to patterns or keywords. Remember to click "Save Changes" at the - bottom. -

-
+ + You have unsaved changes to patterns or keywords. Remember to click "Save Changes" at the + bottom. + + } + /> )}
{guardrailSettings && guardrailSettings.content_filter_settings && ( @@ -150,6 +199,19 @@ const ContentFilterManager: React.FC = ({ console.log("File uploaded:", content); }} accessToken={accessToken} + contentCategories={guardrailSettings.content_filter_settings.content_categories || []} + selectedContentCategories={selectedContentCategories} + onContentCategoryAdd={(category) => + setSelectedContentCategories([...selectedContentCategories, category]) + } + onContentCategoryRemove={(id) => + setSelectedContentCategories(selectedContentCategories.filter((c) => c.id !== id)) + } + onContentCategoryUpdate={(id, field, value) => + setSelectedContentCategories( + selectedContentCategories.map((c) => (c.id === id ? { ...c, [field]: value } : c)) + ) + } /> )}
@@ -160,8 +222,16 @@ const ContentFilterManager: React.FC = ({ export default ContentFilterManager; // Helper function to format data for API -export const formatContentFilterDataForAPI = (patterns: Pattern[], blockedWords: BlockedWord[]) => { - return { +export const formatContentFilterDataForAPI = ( + patterns: Pattern[], + blockedWords: BlockedWord[], + categories?: SelectedContentCategory[] +) => { + const result: { + patterns: any[]; + blocked_words: any[]; + categories?: any[]; + } = { patterns: patterns.map((p) => ({ pattern_type: p.type === "prebuilt" ? "prebuilt" : "regex", pattern_name: p.type === "prebuilt" ? p.name : undefined, @@ -175,4 +245,13 @@ export const formatContentFilterDataForAPI = (patterns: Pattern[], blockedWords: description: w.description, })), }; + if (categories !== undefined) { + result.categories = categories.map((c) => ({ + category: c.category, + enabled: true, + action: c.action, + severity_threshold: c.severity_threshold || "medium", + })); + } + return result; }; diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.tsx index c69a7a0e33..f88e5db59a 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.tsx @@ -1,6 +1,5 @@ import React from "react"; -import { Typography, Select, Modal, Space } from "antd"; -import { Button, TextInput } from "@tremor/react"; +import { Typography, Select, Modal, Space, Button, Input } from "antd"; const { Text } = Typography; const { Option } = Select; @@ -39,20 +38,20 @@ const CustomPatternModal: React.FC = ({
Pattern name - onNameChange(e.target.value)} style={{ marginTop: 8 }} />
Regex pattern - onRegexChange(e.target.value)} style={{ marginTop: 8 }} /> @@ -77,10 +76,10 @@ const CustomPatternModal: React.FC = ({
- -
diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/KeywordModal.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/KeywordModal.tsx index 2a57a3d0db..8bdbfb326d 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/KeywordModal.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/KeywordModal.tsx @@ -1,6 +1,5 @@ import React from "react"; -import { Typography, Select, Modal, Space } from "antd"; -import { Button, TextInput, Textarea } from "@tremor/react"; +import { Typography, Select, Modal, Space, Button, Input } from "antd"; const { Text } = Typography; const { Option } = Select; @@ -39,10 +38,10 @@ const KeywordModal: React.FC = ({
Keyword - onKeywordChange(e.target.value)} style={{ marginTop: 8 }} />
@@ -64,10 +63,10 @@ const KeywordModal: React.FC = ({
Description (optional) -