mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-06 18:23:07 +00:00
[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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user