mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-07 06:29:41 +00:00
Fixing content filter guardrail update
This commit is contained in:
@@ -200,6 +200,7 @@ export const ModelSelect = (props: ModelSelectProps) => {
|
||||
},
|
||||
]}
|
||||
mode="multiple"
|
||||
placeholder="Select Models"
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
maxTagPlaceholder={(omittedValues) => (
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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<AddGuardrailFormProps> = ({ visible, onClose, a
|
||||
label="Guardrail Name"
|
||||
rules={[{ required: true, message: "Please enter a guardrail name" }]}
|
||||
>
|
||||
<TextInput placeholder="Enter a name for this guardrail" />
|
||||
<Input placeholder="Enter a name for this guardrail" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -547,41 +546,41 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
|
||||
</div>
|
||||
</Option>
|
||||
)) || (
|
||||
<>
|
||||
<Option value="pre_call" label="pre_call">
|
||||
<div>
|
||||
<>
|
||||
<Option value="pre_call" label="pre_call">
|
||||
<div>
|
||||
<strong>pre_call</strong> <Tag color="green">Recommended</Tag>
|
||||
<div>
|
||||
<strong>pre_call</strong> <Tag color="green">Recommended</Tag>
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>{modeDescriptions.pre_call}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>{modeDescriptions.pre_call}</div>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="during_call" label="during_call">
|
||||
<div>
|
||||
</Option>
|
||||
<Option value="during_call" label="during_call">
|
||||
<div>
|
||||
<strong>during_call</strong>
|
||||
<div>
|
||||
<strong>during_call</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>{modeDescriptions.during_call}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>{modeDescriptions.during_call}</div>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="post_call" label="post_call">
|
||||
<div>
|
||||
</Option>
|
||||
<Option value="post_call" label="post_call">
|
||||
<div>
|
||||
<strong>post_call</strong>
|
||||
<div>
|
||||
<strong>post_call</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>{modeDescriptions.post_call}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>{modeDescriptions.post_call}</div>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="logging_only" label="logging_only">
|
||||
<div>
|
||||
</Option>
|
||||
<Option value="logging_only" label="logging_only">
|
||||
<div>
|
||||
<strong>logging_only</strong>
|
||||
<div>
|
||||
<strong>logging_only</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>{modeDescriptions.logging_only}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>{modeDescriptions.logging_only}</div>
|
||||
</div>
|
||||
</Option>
|
||||
</>
|
||||
)}
|
||||
</Option>
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
@@ -721,21 +720,21 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
|
||||
const renderStepButtons = () => {
|
||||
const totalSteps = shouldRenderContentFilterConfigSettings(selectedProvider) ? 4 : 2;
|
||||
const isLastStep = currentStep === totalSteps - 1;
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex justify-end space-x-2 mt-4">
|
||||
{currentStep > 0 && (
|
||||
<Button variant="secondary" onClick={prevStep}>
|
||||
<Button onClick={prevStep}>
|
||||
Previous
|
||||
</Button>
|
||||
)}
|
||||
{!isLastStep && <Button onClick={nextStep}>Next</Button>}
|
||||
{!isLastStep && <Button type="primary" onClick={nextStep}>Next</Button>}
|
||||
{isLastStep && (
|
||||
<Button onClick={handleSubmit} loading={loading}>
|
||||
<Button type="primary" onClick={handleSubmit} loading={loading}>
|
||||
Create Guardrail
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
<Button onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+39
-46
@@ -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<ContentCategoryConfigurationProps>
|
||||
width: 80,
|
||||
render: (_: any, record: SelectedCategory) => (
|
||||
<Button
|
||||
icon={DeleteOutlined}
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => onCategoryRemove(record.id)}
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
size="small"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
@@ -244,9 +241,10 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleAddCategory}
|
||||
disabled={!selectedCategoryName}
|
||||
icon={PlusOutlined}
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
@@ -308,7 +306,6 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
|
||||
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<ContentCategoryConfigurationProps>
|
||||
setExpandedYamlCategories(keyArray as string[]);
|
||||
}}
|
||||
ghost
|
||||
>
|
||||
{selectedCategories.map((category) => (
|
||||
<Panel
|
||||
header={
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<FileTextOutlined />
|
||||
<span>View YAML for {category.display_name}</span>
|
||||
</div>
|
||||
}
|
||||
key={category.category}
|
||||
>
|
||||
{loadingYaml[category.category] ? (
|
||||
<div style={{ padding: "16px", textAlign: "center", color: "#888" }}>
|
||||
Loading YAML...
|
||||
</div>
|
||||
) : categoryYaml[category.category] ? (
|
||||
<pre
|
||||
style={{
|
||||
background: "#f5f5f5",
|
||||
padding: "16px",
|
||||
borderRadius: "4px",
|
||||
overflow: "auto",
|
||||
maxHeight: "400px",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.5",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<code>{categoryYaml[category.category]}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<div style={{ padding: "16px", textAlign: "center", color: "#888" }}>
|
||||
YAML will load when expanded
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
items={selectedCategories.map((category) => ({
|
||||
key: category.category,
|
||||
label: (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<FileTextOutlined />
|
||||
<span>View YAML for {category.display_name}</span>
|
||||
</div>
|
||||
),
|
||||
children: loadingYaml[category.category] ? (
|
||||
<div style={{ padding: "16px", textAlign: "center", color: "#888" }}>
|
||||
Loading YAML...
|
||||
</div>
|
||||
) : categoryYaml[category.category] ? (
|
||||
<pre
|
||||
style={{
|
||||
background: "#f5f5f5",
|
||||
padding: "16px",
|
||||
borderRadius: "4px",
|
||||
overflow: "auto",
|
||||
maxHeight: "400px",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.5",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<code>{categoryYaml[category.category]}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<div style={{ padding: "16px", textAlign: "center", color: "#888" }}>
|
||||
YAML will load when expanded
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
+5
-6
@@ -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<ContentFilterConfigurationProps> = ({
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button type="button" onClick={() => setPatternModalVisible(true)} icon={PlusOutlined}>
|
||||
<Button type="primary" onClick={() => setPatternModalVisible(true)} icon={<PlusOutlined />}>
|
||||
Add prebuilt pattern
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setCustomPatternModalVisible(true)} variant="secondary" icon={PlusOutlined}>
|
||||
<Button onClick={() => setCustomPatternModalVisible(true)} icon={<PlusOutlined />}>
|
||||
Add custom regex
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -253,11 +252,11 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button type="button" onClick={() => setKeywordModalVisible(true)} icon={PlusOutlined}>
|
||||
<Button type="primary" onClick={() => setKeywordModalVisible(true)} icon={<PlusOutlined />}>
|
||||
Add keyword
|
||||
</Button>
|
||||
<Upload beforeUpload={handleFileUpload} accept=".yaml,.yml" showUploadList={false}>
|
||||
<Button type="button" variant="secondary" icon={UploadOutlined} loading={uploadValidating}>
|
||||
<Button icon={<UploadOutlined />} loading={uploadValidating}>
|
||||
Upload YAML file
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
+468
-37
@@ -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: () => <div data-testid="content-filter-config">Mock Content Filter Configuration</div>
|
||||
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 }[];
|
||||
}) => (
|
||||
<div data-testid="content-filter-config">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onPatternAdd({
|
||||
id: "pattern-new",
|
||||
type: "prebuilt",
|
||||
name: "ssn",
|
||||
action: "BLOCK",
|
||||
})
|
||||
}
|
||||
>
|
||||
Add pattern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onBlockedWordAdd({
|
||||
id: "word-new",
|
||||
keyword: "secret",
|
||||
action: "MASK",
|
||||
})
|
||||
}
|
||||
>
|
||||
Add keyword
|
||||
</button>
|
||||
{selectedPatterns[0] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPatternRemove(selectedPatterns[0].id)}
|
||||
>
|
||||
Remove pattern
|
||||
</button>
|
||||
)}
|
||||
{blockedWords[0] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBlockedWordRemove(blockedWords[0].id)}
|
||||
>
|
||||
Remove keyword
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./ContentFilterDisplay", () => ({
|
||||
default: () => <div data-testid="content-filter-display">Mock Content Filter Display</div>
|
||||
default: ({
|
||||
patterns,
|
||||
blockedWords,
|
||||
}: {
|
||||
patterns: { name: string }[];
|
||||
blockedWords: { keyword: string }[];
|
||||
}) => (
|
||||
<div data-testid="content-filter-display">
|
||||
<span>Patterns: {patterns.map((p) => p.name).join(", ")}</span>
|
||||
<span>Keywords: {blockedWords.map((w) => w.keyword).join(", ")}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("antd", () => ({
|
||||
Divider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
|
||||
}));
|
||||
vi.mock("antd", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("antd")>();
|
||||
return {
|
||||
...actual,
|
||||
Divider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="divider">{children}</div>
|
||||
),
|
||||
Alert: ({
|
||||
message,
|
||||
type,
|
||||
}: {
|
||||
message: React.ReactNode;
|
||||
type: string;
|
||||
}) => (
|
||||
<div data-testid="unsaved-alert" data-type={type}>
|
||||
{message}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
describe("ContentFilterManager - Unsaved Changes Detection", () => {
|
||||
describe("ContentFilterManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render when guardrail is content filter and isEditing is true", async () => {
|
||||
render(
|
||||
<ContentFilterManager
|
||||
guardrailData={CONTENT_FILTER_GUARDRAIL_DATA}
|
||||
guardrailSettings={GUARDRAIL_SETTINGS}
|
||||
isEditing={true}
|
||||
accessToken="test-token"
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ContentFilterManager
|
||||
guardrailData={guardrailData}
|
||||
guardrailSettings={GUARDRAIL_SETTINGS}
|
||||
isEditing={true}
|
||||
accessToken="test-token"
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ContentFilterManager
|
||||
guardrailData={CONTENT_FILTER_GUARDRAIL_DATA}
|
||||
guardrailSettings={GUARDRAIL_SETTINGS}
|
||||
isEditing={false}
|
||||
accessToken="test-token"
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ContentFilterManager
|
||||
guardrailData={guardrailData}
|
||||
guardrailSettings={guardrailSettings}
|
||||
guardrailData={CONTENT_FILTER_GUARDRAIL_DATA}
|
||||
guardrailSettings={GUARDRAIL_SETTINGS}
|
||||
isEditing={true}
|
||||
accessToken="test-token"
|
||||
onDataChange={mockOnDataChange}
|
||||
@@ -60,18 +197,312 @@ describe("ContentFilterManager - Unsaved Changes Detection", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ContentFilterManager
|
||||
guardrailData={CONTENT_FILTER_GUARDRAIL_DATA}
|
||||
guardrailSettings={GUARDRAIL_SETTINGS}
|
||||
isEditing={true}
|
||||
accessToken="test-token"
|
||||
onUnsavedChanges={mockOnUnsavedChanges}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ContentFilterManager
|
||||
guardrailData={CONTENT_FILTER_GUARDRAIL_DATA}
|
||||
guardrailSettings={GUARDRAIL_SETTINGS}
|
||||
isEditing={true}
|
||||
accessToken="test-token"
|
||||
onUnsavedChanges={mockOnUnsavedChanges}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ContentFilterManager
|
||||
guardrailData={CONTENT_FILTER_GUARDRAIL_DATA}
|
||||
guardrailSettings={GUARDRAIL_SETTINGS}
|
||||
isEditing={true}
|
||||
accessToken="test-token"
|
||||
onUnsavedChanges={mockOnUnsavedChanges}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ContentFilterManager
|
||||
guardrailData={CONTENT_FILTER_GUARDRAIL_DATA}
|
||||
guardrailSettings={GUARDRAIL_SETTINGS}
|
||||
isEditing={true}
|
||||
accessToken="test-token"
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ContentFilterManager
|
||||
guardrailData={CONTENT_FILTER_GUARDRAIL_DATA}
|
||||
guardrailSettings={GUARDRAIL_SETTINGS}
|
||||
isEditing={true}
|
||||
accessToken="test-token"
|
||||
onDataChange={mockOnDataChange}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ContentFilterManager
|
||||
guardrailData={CONTENT_FILTER_GUARDRAIL_DATA}
|
||||
guardrailSettings={GUARDRAIL_SETTINGS}
|
||||
isEditing={false}
|
||||
accessToken="test-token"
|
||||
onUnsavedChanges={mockOnUnsavedChanges}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ContentFilterManager
|
||||
guardrailData={guardrailData}
|
||||
guardrailSettings={GUARDRAIL_SETTINGS}
|
||||
isEditing={true}
|
||||
accessToken="test-token"
|
||||
onDataChange={mockOnDataChange}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnDataChange).toHaveBeenCalledWith([], [], []);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render ContentFilterConfiguration when guardrailSettings has no content_filter_settings", async () => {
|
||||
render(
|
||||
<ContentFilterManager
|
||||
guardrailData={CONTENT_FILTER_GUARDRAIL_DATA}
|
||||
guardrailSettings={null}
|
||||
isEditing={true}
|
||||
accessToken="test-token"
|
||||
/>
|
||||
);
|
||||
|
||||
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<typeof formatContentFilterDataForAPI>[0] = [];
|
||||
const blockedWords: Parameters<typeof formatContentFilterDataForAPI>[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<typeof formatContentFilterDataForAPI>[0] = [];
|
||||
const blockedWords: Parameters<typeof formatContentFilterDataForAPI>[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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+95
-16
@@ -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<ContentFilterManagerProps> = ({
|
||||
}) => {
|
||||
const [selectedPatterns, setSelectedPatterns] = useState<Pattern[]>([]);
|
||||
const [blockedWords, setBlockedWords] = useState<BlockedWord[]>([]);
|
||||
const [selectedContentCategories, setSelectedContentCategories] = useState<SelectedContentCategory[]>([]);
|
||||
const [originalPatterns, setOriginalPatterns] = useState<Pattern[]>([]);
|
||||
const [originalBlockedWords, setOriginalBlockedWords] = useState<BlockedWord[]>([]);
|
||||
const [originalContentCategories, setOriginalContentCategories] = useState<SelectedContentCategory[]>([]);
|
||||
|
||||
// Load data from guardrail on mount or when guardrailData changes
|
||||
useEffect(() => {
|
||||
@@ -85,21 +105,45 @@ const ContentFilterManager: React.FC<ContentFilterManagerProps> = ({
|
||||
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<ContentFilterManagerProps> = ({
|
||||
<>
|
||||
<Divider orientation="left">Content Filter Configuration</Divider>
|
||||
{hasUnsavedChanges && (
|
||||
<div className="mb-4 px-4 py-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<p className="text-sm text-yellow-800 font-medium">
|
||||
⚠️ You have unsaved changes to patterns or keywords. Remember to click "Save Changes" at the
|
||||
bottom.
|
||||
</p>
|
||||
</div>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-4"
|
||||
message={
|
||||
<Text>
|
||||
You have unsaved changes to patterns or keywords. Remember to click "Save Changes" at the
|
||||
bottom.
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
{guardrailSettings && guardrailSettings.content_filter_settings && (
|
||||
@@ -150,6 +199,19 @@ const ContentFilterManager: React.FC<ContentFilterManagerProps> = ({
|
||||
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))
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -160,8 +222,16 @@ const ContentFilterManager: React.FC<ContentFilterManagerProps> = ({
|
||||
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;
|
||||
};
|
||||
|
||||
+7
-8
@@ -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<CustomPatternModalProps> = ({
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="large">
|
||||
<div>
|
||||
<Text strong>Pattern name</Text>
|
||||
<TextInput
|
||||
<Input
|
||||
placeholder="e.g., internal_id, employee_code"
|
||||
value={patternName}
|
||||
onValueChange={onNameChange}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Regex pattern</Text>
|
||||
<TextInput
|
||||
<Input
|
||||
placeholder="e.g., ID-[0-9]{6}"
|
||||
value={patternRegex}
|
||||
onValueChange={onRegexChange}
|
||||
onChange={(e) => onRegexChange(e.target.value)}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
@@ -77,10 +76,10 @@ const CustomPatternModal: React.FC<CustomPatternModalProps> = ({
|
||||
</Space>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "24px" }}>
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
<Button onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onAdd}>
|
||||
<Button type="primary" onClick={onAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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<KeywordModalProps> = ({
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="large">
|
||||
<div>
|
||||
<Text strong>Keyword</Text>
|
||||
<TextInput
|
||||
<Input
|
||||
placeholder="Enter sensitive keyword or phrase"
|
||||
value={keyword}
|
||||
onValueChange={onKeywordChange}
|
||||
onChange={(e) => onKeywordChange(e.target.value)}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
@@ -64,10 +63,10 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
|
||||
|
||||
<div>
|
||||
<Text strong>Description (optional)</Text>
|
||||
<Textarea
|
||||
<Input.TextArea
|
||||
placeholder="Explain why this keyword is sensitive"
|
||||
value={description}
|
||||
onValueChange={onDescriptionChange}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
rows={3}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
@@ -75,10 +74,10 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
|
||||
</Space>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "24px" }}>
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
<Button onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onAdd}>
|
||||
<Button type="primary" onClick={onAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { Typography, Select, Table } from "antd";
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button } from "@tremor/react";
|
||||
import { Button, Select, Table, Typography } from "antd";
|
||||
import React from "react";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Option } = Select;
|
||||
@@ -59,11 +58,10 @@ const KeywordTable: React.FC<KeywordTableProps> = ({
|
||||
width: 100,
|
||||
render: (_: any, record: BlockedWord) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
icon={DeleteOutlined}
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => onRemove(record.id)}
|
||||
>
|
||||
Delete
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { Typography, Select, Modal, Space } from "antd";
|
||||
import { Button } from "@tremor/react";
|
||||
import { Typography, Select, Modal, Space, Button } from "antd";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Option } = Select;
|
||||
@@ -97,10 +96,10 @@ const PatternModal: React.FC<PatternModalProps> = ({
|
||||
</Space>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "24px" }}>
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
<Button onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onAdd}>
|
||||
<Button type="primary" onClick={onAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { Typography, Select, Table, Tag } from "antd";
|
||||
import { Typography, Select, Table, Tag, Button } from "antd";
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button } from "@tremor/react";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Option } = Select;
|
||||
@@ -73,11 +72,10 @@ const PatternTable: React.FC<PatternTableProps> = ({
|
||||
width: 100,
|
||||
render: (_: any, record: Pattern) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
icon={DeleteOutlined}
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => onRemove(record.id)}
|
||||
>
|
||||
Delete
|
||||
|
||||
@@ -20,16 +20,17 @@ vi.mock("./content_filter/ContentFilterManager", () => ({
|
||||
{isEditing && (
|
||||
<button onClick={() => {
|
||||
onUnsavedChanges(true);
|
||||
onDataChange?.(["new_pattern"], ["new_word"]);
|
||||
onDataChange?.(["new_pattern"], ["new_word"], []);
|
||||
}}>
|
||||
Simulate Change
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
formatContentFilterDataForAPI: (patterns: any[], blockedWords: any[]) => ({
|
||||
formatContentFilterDataForAPI: (patterns: any[], blockedWords: any[], categories?: any[]) => ({
|
||||
patterns,
|
||||
blocked_words: blockedWords,
|
||||
categories: categories ?? [],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -63,7 +64,7 @@ describe("Guardrail Info", () => {
|
||||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
|
||||
const { getAllByText, getByText } = render(
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} />,
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => { }} accessToken="123" isAdmin={true} />,
|
||||
);
|
||||
|
||||
// Wait for the loading to complete and data to be rendered
|
||||
@@ -104,7 +105,7 @@ describe("Guardrail Info", () => {
|
||||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
|
||||
const { getByText, container } = render(
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} />,
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => { }} accessToken="123" isAdmin={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -162,7 +163,7 @@ describe("Guardrail Info", () => {
|
||||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
|
||||
const { getByText } = render(
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} />,
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => { }} accessToken="123" isAdmin={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,39 +1,37 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Card,
|
||||
Title,
|
||||
Text,
|
||||
Grid,
|
||||
getGuardrailInfo,
|
||||
getGuardrailProviderSpecificParams,
|
||||
getGuardrailUISettings,
|
||||
updateGuardrailCall,
|
||||
} from "@/components/networking";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { CodeOutlined, EyeInvisibleOutlined, InfoCircleOutlined, StopOutlined } from "@ant-design/icons";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
Badge,
|
||||
Button as TremorButton,
|
||||
Card,
|
||||
Grid,
|
||||
Tab,
|
||||
TabGroup,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
TextInput,
|
||||
Text,
|
||||
Title,
|
||||
} from "@tremor/react";
|
||||
import { Button, Form, Input, Select, Divider, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined, EyeInvisibleOutlined, StopOutlined, CodeOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
getGuardrailInfo,
|
||||
updateGuardrailCall,
|
||||
getGuardrailUISettings,
|
||||
getGuardrailProviderSpecificParams,
|
||||
} from "@/components/networking";
|
||||
import { getGuardrailLogoAndName, guardrail_provider_map } from "./guardrail_info_helpers";
|
||||
import PiiConfiguration from "./pii_configuration";
|
||||
import GuardrailProviderFields from "./guardrail_provider_fields";
|
||||
import GuardrailOptionalParams from "./guardrail_optional_params";
|
||||
import { Button, Divider, Form, Input, Select, Tooltip } from "antd";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import ContentFilterManager, { formatContentFilterDataForAPI } from "./content_filter/ContentFilterManager";
|
||||
import CustomCodeModal, { EditGuardrailData } from "./custom_code/CustomCodeModal";
|
||||
import { getGuardrailLogoAndName, guardrail_provider_map } 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";
|
||||
import CustomCodeModal, { EditGuardrailData } from "./custom_code/CustomCodeModal";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/outline";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
|
||||
export interface GuardrailInfoProps {
|
||||
guardrailId: string;
|
||||
@@ -83,6 +81,12 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
||||
}>;
|
||||
pattern_categories: string[];
|
||||
supported_actions: string[];
|
||||
content_categories?: Array<{
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
default_action: string;
|
||||
}>;
|
||||
};
|
||||
} | null>(null);
|
||||
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
|
||||
@@ -98,14 +102,19 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
||||
const [customCodeModalVisible, setCustomCodeModalVisible] = useState(false);
|
||||
|
||||
// Content Filter data ref (managed by ContentFilterManager)
|
||||
const contentFilterDataRef = React.useRef<{ patterns: any[]; blockedWords: any[] }>({
|
||||
const contentFilterDataRef = React.useRef<{
|
||||
patterns: any[];
|
||||
blockedWords: any[];
|
||||
categories: any[];
|
||||
}>({
|
||||
patterns: [],
|
||||
blockedWords: [],
|
||||
categories: [],
|
||||
});
|
||||
|
||||
// Memoize onDataChange callback to prevent unnecessary re-renders
|
||||
const handleContentFilterDataChange = useCallback((patterns: any[], blockedWords: any[]) => {
|
||||
contentFilterDataRef.current = { patterns, blockedWords };
|
||||
const handleContentFilterDataChange = useCallback((patterns: any[], blockedWords: any[], categories: any[]) => {
|
||||
contentFilterDataRef.current = { patterns, blockedWords, categories: categories || [] };
|
||||
}, []);
|
||||
|
||||
const fetchGuardrailInfo = async () => {
|
||||
@@ -274,16 +283,15 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
||||
|
||||
// Only add Content Filter patterns if there are changes
|
||||
if (guardrailData.litellm_params?.guardrail === "litellm_content_filter" && hasUnsavedContentFilterChanges) {
|
||||
const originalPatterns = guardrailData.litellm_params?.patterns || [];
|
||||
const originalBlockedWords = guardrailData.litellm_params?.blocked_words || [];
|
||||
|
||||
const formattedData = formatContentFilterDataForAPI(
|
||||
contentFilterDataRef.current.patterns || [],
|
||||
contentFilterDataRef.current.blockedWords || [],
|
||||
contentFilterDataRef.current.categories || [],
|
||||
);
|
||||
|
||||
updateData.litellm_params.patterns = formattedData.patterns;
|
||||
updateData.litellm_params.blocked_words = formattedData.blocked_words;
|
||||
updateData.litellm_params.categories = formattedData.categories;
|
||||
}
|
||||
|
||||
if (guardrailData.litellm_params?.guardrail === "tool_permission") {
|
||||
@@ -354,7 +362,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
||||
|
||||
console.log("allowedParams: ", allowedParams);
|
||||
allowedParams.forEach((paramName) => {
|
||||
if (paramName === "patterns" || paramName === "blocked_words") {
|
||||
if (paramName === "patterns" || paramName === "blocked_words" || paramName === "categories") {
|
||||
return;
|
||||
}
|
||||
// Check for both direct parameter name and nested optional_params object
|
||||
@@ -438,9 +446,9 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div>
|
||||
<TremorButton icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
|
||||
<Button type="text" icon={<ArrowLeftIcon className="w-4 h-4" />} onClick={onClose} className="mb-4">
|
||||
Back to Guardrails
|
||||
</TremorButton>
|
||||
</Button>
|
||||
<Title>{guardrailData.guardrail_name || "Unnamed Guardrail"}</Title>
|
||||
<div className="flex items-center cursor-pointer">
|
||||
<Text className="text-gray-500 font-mono">{guardrailData.guardrail_id}</Text>
|
||||
@@ -450,11 +458,10 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
||||
size="small"
|
||||
icon={copiedStates["guardrail-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||||
onClick={() => copyToClipboard(guardrailData.guardrail_id, "guardrail-id")}
|
||||
className={`left-2 z-10 transition-all duration-200 ${
|
||||
copiedStates["guardrail-id"]
|
||||
? "text-green-600 bg-green-50 border-green-200"
|
||||
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
className={`left-2 z-10 transition-all duration-200 ${copiedStates["guardrail-id"]
|
||||
? "text-green-600 bg-green-50 border-green-200"
|
||||
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -533,9 +540,8 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
||||
<Text className="flex-1 font-medium text-gray-900">{key}</Text>
|
||||
<Text className="flex-1">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 ${
|
||||
value === "MASK" ? "text-blue-600" : "text-red-600"
|
||||
}`}
|
||||
className={`inline-flex items-center gap-1.5 ${value === "MASK" ? "text-blue-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{value === "MASK" ? <EyeInvisibleOutlined /> : <StopOutlined />}
|
||||
{String(value)}
|
||||
@@ -563,14 +569,13 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
||||
<Text className="font-medium text-lg">Custom Code</Text>
|
||||
</div>
|
||||
{isAdmin && !isConfigGuardrail && (
|
||||
<TremorButton
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
icon={CodeOutlined}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CodeOutlined />}
|
||||
onClick={() => setCustomCodeModalVisible(true)}
|
||||
>
|
||||
Edit Code
|
||||
</TremorButton>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative rounded-lg overflow-hidden border border-gray-700 bg-[#1e1e1e]">
|
||||
@@ -603,14 +608,14 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
||||
)}
|
||||
{!isEditing && !isConfigGuardrail && (
|
||||
guardrailData.litellm_params?.guardrail === "custom_code" ? (
|
||||
<TremorButton
|
||||
icon={CodeOutlined}
|
||||
<Button
|
||||
icon={<CodeOutlined />}
|
||||
onClick={() => setCustomCodeModalVisible(true)}
|
||||
>
|
||||
Edit Code
|
||||
</TremorButton>
|
||||
</Button>
|
||||
) : (
|
||||
<TremorButton onClick={() => setIsEditing(true)}>Edit Settings</TremorButton>
|
||||
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -637,7 +642,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
||||
name="guardrail_name"
|
||||
rules={[{ required: true, message: "Please input a guardrail name" }]}
|
||||
>
|
||||
<TextInput />
|
||||
<Input placeholder="Enter guardrail name" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Default On" name="default_on">
|
||||
@@ -675,7 +680,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
||||
onUnsavedChanges={setHasUnsavedContentFilterChanges}
|
||||
/>
|
||||
|
||||
<Divider orientation="left">Provider Settings</Divider>
|
||||
{(guardrailData.litellm_params?.guardrail === "tool_permission" || guardrailProviderSpecificParams) && <Divider orientation="left">Provider Settings</Divider>}
|
||||
|
||||
{guardrailData.litellm_params?.guardrail === "tool_permission" ? (
|
||||
<ToolPermissionRulesEditor
|
||||
@@ -735,7 +740,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TremorButton>Save Changes</TremorButton>
|
||||
<Button type="primary" htmlType="submit">Save Changes</Button>
|
||||
</div>
|
||||
</Form>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { Form, Select, Typography } from "antd";
|
||||
import { TextInput } from "@tremor/react";
|
||||
import { Form, Select, Typography, Input, Button } from "antd";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
|
||||
const { Title } = Typography;
|
||||
@@ -95,17 +94,18 @@ const DictField: React.FC<DictFieldProps> = ({ field, fieldKey, fullFieldKey, va
|
||||
<Select.Option value={false}>False</Select.Option>
|
||||
</Select>
|
||||
) : (
|
||||
<TextInput placeholder={`Enter ${entry.key} value`} type="text" />
|
||||
<Input placeholder={`Enter ${entry.key} value`} />
|
||||
)}
|
||||
</Form.Item>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-red-500 hover:text-red-700 text-sm"
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
onClick={() => removeEntry(entry.id, entry.key)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -198,9 +198,9 @@ const GuardrailOptionalParams: React.FC<GuardrailOptionalParamsProps> = ({
|
||||
) : field.type === "number" ? (
|
||||
<NumericalInput step={1} width={400} placeholder={field.description} />
|
||||
) : fieldKey.includes("password") || fieldKey.includes("secret") || fieldKey.includes("key") ? (
|
||||
<TextInput placeholder={field.description} type="password" />
|
||||
<Input.Password placeholder={field.description} />
|
||||
) : (
|
||||
<TextInput placeholder={field.description} type="text" />
|
||||
<Input placeholder={field.description} />
|
||||
)}
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Form, Select, Spin } from "antd";
|
||||
import { TextInput } from "@tremor/react";
|
||||
import { Form, Select, Spin, Input } from "antd";
|
||||
import {
|
||||
guardrail_provider_map,
|
||||
populateGuardrailProviders,
|
||||
@@ -195,9 +194,9 @@ const GuardrailProviderFields: React.FC<GuardrailProviderFieldsProps> = ({
|
||||
defaultValue={fieldValue !== undefined ? Number(fieldValue) : undefined}
|
||||
/>
|
||||
) : fieldKey.includes("password") || fieldKey.includes("secret") || fieldKey.includes("key") ? (
|
||||
<TextInput placeholder={field.description} type="password" defaultValue={fieldValue || ""} />
|
||||
<Input.Password placeholder={field.description} defaultValue={fieldValue || ""} />
|
||||
) : (
|
||||
<TextInput placeholder={field.description} type="text" defaultValue={fieldValue || ""} />
|
||||
<Input placeholder={field.description} defaultValue={fieldValue || ""} />
|
||||
)}
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user