[UI] Feature - Add Apply Guardrail Testing Playground (#16030)

* add applyGuardrail endpoints

* v0 testing apply guard

* fix: use tabs

* move apply guardrails endpoint

* fix apply_guardrail

* fix applyGuardrail

* fix apply guardrail for bedrock

* test guard endpoints

* add tooltip for enter button

* refactor

* add guardrail test

* tests guardrails selector

* TestNomaApplyGuardrail
This commit is contained in:
Ishaan Jaff
2025-10-28 16:41:17 -07:00
committed by GitHub
parent 5c375b23ae
commit 95dd216150
14 changed files with 1322 additions and 106 deletions
@@ -1,43 +0,0 @@
"""
Enterprise Guardrail Routes on LiteLLM Proxy
To see all free guardrails see litellm/proxy/guardrails/*
Exposed Routes:
- /mask_pii
"""
from typing import Optional
from fastapi import APIRouter, Depends
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.guardrails.guardrail_endpoints import GUARDRAIL_REGISTRY
from litellm.types.guardrails import ApplyGuardrailRequest, ApplyGuardrailResponse
router = APIRouter(tags=["guardrails"], prefix="/guardrails")
@router.post("/apply_guardrail", response_model=ApplyGuardrailResponse)
async def apply_guardrail(
request: ApplyGuardrailRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Mask PII from a given text, requires a guardrail to be added to litellm.
"""
active_guardrail: Optional[
CustomGuardrail
] = GUARDRAIL_REGISTRY.get_initialized_guardrail_callback(
guardrail_name=request.guardrail_name
)
if active_guardrail is None:
raise Exception(f"Guardrail {request.guardrail_name} not found")
response_text = await active_guardrail.apply_guardrail(
text=request.text, language=request.language, entities=request.entities
)
return ApplyGuardrailResponse(response_text=response_text)
@@ -10,10 +10,14 @@ from pydantic import BaseModel
from litellm._logging import verbose_proxy_logger
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.guardrails.guardrail_registry import GuardrailRegistry
from litellm.types.guardrails import (
PII_ENTITY_CATEGORIES_MAP,
ApplyGuardrailRequest,
ApplyGuardrailResponse,
BedrockGuardrailConfigModel,
Guardrail,
GuardrailEventHooks,
@@ -1056,3 +1060,37 @@ async def get_provider_specific_params():
provider_params[guardrail_name] = fields
return provider_params
@router.post("/guardrails/apply_guardrail", response_model=ApplyGuardrailResponse)
@router.post("/apply_guardrail", response_model=ApplyGuardrailResponse)
async def apply_guardrail(
request: ApplyGuardrailRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Apply a guardrail to text input and return the processed result.
This endpoint allows testing guardrails by applying them to custom text inputs.
"""
from litellm.proxy.utils import handle_exception_on_proxy
try:
active_guardrail: Optional[
CustomGuardrail
] = GUARDRAIL_REGISTRY.get_initialized_guardrail_callback(
guardrail_name=request.guardrail_name
)
if active_guardrail is None:
raise HTTPException(
status_code=404,
detail=f"Guardrail '{request.guardrail_name}' not found. Please ensure the guardrail is configured in your LiteLLM proxy.",
)
response_text = await active_guardrail.apply_guardrail(
text=request.text, language=request.language, entities=request.entities
)
return ApplyGuardrailResponse(response_text=response_text)
except Exception as e:
raise handle_exception_on_proxy(e)
@@ -9,7 +9,7 @@ import asyncio
import copy
import os
from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, Final, Literal, Optional, Type, Union
from typing import TYPE_CHECKING, Any, Dict, Final, List, Literal, Optional, Type, Union
from urllib.parse import urljoin
from fastapi import HTTPException
@@ -23,7 +23,7 @@ from litellm.llms.custom_httpx.http_handler import (
httpxSpecialProvider,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.guardrails import GuardrailEventHooks, PiiEntityType
from litellm.types.utils import EmbeddingResponse, GuardrailStatus, ImageResponse
# Constants
@@ -851,6 +851,92 @@ class NomaGuardrail(CustomGuardrail):
else:
verbose_proxy_logger.debug(msg)
async def apply_guardrail(
self,
text: str,
language: Optional[str] = None,
entities: Optional[List[PiiEntityType]] = None,
) -> str:
"""
Apply Noma guardrail to the given text for testing purposes.
This method allows users to test Noma guardrails without making actual LLM calls.
It creates a mock request to test the guardrail functionality.
Args:
text: The text to analyze
language: Optional language parameter (not used by Noma)
entities: Optional entities parameter (not used by Noma)
Returns:
The original text if allowed, or anonymized text if available
Raises:
Exception: If the content is blocked by Noma guardrail
"""
try:
verbose_proxy_logger.debug("Noma Guardrail: Applying guardrail")
# Create a mock user auth object for testing
from litellm.proxy._types import UserAPIKeyAuth
mock_user_auth = UserAPIKeyAuth()
# Create payload for Noma API
payload = {"request": {"text": text}}
# Call Noma API
response_json = await self._call_noma_api(
payload=payload,
llm_request_id=None,
request_data={"messages": [{"role": "user", "content": text}]},
user_auth=mock_user_auth,
extra_data={},
)
# Check if content is blocked
verdict = response_json.get("verdict", True)
if not verdict:
# Check if we should anonymize instead of blocking
if self.anonymize_input and self._should_anonymize(response_json, USER_ROLE):
anonymized_content = self._extract_anonymized_content(
response_json, USER_ROLE
)
if anonymized_content:
verbose_proxy_logger.debug(
"Noma Guardrail: Content anonymized"
)
return anonymized_content
# Content is blocked
original_response = response_json.get("originalResponse", {})
filtered_response = NomaBlockedMessage(original_response)._filter_triggered_classifications(original_response)
raise Exception(
f"Content blocked by Noma guardrail: {filtered_response}"
)
# Check if anonymization is available even for allowed content
if self.anonymize_input:
anonymized_content = self._extract_anonymized_content(
response_json, USER_ROLE
)
if anonymized_content:
verbose_proxy_logger.debug(
"Noma Guardrail: Content anonymized"
)
return anonymized_content
verbose_proxy_logger.debug(
"Noma Guardrail: Successfully applied guardrail"
)
return text
except Exception as e:
verbose_proxy_logger.error(
"Noma Guardrail: Failed to apply guardrail: %s", str(e)
)
raise Exception(f"Noma guardrail failed: {str(e)}")
@staticmethod
def get_config_model() -> Optional[Type["GuardrailConfigModel"]]:
from litellm.types.proxy.guardrails.guardrail_hooks.noma import (
@@ -731,7 +731,7 @@ class TestBackgroundProcessing:
):
"""Test post-call success hook in monitor mode"""
from litellm.types.utils import Choices, Message
# Update event hook to post_call
monitor_mode_guardrail.event_hook = "post_call"
@@ -764,6 +764,104 @@ class TestBackgroundProcessing:
mock_create_background.assert_called_once()
class TestNomaApplyGuardrail:
"""
Test the apply_guardrail method for Noma guardrails
"""
@pytest.mark.asyncio
async def test_apply_guardrail_success(self):
"""
Test that apply_guardrail returns text when content is allowed
"""
guardrail = NomaGuardrail(
api_key="test-api-key",
api_base="https://api.test.noma.security/",
application_id="test-app",
monitor_mode=False,
block_failures=True,
)
mock_response = MagicMock()
mock_response.json.return_value = {"verdict": True}
mock_response.raise_for_status = MagicMock()
with patch.object(
guardrail.async_handler, "post", return_value=mock_response
):
result = await guardrail.apply_guardrail(
text="This is a safe test message"
)
assert result == "This is a safe test message"
@pytest.mark.asyncio
async def test_apply_guardrail_blocked(self):
"""
Test that apply_guardrail raises exception when content is blocked
"""
guardrail = NomaGuardrail(
api_key="test-api-key",
api_base="https://api.test.noma.security/",
application_id="test-app",
monitor_mode=False,
block_failures=True,
)
mock_response = MagicMock()
mock_response.json.return_value = {
"verdict": False,
"originalResponse": {
"prompt": {"contentDetector": {"result": True, "confidence": 0.9}}
},
}
mock_response.raise_for_status = MagicMock()
with patch.object(
guardrail.async_handler, "post", return_value=mock_response
):
with pytest.raises(Exception) as exc_info:
await guardrail.apply_guardrail(text="This is blocked content")
assert "Content blocked by Noma guardrail" in str(exc_info.value)
@pytest.mark.asyncio
async def test_apply_guardrail_with_anonymization(self):
"""
Test that apply_guardrail returns anonymized text when anonymize_input is enabled
"""
guardrail = NomaGuardrail(
api_key="test-api-key",
api_base="https://api.test.noma.security/",
application_id="test-app",
anonymize_input=True,
monitor_mode=False,
block_failures=True,
)
mock_response = MagicMock()
mock_response.json.return_value = {
"verdict": True,
"originalResponse": {
"prompt": {
"anonymizedContent": {
"anonymized": "My email is ******* and phone is *******"
}
}
},
}
mock_response.raise_for_status = MagicMock()
with patch.object(
guardrail.async_handler, "post", return_value=mock_response
):
result = await guardrail.apply_guardrail(
text="My email is test@example.com and phone is 123-456-7890"
)
assert result == "My email is ******* and phone is *******"
class TestIntegration:
@pytest.mark.asyncio
async def test_full_guardrail_flow(self):
@@ -14,25 +14,27 @@ sys.path.insert(
from fastapi import HTTPException
from litellm.proxy.guardrails.guardrail_endpoints import (
CreateGuardrailRequest,
PatchGuardrailRequest,
UpdateGuardrailRequest,
apply_guardrail,
create_guardrail,
delete_guardrail,
get_guardrail_info,
list_guardrails_v2,
CreateGuardrailRequest,
create_guardrail,
UpdateGuardrailRequest,
update_guardrail,
PatchGuardrailRequest,
patch_guardrail,
delete_guardrail,
update_guardrail,
)
from litellm.proxy.guardrails.guardrail_registry import (
IN_MEMORY_GUARDRAIL_HANDLER,
InMemoryGuardrailHandler,
)
from litellm.types.guardrails import (
ApplyGuardrailRequest,
BaseLitellmParams,
Guardrail,
GuardrailInfoResponse,
LitellmParams,
Guardrail,
)
# Mock data for testing
@@ -343,8 +345,11 @@ def test_optional_params_returned_when_properly_overridden():
async def test_bedrock_guardrail_prepare_request_with_api_key():
"""Test _prepare_request method uses Bearer token when api_key is provided in data"""
from unittest.mock import Mock, patch
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
BedrockGuardrail,
)
# Setup guardrail hook
guardrail_hook = BedrockGuardrail(
guardrailIdentifier="test-guardrail-id",
@@ -377,8 +382,11 @@ async def test_bedrock_guardrail_prepare_request_with_api_key():
async def test_bedrock_guardrail_prepare_request_without_api_key():
"""Test _prepare_request method falls back to SigV4 when no api_key is provided"""
from unittest.mock import Mock, patch
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
BedrockGuardrail,
)
# Setup guardrail hook
guardrail_hook = BedrockGuardrail(
guardrailIdentifier="test-guardrail-id",
@@ -427,8 +435,11 @@ async def test_bedrock_guardrail_prepare_request_without_api_key():
async def test_bedrock_guardrail_prepare_request_with_bearer_token_env():
"""Test _prepare_request method uses Bearer token from environment when available"""
from unittest.mock import Mock, patch
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
BedrockGuardrail,
)
# Setup guardrail hook
guardrail_hook = BedrockGuardrail(
guardrailIdentifier="test-guardrail-id",
@@ -469,8 +480,11 @@ async def test_bedrock_guardrail_prepare_request_with_bearer_token_env():
@pytest.mark.asyncio
async def test_bedrock_guardrail_make_api_request_passes_api_key():
"""Test make_bedrock_api_request method correctly passes api_key from request_data"""
from unittest.mock import Mock, patch, AsyncMock
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail
from unittest.mock import AsyncMock, Mock, patch
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
BedrockGuardrail,
)
guardrail_hook = BedrockGuardrail(
guardrailIdentifier="test-guardrail-id",
@@ -850,4 +864,69 @@ async def test_delete_guardrail_endpoint(
if scenario == "success_sync_fails":
assert mock_logger is not None
mock_logger.warning.assert_called_once()
assert "Failed to remove guardrail" in str(mock_logger.warning.call_args)
assert "Failed to remove guardrail" in str(mock_logger.warning.call_args)
@pytest.mark.asyncio
async def test_apply_guardrail_not_found(mocker):
"""
Test apply_guardrail endpoint returns proper error when guardrail is not found.
"""
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
# Mock the GUARDRAIL_REGISTRY to return None (guardrail not found)
mock_registry = mocker.Mock()
mock_registry.get_initialized_guardrail_callback.return_value = None
mocker.patch("litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY", mock_registry)
# Create request
request = ApplyGuardrailRequest(
guardrail_name="non-existent-guardrail",
text="Test input text"
)
# Mock user auth
mock_user_auth = UserAPIKeyAuth()
# Call endpoint and expect ProxyException
with pytest.raises(ProxyException) as exc_info:
await apply_guardrail(request=request, user_api_key_dict=mock_user_auth)
# Verify error details
assert str(exc_info.value.code) == "404"
assert "not found" in str(exc_info.value.message).lower()
@pytest.mark.asyncio
async def test_apply_guardrail_execution_error(mocker):
"""
Test apply_guardrail endpoint handles exceptions from guardrail execution properly.
"""
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
# Mock guardrail that raises an exception
mock_guardrail = mocker.Mock()
mock_guardrail.apply_guardrail = AsyncMock(
side_effect=Exception("Bedrock guardrail failed: Violated guardrail policy")
)
# Mock the GUARDRAIL_REGISTRY
mock_registry = mocker.Mock()
mock_registry.get_initialized_guardrail_callback.return_value = mock_guardrail
mocker.patch("litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY", mock_registry)
# Create request
request = ApplyGuardrailRequest(
guardrail_name="test-guardrail",
text="Test input text with forbidden content"
)
# Mock user auth
mock_user_auth = UserAPIKeyAuth()
# Call endpoint and expect ProxyException
with pytest.raises(ProxyException) as exc_info:
await apply_guardrail(request=request, user_api_key_dict=mock_user_auth)
# Verify error is properly handled
assert "Bedrock guardrail failed" in str(exc_info.value.message)
@@ -1,11 +1,12 @@
import React, { useState, useEffect } from "react";
import { Button } from "@tremor/react";
import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
import { Modal } from "antd";
import { getGuardrailsList, deleteGuardrailCall } from "./networking";
import AddGuardrailForm from "./guardrails/add_guardrail_form";
import GuardrailTable from "./guardrails/guardrail_table";
import { isAdminRole } from "@/utils/roles";
import GuardrailInfoView from "./guardrails/guardrail_info";
import GuardrailTestPlayground from "./guardrails/GuardrailTestPlayground";
import NotificationsManager from "./molecules/notifications_manager";
interface GuardrailsPanelProps {
@@ -37,6 +38,7 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
const [isDeleting, setIsDeleting] = useState(false);
const [guardrailToDelete, setGuardrailToDelete] = useState<{ id: string; name: string } | null>(null);
const [selectedGuardrailId, setSelectedGuardrailId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<number>(0);
const isAdmin = userRole ? isAdminRole(userRole) : false;
@@ -104,52 +106,72 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
<div className="flex justify-between items-center mb-4">
<Button onClick={handleAddGuardrail} disabled={!accessToken}>
+ Add New Guardrail
</Button>
</div>
<TabGroup index={activeTab} onIndexChange={setActiveTab}>
<TabList className="mb-4">
<Tab>Guardrails</Tab>
<Tab disabled={!accessToken || guardrailsList.length === 0}>Test Playground</Tab>
</TabList>
{selectedGuardrailId ? (
<GuardrailInfoView
guardrailId={selectedGuardrailId}
onClose={() => setSelectedGuardrailId(null)}
accessToken={accessToken}
isAdmin={isAdmin}
/>
) : (
<GuardrailTable
guardrailsList={guardrailsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
accessToken={accessToken}
onGuardrailUpdated={fetchGuardrails}
isAdmin={isAdmin}
onGuardrailClick={(id) => setSelectedGuardrailId(id)}
/>
)}
<TabPanels>
<TabPanel>
<div className="flex justify-between items-center mb-4">
<Button onClick={handleAddGuardrail} disabled={!accessToken}>
+ Add New Guardrail
</Button>
</div>
<AddGuardrailForm
visible={isAddModalVisible}
onClose={handleCloseModal}
accessToken={accessToken}
onSuccess={handleSuccess}
/>
{selectedGuardrailId ? (
<GuardrailInfoView
guardrailId={selectedGuardrailId}
onClose={() => setSelectedGuardrailId(null)}
accessToken={accessToken}
isAdmin={isAdmin}
/>
) : (
<GuardrailTable
guardrailsList={guardrailsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
accessToken={accessToken}
onGuardrailUpdated={fetchGuardrails}
isAdmin={isAdmin}
onGuardrailClick={(id) => setSelectedGuardrailId(id)}
/>
)}
{guardrailToDelete && (
<Modal
title="Delete Guardrail"
open={guardrailToDelete !== null}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
confirmLoading={isDeleting}
okText="Delete"
okButtonProps={{ danger: true }}
>
<p>Are you sure you want to delete guardrail: {guardrailToDelete.name} ?</p>
<p>This action cannot be undone.</p>
</Modal>
)}
<AddGuardrailForm
visible={isAddModalVisible}
onClose={handleCloseModal}
accessToken={accessToken}
onSuccess={handleSuccess}
/>
{guardrailToDelete && (
<Modal
title="Delete Guardrail"
open={guardrailToDelete !== null}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
confirmLoading={isDeleting}
okText="Delete"
okButtonProps={{ danger: true }}
>
<p>Are you sure you want to delete guardrail: {guardrailToDelete.name} ?</p>
<p>This action cannot be undone.</p>
</Modal>
)}
</TabPanel>
<TabPanel>
<GuardrailTestPlayground
guardrailsList={guardrailsList}
isLoading={isLoading}
accessToken={accessToken}
onClose={() => setActiveTab(0)}
/>
</TabPanel>
</TabPanels>
</TabGroup>
</div>
);
};
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import GuardrailSelector from "./GuardrailSelector";
import * as networking from "../networking";
vi.mock("../networking");
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
describe("GuardrailSelector", () => {
const mockAccessToken = "test-token";
const mockOnChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("should load guardrails from API when component mounts", async () => {
/**
* Tests that the selector fetches guardrails from the API on mount.
* This validates the core data loading functionality.
*/
const mockGuardrails = [
{
guardrail_name: "pii-guard",
litellm_params: { guardrail: "presidio", mode: "pre_call", default_on: false },
},
{
guardrail_name: "content-filter",
litellm_params: { guardrail: "lakera", mode: "post_call", default_on: true },
},
];
vi.mocked(networking.getGuardrailsList).mockResolvedValue({
guardrails: mockGuardrails,
});
render(
<GuardrailSelector
accessToken={mockAccessToken}
onChange={mockOnChange}
value={[]}
/>
);
// Verify API was called with correct token
await waitFor(() => {
expect(networking.getGuardrailsList).toHaveBeenCalledWith(mockAccessToken);
});
});
});
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GuardrailTestPanel } from "./GuardrailTestPanel";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
describe("GuardrailTestPanel", () => {
const mockOnSubmit = vi.fn();
const mockOnClose = vi.fn();
const mockGuardrailNames = ["test-guardrail-1", "test-guardrail-2"];
beforeEach(() => {
vi.clearAllMocks();
});
it("should submit text when Enter key is pressed", async () => {
/**
* Tests that pressing Enter submits the form with the input text.
* This validates the keyboard shortcut functionality.
*/
const user = userEvent.setup();
render(
<GuardrailTestPanel
guardrailNames={mockGuardrailNames}
onSubmit={mockOnSubmit}
isLoading={false}
results={null}
errors={null}
onClose={mockOnClose}
/>
);
// Find and type in the textarea
const textarea = screen.getByPlaceholderText("Enter text to test with guardrails...");
await user.type(textarea, "Test input text");
// Press Enter to submit
await user.keyboard("{Enter}");
// Verify onSubmit was called with the correct text
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith("Test input text");
});
});
});
@@ -0,0 +1,169 @@
import React, { useState } from "react";
import { Button } from "@tremor/react";
import { Input, Typography, Tooltip } from "antd";
import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons";
import NotificationsManager from "../molecules/notifications_manager";
import GuardrailTestResults from "./GuardrailTestResults";
const { TextArea } = Input;
const { Text } = Typography;
interface GuardrailTestPanelProps {
guardrailNames: string[];
onSubmit: (text: string) => void;
isLoading: boolean;
results: Array<{ guardrailName: string; response_text: string; latency: number }> | null;
errors: Array<{ guardrailName: string; error: Error; latency: number }> | null;
onClose: () => void;
}
export function GuardrailTestPanel({
guardrailNames,
onSubmit,
isLoading,
results,
errors,
onClose,
}: GuardrailTestPanelProps) {
const [inputText, setInputText] = useState("");
const handleSubmit = () => {
if (!inputText.trim()) {
NotificationsManager.fromBackend("Please enter text to test");
return;
}
onSubmit(inputText);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
handleSubmit();
}
};
const copyToClipboard = async (text: string) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
} else {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand("copy");
document.body.removeChild(textArea);
if (!successful) {
throw new Error("execCommand failed");
}
return true;
}
} catch (error) {
console.error("Copy failed:", error);
return false;
}
};
const handleCopyInput = async () => {
const success = await copyToClipboard(inputText);
if (success) {
NotificationsManager.success("Input copied to clipboard");
} else {
NotificationsManager.fromBackend("Failed to copy input");
}
};
return (
<div className="space-y-4 h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
<div className="flex items-center space-x-3">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<h2 className="text-lg font-semibold text-gray-900">Test Guardrails:</h2>
<div className="flex flex-wrap gap-2">
{guardrailNames.map((name) => (
<div
key={name}
className="inline-flex items-center space-x-1 bg-blue-50 px-3 py-1 rounded-md border border-blue-200"
>
<span className="font-mono text-blue-700 font-medium text-sm">{name}</span>
</div>
))}
</div>
</div>
<p className="text-sm text-gray-500">
Test {guardrailNames.length > 1 ? "guardrails" : "guardrail"} and compare results
</p>
</div>
</div>
</div>
{/* Input Section */}
<div className="flex-1 overflow-auto space-y-4">
<div className="space-y-3">
<div>
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Input Text</label>
<Tooltip title="Press Enter to submit. Use Shift+Enter for new line.">
<InfoCircleOutlined className="text-gray-400 cursor-help" />
</Tooltip>
</div>
{inputText && (
<Button
size="xs"
variant="secondary"
icon={CopyOutlined}
onClick={handleCopyInput}
>
Copy Input
</Button>
)}
</div>
<TextArea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter text to test with guardrails..."
rows={8}
className="font-mono text-sm"
/>
<div className="flex justify-between items-center mt-1">
<Text className="text-xs text-gray-500">
Press <kbd className="px-1 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">Enter</kbd> to submit <kbd className="px-1 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">Shift+Enter</kbd> for new line
</Text>
<Text className="text-xs text-gray-500">Characters: {inputText.length}</Text>
</div>
</div>
<div className="pt-2">
<Button
onClick={handleSubmit}
loading={isLoading}
disabled={!inputText.trim()}
className="w-full"
>
{isLoading
? `Testing ${guardrailNames.length} guardrail${guardrailNames.length > 1 ? "s" : ""}...`
: `Test ${guardrailNames.length} guardrail${guardrailNames.length > 1 ? "s" : ""}`}
</Button>
</div>
</div>
{/* Results Section */}
<GuardrailTestResults results={results} errors={errors} />
</div>
</div>
);
}
export default GuardrailTestPanel;
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import GuardrailTestPlayground from "./GuardrailTestPlayground";
import * as networking from "../networking";
vi.mock("../networking");
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
describe("GuardrailTestPlayground", () => {
const mockAccessToken = "test-token";
const mockGuardrails = [
{
guardrail_id: "guard-1",
guardrail_name: "test-guardrail",
litellm_params: {
guardrail: "presidio",
mode: "pre_call",
default_on: false,
},
guardrail_info: {},
},
];
beforeEach(() => {
vi.clearAllMocks();
});
it("should allow selecting a guardrail and show test panel", async () => {
/**
* Tests that clicking on a guardrail selects it and displays the test panel.
* This validates the core workflow of selecting and testing guardrails.
*/
const user = userEvent.setup();
render(
<GuardrailTestPlayground
guardrailsList={mockGuardrails}
isLoading={false}
accessToken={mockAccessToken}
onClose={vi.fn()}
/>
);
// Initially, the empty state should be shown
expect(screen.getByText("Select Guardrails to Test")).toBeInTheDocument();
// Click on the guardrail to select it
const guardrailItem = screen.getByText("test-guardrail");
await user.click(guardrailItem);
// Verify the test panel is now shown
await waitFor(() => {
expect(screen.getByText("Test Guardrails:")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Enter text to test with guardrails...")).toBeInTheDocument();
});
// Verify the selected count
expect(screen.getByText("1 of 1 selected")).toBeInTheDocument();
});
});
@@ -0,0 +1,254 @@
import React, { useState } from "react";
import { Card, Title, Text, TextInput } from "@tremor/react";
import { List, Empty, Spin, Checkbox } from "antd";
import { ExperimentOutlined, SearchOutlined } from "@ant-design/icons";
import GuardrailTestPanel from "./GuardrailTestPanel";
import { applyGuardrail } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
interface GuardrailItem {
guardrail_id?: string;
guardrail_name: string | null;
litellm_params: {
guardrail: string;
mode: string;
default_on: boolean;
};
guardrail_info: Record<string, any> | null;
created_at?: string;
updated_at?: string;
}
interface GuardrailTestPlaygroundProps {
guardrailsList: GuardrailItem[];
isLoading: boolean;
accessToken: string | null;
onClose: () => void;
}
interface TestResult {
guardrailName: string;
response_text: string;
latency: number;
}
interface TestError {
guardrailName: string;
error: Error;
latency: number;
}
const GuardrailTestPlayground: React.FC<GuardrailTestPlaygroundProps> = ({
guardrailsList,
isLoading,
accessToken,
onClose,
}) => {
const [selectedGuardrails, setSelectedGuardrails] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [testResults, setTestResults] = useState<TestResult[]>([]);
const [testErrors, setTestErrors] = useState<TestError[]>([]);
const [isTesting, setIsTesting] = useState(false);
const filteredGuardrails = guardrailsList.filter((guardrail) =>
guardrail.guardrail_name?.toLowerCase().includes(searchQuery.toLowerCase())
);
const toggleGuardrailSelection = (guardrailName: string) => {
const newSelection = new Set(selectedGuardrails);
if (newSelection.has(guardrailName)) {
newSelection.delete(guardrailName);
} else {
newSelection.add(guardrailName);
}
setSelectedGuardrails(newSelection);
};
const handleTestGuardrails = async (text: string) => {
if (selectedGuardrails.size === 0 || !accessToken) {
return;
}
setIsTesting(true);
setTestResults([]);
setTestErrors([]);
const results: TestResult[] = [];
const errors: TestError[] = [];
await Promise.all(
Array.from(selectedGuardrails).map(async (guardrailName) => {
const startTime = Date.now();
try {
const result = await applyGuardrail(accessToken, guardrailName, text, null, null);
const latency = Date.now() - startTime;
results.push({
guardrailName,
response_text: result.response_text,
latency,
});
} catch (error) {
const latency = Date.now() - startTime;
console.error(`Error testing guardrail ${guardrailName}:`, error);
errors.push({
guardrailName,
error: error as Error,
latency,
});
}
})
);
setTestResults(results);
setTestErrors(errors);
setIsTesting(false);
if (results.length > 0) {
NotificationsManager.success(
`${results.length} guardrail${results.length > 1 ? "s" : ""} applied successfully`
);
}
if (errors.length > 0) {
NotificationsManager.fromBackend(
`${errors.length} guardrail${errors.length > 1 ? "s" : ""} failed`
);
}
};
return (
<div className="w-full h-[calc(100vh-200px)]">
<Card className="h-full">
<div className="flex h-full">
{/* Left Sidebar - Guardrails List */}
<div className="w-1/4 border-r border-gray-200 flex flex-col overflow-hidden">
<div className="p-4 border-b border-gray-200">
<div className="mb-3">
<Title className="text-lg font-semibold mb-3">Guardrails</Title>
<TextInput
icon={SearchOutlined}
placeholder="Search guardrails..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
</div>
</div>
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-32">
<Spin />
</div>
) : filteredGuardrails.length === 0 ? (
<div className="p-4">
<Empty
description={
searchQuery ? "No guardrails match your search" : "No guardrails available"
}
/>
</div>
) : (
<List
dataSource={filteredGuardrails}
renderItem={(guardrail) => (
<List.Item
onClick={() => {
if (guardrail.guardrail_name) {
toggleGuardrailSelection(guardrail.guardrail_name);
}
}}
className={`cursor-pointer hover:bg-gray-50 transition-colors px-4 ${
selectedGuardrails.has(guardrail.guardrail_name || "")
? "bg-blue-50 border-l-4 border-l-blue-500"
: "border-l-4 border-l-transparent"
}`}
>
<List.Item.Meta
avatar={
<Checkbox
checked={selectedGuardrails.has(guardrail.guardrail_name || "")}
onClick={(e) => {
e.stopPropagation();
if (guardrail.guardrail_name) {
toggleGuardrailSelection(guardrail.guardrail_name);
}
}}
/>
}
title={
<div className="flex items-center space-x-2">
<ExperimentOutlined className="text-gray-400" />
<span className="font-medium text-gray-900">
{guardrail.guardrail_name}
</span>
</div>
}
description={
<div className="text-xs space-y-1 mt-1">
<div>
<span className="font-medium">Type: </span>
<span className="text-gray-600">
{guardrail.litellm_params.guardrail}
</span>
</div>
<div>
<span className="font-medium">Mode: </span>
<span className="text-gray-600">
{guardrail.litellm_params.mode}
</span>
</div>
</div>
}
/>
</List.Item>
)}
/>
)}
</div>
<div className="p-3 border-t border-gray-200 bg-gray-50">
<Text className="text-xs text-gray-600">
{selectedGuardrails.size} of {filteredGuardrails.length} selected
</Text>
</div>
</div>
{/* Right Panel - Test Area */}
<div className="w-3/4 flex flex-col bg-white">
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
<Title className="text-xl font-semibold mb-0">Guardrail Testing Playground</Title>
</div>
<div className="flex-1 overflow-auto p-4">
{selectedGuardrails.size === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-gray-400">
<ExperimentOutlined style={{ fontSize: "48px", marginBottom: "16px" }} />
<Text className="text-lg font-medium text-gray-600 mb-2">
Select Guardrails to Test
</Text>
<Text className="text-center text-gray-500 max-w-md">
Choose one or more guardrails from the left sidebar to start testing and
comparing results.
</Text>
</div>
) : (
<div className="h-full">
<GuardrailTestPanel
guardrailNames={Array.from(selectedGuardrails)}
onSubmit={handleTestGuardrails}
results={testResults.length > 0 ? testResults : null}
errors={testErrors.length > 0 ? testErrors : null}
isLoading={isTesting}
onClose={() => setSelectedGuardrails(new Set())}
/>
</div>
)}
</div>
</div>
</div>
</Card>
</div>
);
};
export default GuardrailTestPlayground;
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GuardrailTestResults } from "./GuardrailTestResults";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
describe("GuardrailTestResults", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should collapse and expand results when clicked", async () => {
/**
* Tests that clicking on a result header toggles its collapsed state.
* This validates the core collapse/expand functionality for managing large payloads.
*/
const user = userEvent.setup();
const mockResults = [
{
guardrailName: "test-guardrail",
response_text: "This is a very long response text that should be collapsible",
latency: 250,
},
];
render(<GuardrailTestResults results={mockResults} errors={null} />);
// Verify output text is initially visible
expect(screen.getByText("This is a very long response text that should be collapsible")).toBeInTheDocument();
// Click on the guardrail name to collapse
const guardrailHeader = screen.getByText("test-guardrail");
await user.click(guardrailHeader);
// Verify output text is now hidden
expect(screen.queryByText("This is a very long response text that should be collapsible")).not.toBeInTheDocument();
// Click again to expand
await user.click(guardrailHeader);
// Verify output text is visible again
expect(screen.getByText("This is a very long response text that should be collapsible")).toBeInTheDocument();
});
});
@@ -0,0 +1,193 @@
import React, { useState } from "react";
import { Button, Card } from "@tremor/react";
import { Typography } from "antd";
import { CopyOutlined, CheckCircleOutlined, ClockCircleOutlined, DownOutlined, RightOutlined } from "@ant-design/icons";
import NotificationsManager from "../molecules/notifications_manager";
const { Text } = Typography;
interface TestResult {
guardrailName: string;
response_text: string;
latency: number;
}
interface TestError {
guardrailName: string;
error: Error;
latency: number;
}
interface GuardrailTestResultsProps {
results: TestResult[] | null;
errors: TestError[] | null;
}
export function GuardrailTestResults({ results, errors }: GuardrailTestResultsProps) {
const [collapsedResults, setCollapsedResults] = useState<Set<string>>(new Set());
const toggleResultCollapse = (guardrailName: string) => {
const newCollapsed = new Set(collapsedResults);
if (newCollapsed.has(guardrailName)) {
newCollapsed.delete(guardrailName);
} else {
newCollapsed.add(guardrailName);
}
setCollapsedResults(newCollapsed);
};
const copyToClipboard = async (text: string) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
} else {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand("copy");
document.body.removeChild(textArea);
if (!successful) {
throw new Error("execCommand failed");
}
return true;
}
} catch (error) {
console.error("Copy failed:", error);
return false;
}
};
if (!results && !errors) {
return null;
}
return (
<div className="space-y-3 pt-4 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900">Results</h3>
{/* Success Results */}
{results &&
results.map((result) => {
const isCollapsed = collapsedResults.has(result.guardrailName);
return (
<Card key={result.guardrailName} className="bg-green-50 border-green-200">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div
className="flex items-center space-x-2 cursor-pointer flex-1"
onClick={() => toggleResultCollapse(result.guardrailName)}
>
{isCollapsed ? (
<RightOutlined className="text-gray-500 text-xs" />
) : (
<DownOutlined className="text-gray-500 text-xs" />
)}
<CheckCircleOutlined className="text-green-600 text-lg" />
<span className="text-sm font-medium text-green-800">
{result.guardrailName}
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center space-x-1 text-xs text-gray-600">
<ClockCircleOutlined />
<span className="font-medium">{result.latency}ms</span>
</div>
{!isCollapsed && (
<Button
size="xs"
variant="secondary"
icon={CopyOutlined}
onClick={async () => {
const success = await copyToClipboard(result.response_text);
if (success) {
NotificationsManager.success("Result copied to clipboard");
} else {
NotificationsManager.fromBackend("Failed to copy result");
}
}}
>
Copy
</Button>
)}
</div>
</div>
{!isCollapsed && (
<>
<div className="bg-white border border-green-200 rounded p-3">
<label className="text-xs font-medium text-gray-600 mb-2 block">
Output Text
</label>
<div className="font-mono text-sm text-gray-900 whitespace-pre-wrap break-words">
{result.response_text}
</div>
</div>
<div className="text-xs text-gray-600">
<span className="font-medium">Characters:</span> {result.response_text.length}
</div>
</>
)}
</div>
</Card>
);
})}
{/* Error Results */}
{errors &&
errors.map((errorItem) => {
const isCollapsed = collapsedResults.has(errorItem.guardrailName);
return (
<Card key={errorItem.guardrailName} className="bg-red-50 border-red-200">
<div className="flex items-start space-x-2">
<div
className="cursor-pointer mt-0.5"
onClick={() => toggleResultCollapse(errorItem.guardrailName)}
>
{isCollapsed ? (
<RightOutlined className="text-gray-500 text-xs" />
) : (
<DownOutlined className="text-gray-500 text-xs" />
)}
</div>
<div className="text-red-600 mt-0.5">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<p
className="text-sm font-medium text-red-800 cursor-pointer"
onClick={() => toggleResultCollapse(errorItem.guardrailName)}
>
{errorItem.guardrailName} - Error
</p>
<div className="flex items-center space-x-1 text-xs text-gray-600">
<ClockCircleOutlined />
<span className="font-medium">{errorItem.latency}ms</span>
</div>
</div>
{!isCollapsed && (
<p className="text-sm text-red-700 mt-1">{errorItem.error.message}</p>
)}
</div>
</div>
</Card>
);
})}
</div>
);
}
export default GuardrailTestResults;
@@ -6430,6 +6430,68 @@ export const updateGuardrailCall = async (
}
};
export const applyGuardrail = async (
accessToken: string,
guardrailName: string,
text: string,
language?: string | null,
entities?: string[] | null,
) => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails/apply_guardrail` : `/guardrails/apply_guardrail`;
const requestBody: Record<string, any> = {
guardrail_name: guardrailName,
text: text,
};
if (language) {
requestBody.language = language;
}
if (entities && entities.length > 0) {
requestBody.entities = entities;
}
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response.text();
let errorMessage = "Failed to apply guardrail";
try {
const errorJson = JSON.parse(errorData);
if (errorJson.error?.message) {
errorMessage = errorJson.error.message;
} else if (errorJson.detail) {
errorMessage = errorJson.detail;
} else if (errorJson.message) {
errorMessage = errorJson.message;
}
} catch (e) {
errorMessage = errorData || errorMessage;
}
handleError(errorData);
throw new Error(errorMessage);
}
const data = await response.json();
console.log("Apply guardrail response:", data);
return data;
} catch (error) {
console.error("Failed to apply guardrail:", error);
throw error;
}
};
export const getSSOSettings = async (accessToken: string) => {
try {
// Construct base URL