diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 27cdc483d4a..67d935e34e3 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -7613,6 +7613,18 @@ ], "title": "Messages" }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, "text": { "title": "Text", "type": "string" diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index b6a2d8d9069..1ed67a93d94 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -2238,7 +2238,10 @@ async def apply_guardrail( if litellm_logging_obj is not None: _patch_logging_obj_for_guardrail(litellm_logging_obj, request) - request_data: dict = {"messages": request.messages} if request.messages else {} + request_data: dict = { + **({"messages": request.messages} if request.messages is not None else {}), + **({"metadata": request.metadata} if request.metadata is not None else {}), + } _input_type = _resolve_guardrail_input_type(active_guardrail, request.input_type) guardrailed_inputs = await active_guardrail.apply_guardrail( inputs={"texts": [request.text]}, diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index ee0e575d2e8..f3410935ec7 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -1039,6 +1039,7 @@ class ApplyGuardrailRequest(BaseModel): entities: Optional[List[PiiEntityType]] = None input_type: str = "request" messages: Optional[List[Dict[str, Any]]] = None + metadata: Dict[str, Any] | None = None class ApplyGuardrailResponse(BaseModel): diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py index 21e7186fca3..359b1807344 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py @@ -1291,6 +1291,136 @@ async def test_apply_guardrail_invokes_logging_pipeline(mocker): } +def _patch_apply_guardrail_env(mocker, guardrail_result): + mock_guardrail = mocker.Mock() + mock_guardrail.apply_guardrail = AsyncMock(return_value=guardrail_result) + + 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 + ) + + mock_logging_obj = mocker.Mock() + mock_logging_obj.async_success_handler = AsyncMock() + mock_logging_obj.model_call_details = {} + mock_processor = mocker.Mock() + mock_processor.common_processing_pre_call_logic = AsyncMock( + return_value=({"guardrail_name": "test-guardrail"}, mock_logging_obj) + ) + mocker.patch( + "litellm.proxy.common_request_processing.ProxyBaseLLMRequestProcessing", + return_value=mock_processor, + ) + + mock_proxy_logging = mocker.Mock() + mock_proxy_logging.post_call_success_hook = AsyncMock() + mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging) + mocker.patch("litellm.proxy.proxy_server.general_settings", {}) + mocker.patch("litellm.proxy.proxy_server.proxy_config", mocker.Mock()) + mocker.patch("litellm.proxy.proxy_server.version", "test") + mocker.patch("litellm.litellm_core_utils.thread_pool_executor.executor") + + return mock_guardrail + + +@pytest.mark.asyncio +async def test_apply_guardrail_forwards_metadata_to_guardrail(mocker): + """Client-supplied metadata must reach apply_guardrail via request_data so + parameterized custom guardrails can read per-request configuration.""" + mock_guardrail = _patch_apply_guardrail_env(mocker, {"texts": ["ok"]}) + + request = ApplyGuardrailRequest( + guardrail_name="test-guardrail", + text="What are tax loopholes?", + metadata={"forbidden_topics": ["tax"]}, + ) + await apply_guardrail( + fastapi_request=mocker.Mock(), + request=request, + user_api_key_dict=UserAPIKeyAuth(), + ) + + mock_guardrail.apply_guardrail.assert_awaited_once_with( + inputs={"texts": ["What are tax loopholes?"]}, + request_data={"metadata": {"forbidden_topics": ["tax"]}}, + input_type="request", + ) + + +@pytest.mark.asyncio +async def test_apply_guardrail_forwards_metadata_and_messages_together(mocker): + """metadata and messages must coexist in request_data; the dict merge must + not clobber messages when both fields are sent.""" + mock_guardrail = _patch_apply_guardrail_env(mocker, {"texts": ["ok"]}) + + messages = [{"role": "user", "content": "What are tax loopholes?"}] + request = ApplyGuardrailRequest( + guardrail_name="test-guardrail", + text="What are tax loopholes?", + messages=messages, + metadata={"forbidden_topics": ["tax"]}, + ) + await apply_guardrail( + fastapi_request=mocker.Mock(), + request=request, + user_api_key_dict=UserAPIKeyAuth(), + ) + + mock_guardrail.apply_guardrail.assert_awaited_once_with( + inputs={"texts": ["What are tax loopholes?"]}, + request_data={ + "messages": messages, + "metadata": {"forbidden_topics": ["tax"]}, + }, + input_type="request", + ) + + +@pytest.mark.asyncio +async def test_apply_guardrail_omits_metadata_when_not_sent(mocker): + """Without metadata, request_data stays empty (backward-compatible).""" + mock_guardrail = _patch_apply_guardrail_env(mocker, {"texts": ["ok"]}) + + request = ApplyGuardrailRequest(guardrail_name="test-guardrail", text="hello") + await apply_guardrail( + fastapi_request=mocker.Mock(), + request=request, + user_api_key_dict=UserAPIKeyAuth(), + ) + + mock_guardrail.apply_guardrail.assert_awaited_once_with( + inputs={"texts": ["hello"]}, + request_data={}, + input_type="request", + ) + + +@pytest.mark.asyncio +async def test_apply_guardrail_forwards_explicit_empty_messages_and_metadata(mocker): + """Explicitly-sent empty messages/metadata must be forwarded, not dropped; + only omitted fields stay out of request_data.""" + mock_guardrail = _patch_apply_guardrail_env(mocker, {"texts": ["ok"]}) + + request = ApplyGuardrailRequest( + guardrail_name="test-guardrail", + text="hello", + messages=[], + metadata={}, + ) + await apply_guardrail( + fastapi_request=mocker.Mock(), + request=request, + user_api_key_dict=UserAPIKeyAuth(), + ) + + mock_guardrail.apply_guardrail.assert_awaited_once_with( + inputs={"texts": ["hello"]}, + request_data={"messages": [], "metadata": {}}, + input_type="request", + ) + + @pytest.mark.asyncio async def test_get_guardrail_info_endpoint_config_guardrail(mocker): """ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.test.tsx index 94ed707f31d..d6f5f22616b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.test.tsx @@ -53,7 +53,72 @@ describe("GuardrailTestPanel", () => { // Verify onSubmit was called with the correct text await waitFor(() => { - expect(mockOnSubmit).toHaveBeenCalledWith("Test input text"); + expect(mockOnSubmit).toHaveBeenCalledWith("Test input text", null); }); }); + + it("should submit parsed metadata when a JSON object is provided", async () => { + /** + * Tests that a JSON object typed into the Metadata field is parsed and + * passed to onSubmit so it reaches the apply_guardrail request body. + */ + const user = userEvent.setup(); + + render( + , + ); + + const textarea = screen.getByPlaceholderText("Enter text to test with guardrails..."); + await user.type(textarea, "Test input text"); + + const metadataField = screen.getByPlaceholderText('{"forbidden_topics": ["tax", "finance"]}'); + await user.click(metadataField); + await user.paste('{"forbidden_topics": ["tax"]}'); + + await user.click(screen.getByRole("button", { name: /Test 2 guardrails/ })); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith("Test input text", { forbidden_topics: ["tax"] }); + }); + }); + + it("should block submission and show an error for invalid metadata JSON", async () => { + /** + * Tests that invalid JSON in the Metadata field prevents submission + * instead of silently sending a request without metadata. + */ + const user = userEvent.setup(); + + render( + , + ); + + const textarea = screen.getByPlaceholderText("Enter text to test with guardrails..."); + await user.type(textarea, "Test input text"); + + const metadataField = screen.getByPlaceholderText('{"forbidden_topics": ["tax", "finance"]}'); + await user.click(metadataField); + await user.paste("{not json"); + + await user.click(screen.getByRole("button", { name: /Test 2 guardrails/ })); + + await waitFor(() => { + expect(screen.getByText("Invalid JSON")).toBeInTheDocument(); + }); + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx index a65b980d8b8..e310da55063 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx @@ -10,7 +10,7 @@ const { Text } = Typography; interface GuardrailTestPanelProps { guardrailNames: string[]; - onSubmit: (text: string) => void; + onSubmit: (text: string, metadata?: Record | null) => void; isLoading: boolean; results: Array<{ guardrailName: string; response_text: string; latency: number }> | null; errors: Array<{ guardrailName: string; error: Error; latency: number }> | null; @@ -26,6 +26,23 @@ export function GuardrailTestPanel({ onClose, }: GuardrailTestPanelProps) { const [inputText, setInputText] = useState(""); + const [metadataText, setMetadataText] = useState(""); + const [metadataError, setMetadataError] = useState(null); + + const parseMetadata = (raw: string): { metadata: Record | null; error: string | null } => { + if (!raw.trim()) { + return { metadata: null, error: null }; + } + try { + const parsed = JSON.parse(raw); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { metadata: null, error: "Metadata must be a JSON object" }; + } + return { metadata: parsed, error: null }; + } catch { + return { metadata: null, error: "Invalid JSON" }; + } + }; const handleSubmit = () => { if (!inputText.trim()) { @@ -33,7 +50,15 @@ export function GuardrailTestPanel({ return; } - onSubmit(inputText); + const { metadata, error } = parseMetadata(metadataText); + if (error) { + setMetadataError(error); + NotificationsManager.fromBackend(`Metadata: ${error}`); + return; + } + setMetadataError(null); + + onSubmit(inputText, metadata); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -142,6 +167,33 @@ export function GuardrailTestPanel({ +
+
+ + + + +
+