From 95dd216150a48df959bf409af29b178990bef911 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 28 Oct 2025 16:41:17 -0700 Subject: [PATCH] [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 --- .../proxy/guardrails/endpoints.py | 43 --- .../proxy/guardrails/guardrail_endpoints.py | 38 +++ .../guardrails/guardrail_hooks/noma/noma.py | 90 ++++++- .../guardrails/guardrail_hooks/test_noma.py | 100 ++++++- .../guardrails/test_guardrail_endpoints.py | 111 ++++++-- .../src/components/guardrails.tsx | 110 +++++--- .../guardrails/GuardrailSelector.test.tsx | 65 +++++ .../guardrails/GuardrailTestPanel.test.tsx | 60 +++++ .../guardrails/GuardrailTestPanel.tsx | 169 ++++++++++++ .../GuardrailTestPlayground.test.tsx | 75 ++++++ .../guardrails/GuardrailTestPlayground.tsx | 254 ++++++++++++++++++ .../guardrails/GuardrailTestResults.test.tsx | 58 ++++ .../guardrails/GuardrailTestResults.tsx | 193 +++++++++++++ .../src/components/networking.tsx | 62 +++++ 14 files changed, 1322 insertions(+), 106 deletions(-) delete mode 100644 enterprise/litellm_enterprise/proxy/guardrails/endpoints.py create mode 100644 ui/litellm-dashboard/src/components/guardrails/GuardrailSelector.test.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/GuardrailTestPanel.test.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/GuardrailTestPanel.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/GuardrailTestPlayground.test.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/GuardrailTestPlayground.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/GuardrailTestResults.test.tsx create mode 100644 ui/litellm-dashboard/src/components/guardrails/GuardrailTestResults.tsx diff --git a/enterprise/litellm_enterprise/proxy/guardrails/endpoints.py b/enterprise/litellm_enterprise/proxy/guardrails/endpoints.py deleted file mode 100644 index 8b42b2549c..0000000000 --- a/enterprise/litellm_enterprise/proxy/guardrails/endpoints.py +++ /dev/null @@ -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) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 29da0ec40e..f2f63778e4 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -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) + diff --git a/litellm/proxy/guardrails/guardrail_hooks/noma/noma.py b/litellm/proxy/guardrails/guardrail_hooks/noma/noma.py index 7073a4341f..e049ca6a13 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/noma/noma.py +++ b/litellm/proxy/guardrails/guardrail_hooks/noma/noma.py @@ -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 ( diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma.py index d98473a128..f9b80a0a57 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma.py @@ -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): diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py index 34a1ae4b38..75daf54016 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py @@ -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) \ No newline at end of file + 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) \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/guardrails.tsx b/ui/litellm-dashboard/src/components/guardrails.tsx index 605770831d..23aec34c40 100644 --- a/ui/litellm-dashboard/src/components/guardrails.tsx +++ b/ui/litellm-dashboard/src/components/guardrails.tsx @@ -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 = ({ accessToken, userRole const [isDeleting, setIsDeleting] = useState(false); const [guardrailToDelete, setGuardrailToDelete] = useState<{ id: string; name: string } | null>(null); const [selectedGuardrailId, setSelectedGuardrailId] = useState(null); + const [activeTab, setActiveTab] = useState(0); const isAdmin = userRole ? isAdminRole(userRole) : false; @@ -104,52 +106,72 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole return (
-
- -
+ + + Guardrails + Test Playground + - {selectedGuardrailId ? ( - setSelectedGuardrailId(null)} - accessToken={accessToken} - isAdmin={isAdmin} - /> - ) : ( - setSelectedGuardrailId(id)} - /> - )} + + +
+ +
- + {selectedGuardrailId ? ( + setSelectedGuardrailId(null)} + accessToken={accessToken} + isAdmin={isAdmin} + /> + ) : ( + setSelectedGuardrailId(id)} + /> + )} - {guardrailToDelete && ( - -

Are you sure you want to delete guardrail: {guardrailToDelete.name} ?

-

This action cannot be undone.

-
- )} + + + {guardrailToDelete && ( + +

Are you sure you want to delete guardrail: {guardrailToDelete.name} ?

+

This action cannot be undone.

+
+ )} +
+ + + setActiveTab(0)} + /> + +
+
); }; diff --git a/ui/litellm-dashboard/src/components/guardrails/GuardrailSelector.test.tsx b/ui/litellm-dashboard/src/components/guardrails/GuardrailSelector.test.tsx new file mode 100644 index 0000000000..9da2b09b51 --- /dev/null +++ b/ui/litellm-dashboard/src/components/guardrails/GuardrailSelector.test.tsx @@ -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( + + ); + + // Verify API was called with correct token + await waitFor(() => { + expect(networking.getGuardrailsList).toHaveBeenCalledWith(mockAccessToken); + }); + }); +}); + diff --git a/ui/litellm-dashboard/src/components/guardrails/GuardrailTestPanel.test.tsx b/ui/litellm-dashboard/src/components/guardrails/GuardrailTestPanel.test.tsx new file mode 100644 index 0000000000..fe99baf093 --- /dev/null +++ b/ui/litellm-dashboard/src/components/guardrails/GuardrailTestPanel.test.tsx @@ -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( + + ); + + // 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"); + }); + }); +}); + diff --git a/ui/litellm-dashboard/src/components/guardrails/GuardrailTestPanel.tsx b/ui/litellm-dashboard/src/components/guardrails/GuardrailTestPanel.tsx new file mode 100644 index 0000000000..8cda73790c --- /dev/null +++ b/ui/litellm-dashboard/src/components/guardrails/GuardrailTestPanel.tsx @@ -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) => { + 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 ( +
+ {/* Header */} +
+
+
+
+

Test Guardrails:

+
+ {guardrailNames.map((name) => ( +
+ {name} +
+ ))} +
+
+

+ Test {guardrailNames.length > 1 ? "guardrails" : "guardrail"} and compare results +

+
+
+
+ + {/* Input Section */} +
+
+
+
+
+ + + + +
+ {inputText && ( + + )} +
+