From 146085669c1f3a70e413aeb4cffb1d522706e47b Mon Sep 17 00:00:00 2001 From: Yucheng He Date: Tue, 15 Sep 2026 19:14:49 -0700 Subject: [PATCH] fix(guardrails): run Singulr logging_only through the base hook, key MCP scans off the proxy call type and type the payloads Removes the Singulr async_logging_hook and logging_hook overrides so logging_only runs through CustomGuardrail.async_logging_hook: the response scope reaches Singulr as an assistant message instead of a raw ModelResponse dump, a vendor timeout is recorded as guardrail_failed_to_respond, a request-scope block ends the scan, and the sync success callback thread makes no Singulr call. Decides MCP versus LLM by the proxy logging object's call_type (then the call_type or server-only markers in request_data), never by name, arguments or mcp_tool_name keys a client can put in a chat body. REST /mcp-rest/tools/call pre-scans reach Singulr as mcp_request and a non-mapping arguments value is forwarded as tool_arguments instead of raising. should_block is a strict bool defaulting to false so a null verdict is an invalid response that block_on_error decides; payload fields drop Any for Sequence, Mapping and AssistantMessage types; metadata carries only the keys present; docstrings and section comments removed per the repo comment policy. --- .../guardrail_hooks/singulr/singulr.py | 171 +----- .../guardrails/guardrail_hooks/singulr.py | 16 +- .../guardrail_hooks/test_singulr.py | 577 +++++++----------- 3 files changed, 256 insertions(+), 508 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py b/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py index e0dcdf02069..a91812bb474 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py +++ b/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py @@ -1,8 +1,6 @@ -import asyncio import json import os from collections.abc import Mapping, Sequence -from datetime import datetime, timezone from types import MappingProxyType from typing import Any, Final from urllib.parse import urlparse @@ -37,11 +35,7 @@ from litellm.types.proxy.guardrails.guardrail_hooks.singulr import ( ToolCall, ToolCallFunction, ) -from litellm.types.utils import ( - GenericGuardrailAPIInputs, - GuardrailStatus, - StandardLoggingGuardrailInformation, -) +from litellm.types.utils import CallTypes, GenericGuardrailAPIInputs _DEFAULT_API_BASE: Final = "http://localhost:8003" _GUARD_ENDPOINT: Final = "/api/v1/ai-gateway/litellm-v2" @@ -117,13 +111,6 @@ class SingulrGuardrail(CustomGuardrail): @staticmethod def _metadata_containers(request_data: Mapping[str, Any]) -> tuple[Mapping[str, Any], ...]: - """Candidate metadata dicts to check, in priority order. - - Most call paths put metadata at the top level of ``request_data`` - (``litellm_metadata`` or ``metadata``). ``post_mcp_call`` instead hands - us ``litellm_logging_obj.model_call_details``, which nests it under - ``litellm_params`` instead, so that's checked as a fallback. - """ litellm_params: Final = request_data.get("litellm_params") or _EMPTY_MAPPING return tuple( container @@ -153,7 +140,7 @@ class SingulrGuardrail(CustomGuardrail): return None @classmethod - def _build_metadata(cls, request_data: Mapping[str, Any]) -> Mapping[str, Any] | None: + def _build_metadata(cls, request_data: Mapping[str, Any]) -> Mapping[str, str] | None: fields: Final = ( "user_api_key_alias", "user_api_key_user_id", @@ -279,13 +266,30 @@ class SingulrGuardrail(CustomGuardrail): ) return inputs + @staticmethod + def _mcp_tool_name(request_data: Mapping[str, Any]) -> str | None: + return request_data.get("mcp_tool_name") or request_data.get("name") + + @staticmethod + def _mcp_arguments(request_data: Mapping[str, Any]) -> object: + arguments: Final = request_data.get("mcp_arguments") + return arguments if arguments is not None else request_data.get("arguments") + + @staticmethod + def _is_mcp_call(request_data: Mapping[str, Any], logging_obj: LiteLLMLoggingObj | None) -> bool: + call_type: Final = logging_obj.call_type if logging_obj is not None else request_data.get("call_type") + if call_type is not None: + return call_type == CallTypes.call_mcp_tool.value + model: Final = request_data.get("model") + return "mcp_tool_name" in request_data or (isinstance(model, str) and model.startswith(_MCP_MODEL_PREFIX)) + async def _apply_guardrail_on_mcp_request(self, request_data: Mapping[str, Any]) -> None: metadata: Final = self._build_metadata(request_data=request_data) singulr_mcp_obj = SingulrMcpGuardrailPayload( guardrail_scope="mcp_request", - tool_name=request_data.get("mcp_tool_name"), - tool_arguments=request_data.get("mcp_arguments"), + tool_name=self._mcp_tool_name(request_data), + tool_arguments=self._mcp_arguments(request_data), mcp_server_name=request_data.get("mcp_server_name"), metadata=metadata, ) @@ -398,134 +402,6 @@ class SingulrGuardrail(CustomGuardrail): ) return inputs - def _logging_only_response_payload( - self, - kwargs: Mapping[str, Any], - result: Any, # noqa: ANN401 # result can be any callback shape - ) -> Mapping[str, Any]: - metadata: Final = self._build_metadata(request_data=kwargs) - try: - return SingulrGuardrailPayload( - correlation_id=kwargs.get("litellm_call_id"), - model_name=kwargs.get("model"), - guardrail_scope="response", - response=result, - metadata=metadata, - ).model_dump(mode="json") - except Exception as exc: # noqa: BLE001 # result can be any callback shape; fall back to a stringified report - verbose_proxy_logger.debug("Singulr: could not JSON-serialize response, falling back: %s", exc) - return { # mutable-ok: short-lived JSON payload dict - "correlation_id": kwargs.get("litellm_call_id"), - "guardrail_scope": "response", - "response": str(result), - "metadata": metadata, - } - - async def _report_logging_only( - self, - kwargs: Mapping[str, Any], - result: Any, # noqa: ANN401 # result can be any callback shape - ) -> tuple[SingulrGuardrailResponse | None, ...]: - messages: Final = kwargs.get("messages") or () - request_verdict: Final = ( - await self._call_api( - SingulrGuardrailPayload( - correlation_id=kwargs.get("litellm_call_id"), - model_name=kwargs.get("model"), - guardrail_scope="request", - messages=messages, - metadata=self._build_metadata(request_data=kwargs), - ).model_dump(mode="json") - ) - if messages - else None - ) - response_verdict: Final = ( - await self._call_api(self._logging_only_response_payload(kwargs=kwargs, result=result)) if result else None - ) - return (request_verdict, response_verdict) - - async def _logging_only_guardrail_status( - self, - kwargs: Mapping[str, Any], - result: Any, # noqa: ANN401 # result can be any callback shape - ) -> GuardrailStatus | None: - """``None`` means no verdict was reached, so nothing should be logged.""" - try: - verdicts: Final = await self._report_logging_only(kwargs=kwargs, result=result) - except GuardrailRaisedException: - return "guardrail_intervened" - except Exception as exc: # noqa: BLE001 # logging_only must never break the request - verbose_proxy_logger.debug("Singulr: logging_only hook swallowed exception: %s", exc) - return None - if any(verdict is not None and verdict.should_block for verdict in verdicts): - return "guardrail_intervened" - return "success" - - @staticmethod - def _is_mcp_call(kwargs: Mapping[str, Any]) -> bool: - model: Final = kwargs.get("model") - return isinstance(model, str) and model.startswith(_MCP_MODEL_PREFIX) - - async def async_logging_hook( - self, - kwargs: dict, # mutable-ok: matches CustomLogger override; mutated via setdefault - result: Any, # noqa: ANN401 # required by CustomLogger.async_logging_hook override signature - call_type: str, - ) -> tuple[dict, Any]: - if self._is_mcp_call(kwargs): - verbose_proxy_logger.debug("Singulr: skipping logging_only report for MCP call %s", kwargs.get("model")) - return kwargs, result - - start_time: Final = datetime.now(timezone.utc) - guardrail_status: Final = await self._logging_only_guardrail_status(kwargs=kwargs, result=result) - if guardrail_status is None: - return kwargs, result - - end_time: Final = datetime.now(timezone.utc) - slg: Final = StandardLoggingGuardrailInformation( - guardrail_name=self.guardrail_name or "singulr", - guardrail_mode=GuardrailEventHooks.logging_only, - guardrail_status=guardrail_status, - start_time=start_time.timestamp(), - end_time=end_time.timestamp(), - duration=(end_time - start_time).total_seconds(), - masked_entity_count=None, - ) - standard_logging_object: Final = kwargs.setdefault( - "standard_logging_object", - {}, # mutable-ok: shared, mutated accumulator - ) - existing = standard_logging_object.get("guardrail_information") - if isinstance(existing, list): - existing.append(slg) - else: - standard_logging_object["guardrail_information"] = [slg] # mutable-ok: shared accumulator - - return kwargs, result - - def logging_hook( - self, - kwargs: dict, # mutable-ok: required by CustomLogger.logging_hook override signature - result: Any, # noqa: ANN401 # required by CustomLogger.logging_hook override signature - call_type: str, - ) -> tuple[dict, Any]: - try: - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - if loop.is_running(): - verbose_proxy_logger.debug( - "Singulr: sync logging_hook called from a running loop; skipping logging_only report" - ) - return kwargs, result - loop.run_until_complete(self.async_logging_hook(kwargs=kwargs, result=result, call_type=call_type)) - except Exception as exc: # noqa: BLE001 # logging_only must never break the request - verbose_proxy_logger.debug("Singulr: sync logging_hook swallowed exception: %s", exc) - return kwargs, result - @log_guardrail_information async def apply_guardrail( self, @@ -544,15 +420,16 @@ class SingulrGuardrail(CustomGuardrail): len(structured_messages), ) + is_mcp_call: Final = self._is_mcp_call(request_data, logging_obj) if input_type == "request": - if request_data.get("mcp_tool_name"): + if is_mcp_call: await self._apply_guardrail_on_mcp_request(request_data=request_data) return inputs return await self._apply_guardrail_on_request( inputs=inputs, texts=texts, structured_messages=structured_messages, request_data=request_data ) elif input_type == "response": - if request_data.get("call_type") == "call_mcp_tool": + if is_mcp_call: return await self._apply_guardrail_on_mcp_response( inputs=inputs, texts=texts, request_data=request_data ) diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/singulr.py b/litellm/types/proxy/guardrails/guardrail_hooks/singulr.py index fd349b44e0c..ea1e6238181 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/singulr.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/singulr.py @@ -1,5 +1,5 @@ from collections.abc import Mapping, Sequence -from typing import Any, Literal +from typing import Literal from pydantic import BaseModel, Field @@ -33,27 +33,27 @@ class SingulrGuardrailPayload(BaseModel): model_name: str | None = None model_provider_name: str | None = None guardrail_scope: str | None = None - messages: Sequence[Any] | None = None + messages: Sequence[Mapping[str, object]] | None = None images: Sequence[str] | None = None - tools: Sequence[Any] | None = None # pyright: ignore[reportExplicitAny] # forwards caller-supplied OpenAI tool defs verbatim - response: Any = None # pyright: ignore[reportExplicitAny] # logging_only reports raw litellm callback results (ModelResponse, EmbeddingResponse, etc.) - metadata: Mapping[str, Any] | None = None + tools: Sequence[Mapping[str, object]] | None = None + response: AssistantMessage | None = None + metadata: Mapping[str, str] | None = None class SingulrMcpGuardrailPayload(BaseModel): model_name: str | None = None guardrail_scope: str | None = None tool_name: str | None = None - tool_arguments: Mapping[str, Any] | None = None + tool_arguments: object = None mcp_server_name: str | None = None tool_result: Sequence[str] | None = None - metadata: Mapping[str, Any] | None = None + metadata: Mapping[str, str] | None = None class SingulrGuardrailResponse(BaseModel): """Response returned by the Singulr guardrail API.""" - should_block: bool | None = None + should_block: bool = False blocking_due_to: str | None = None diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_singulr.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_singulr.py index 7a228a7c3fb..b775d399b86 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_singulr.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_singulr.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import httpx import pytest +import litellm from litellm.exceptions import GuardrailRaisedException from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.guardrails.guardrail_hooks.singulr.singulr import SingulrGuardrail @@ -14,12 +15,8 @@ from litellm.types.proxy.guardrails.guardrail_hooks.singulr import ( from litellm.types.utils import ModelResponse -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- @pytest.fixture def singulr_guardrail(): - """Create a SingulrGuardrail instance with test credentials.""" return SingulrGuardrail( singulr_api_base="https://api.test.singulr.ai", singulr_api_key="test_token_1234", @@ -31,8 +28,26 @@ def singulr_guardrail(): ) +@pytest.fixture +def logging_only_guardrail(): + return SingulrGuardrail( + singulr_api_base="https://api.test.singulr.ai", + singulr_api_key="test_token_1234", + singulr_guardrail_id="test_guardrail_id", + singulr_application_id="test_enforcement_entity", + guardrail_name="test-singulr", + event_hook="logging_only", + default_on=True, + ) + + +def _logging_obj(call_type: str) -> MagicMock: + logging_obj = MagicMock() + logging_obj.call_type = call_type + return logging_obj + + def _make_response(body: dict) -> MagicMock: - """Build a mock httpx response with the given JSON body.""" mock = MagicMock() mock.json.return_value = body mock.raise_for_status = MagicMock() @@ -40,11 +55,6 @@ def _make_response(body: dict) -> MagicMock: return mock -# --------------------------------------------------------------------------- -# Configuration -# --------------------------------------------------------------------------- - - class TestSingulrConfiguration: def test_init_with_explicit_credentials(self): guardrail = SingulrGuardrail( @@ -59,10 +69,6 @@ class TestSingulrConfiguration: assert guardrail.singulr_application_id == "entity123" def test_api_base_strips_surrounding_whitespace(self): - """Regression: a UI-saved api_base with a trailing space - (e.g. "https://custom.api.local ") broke urlparse's port parsing and - made every guardrail call fail with a connection error, even though - the configured host was reachable.""" guardrail = SingulrGuardrail( singulr_api_key="test_key", singulr_api_base=" https://custom.api.local ", @@ -74,8 +80,6 @@ class TestSingulrConfiguration: assert guardrail.singulr_api_base == "https://custom.api.local" def test_non_local_http_api_base_raises(self): - """Guardrail payloads carry the API token and full conversation - content, so a non-local endpoint must use HTTPS.""" with pytest.raises(ValueError, match="HTTPS"): SingulrGuardrail(singulr_api_key="test_key", singulr_api_base="http://guardrails.singulr.ai") @@ -106,11 +110,6 @@ class TestSingulrConfiguration: ] -# --------------------------------------------------------------------------- -# Payload construction for real proxy requests (request_data present) -# --------------------------------------------------------------------------- - - class TestSingulrRequestPayload: @pytest.mark.asyncio async def test_model_and_messages_are_forwarded(self, singulr_guardrail): @@ -130,9 +129,6 @@ class TestSingulrRequestPayload: @pytest.mark.asyncio async def test_structured_messages_are_forwarded_verbatim(self, singulr_guardrail): - """When structured_messages are provided (e.g. system + user turns), - they must be sent as-is instead of being flattened into single - user-role messages built from texts.""" resp = _make_response({"should_block": False}) structured_messages = [ {"role": "system", "content": "Be concise."}, @@ -180,8 +176,6 @@ class TestSingulrRequestPayload: ids=["tools_alone", "images_alone"], ) async def test_tools_or_images_alone_still_trigger_the_api_call(self, singulr_guardrail, extra_inputs): - """Regression: a request with only tool definitions or only images and - no text must still be checked, not skipped for lack of a message.""" resp = _make_response({"should_block": False}) with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: await singulr_guardrail.apply_guardrail( @@ -195,9 +189,6 @@ class TestSingulrRequestPayload: @pytest.mark.asyncio async def test_tools_are_forwarded(self, singulr_guardrail): - """Regression: tool/function definitions are client-controlled and can - carry prompt-injection content, so they must reach Singulr for - inspection instead of only messages and images.""" resp = _make_response({"should_block": False}) tools = [ { @@ -216,10 +207,6 @@ class TestSingulrRequestPayload: @pytest.mark.asyncio async def test_responses_api_mcp_tools_are_forwarded(self, singulr_guardrail): - """Regression: Responses API tools (e.g. {"type": "mcp", "server_label": ...}) - have no "function" key, unlike Chat Completions tools. SingulrGuardrailPayload - rejected them with a pydantic ValidationError, turning every Responses API - request carrying an MCP tool into a 500.""" resp = _make_response({"should_block": False}) tools = [ { @@ -240,8 +227,6 @@ class TestSingulrRequestPayload: @pytest.mark.asyncio async def test_user_api_key_alias_is_forwarded_in_metadata(self, singulr_guardrail): - """Regression: the alias must be sent as {"user_api_key_alias": }, - not as a dict whose key is the alias value itself.""" resp = _make_response({"should_block": False}) request_data = {"litellm_metadata": {"user_api_key_alias": "my-key-alias"}} with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: @@ -420,8 +405,6 @@ class TestSingulrRequestPayload: @pytest.mark.asyncio async def test_no_key_alias_available_sends_no_metadata(self, singulr_guardrail): - """Regression: with no alias found, metadata must be omitted (None), - not a {None: None} dict that fails payload validation.""" resp = _make_response({"should_block": False}) with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: await singulr_guardrail.apply_guardrail( @@ -433,11 +416,6 @@ class TestSingulrRequestPayload: assert sent_payload["metadata"] is None -# --------------------------------------------------------------------------- -# Payload construction for responses -# --------------------------------------------------------------------------- - - class TestSingulrResponsePayload: @pytest.mark.asyncio async def test_assistant_text_and_tool_calls_are_forwarded(self, singulr_guardrail): @@ -506,9 +484,6 @@ class TestSingulrResponsePayload: async def test_tool_call_type_other_than_function_is_still_scanned( self, singulr_guardrail, raw_type, expected_type ): - """Regression: a tool call whose type is absent or isn't "function" used - to raise a pydantic ValidationError while building the payload, which - escaped apply_guardrail as a 500 instead of reaching the scan at all.""" resp = _make_response({"should_block": False}) tool_call = {"id": "call_1", "function": {"name": "get_current_time", "arguments": "{}"}} inputs = { @@ -523,8 +498,6 @@ class TestSingulrResponsePayload: @pytest.mark.asyncio async def test_non_string_tool_call_arguments_are_serialized(self, singulr_guardrail): - """Some providers hand back already-parsed arguments; they must be - scanned as JSON text rather than crashing the payload build.""" resp = _make_response({"should_block": False}) inputs = { "texts": [], @@ -539,7 +512,6 @@ class TestSingulrResponsePayload: @pytest.mark.asyncio async def test_block_verdict_still_raises_for_a_non_function_tool_call(self, singulr_guardrail): - """The point of scanning these calls: the verdict must still be enforced.""" resp = _make_response({"should_block": True, "blocking_due_to": "dangerous_tool"}) inputs = { "texts": [], @@ -551,11 +523,6 @@ class TestSingulrResponsePayload: assert "dangerous_tool" in str(exc_info.value) -# --------------------------------------------------------------------------- -# Allow / block decisions -# --------------------------------------------------------------------------- - - class TestSingulrAllowAction: @pytest.mark.asyncio @pytest.mark.parametrize( @@ -564,8 +531,6 @@ class TestSingulrAllowAction: ids=["should_block_false", "should_block_omitted"], ) async def test_should_block_falsy_returns_inputs_unchanged_on_request(self, singulr_guardrail, guard_response): - """should_block is optional on the wire; a response that omits it - entirely must be treated as allow, not block.""" resp = _make_response(guard_response) inputs = {"texts": ["How do I reset my password?"]} with patch.object(singulr_guardrail.async_handler, "post", return_value=resp): @@ -605,6 +570,34 @@ class TestSingulrAllowAction: ) assert result is inputs + @pytest.mark.asyncio + async def test_explicit_null_verdict_fails_closed_by_default(self, singulr_guardrail): + resp = _make_response({"should_block": None}) + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp): + with pytest.raises(GuardrailRaisedException, match="invalid response"): + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data={"model": "gpt-4o"}, + input_type="request", + ) + + @pytest.mark.asyncio + async def test_explicit_null_verdict_fails_open_when_block_on_error_false(self): + guardrail = SingulrGuardrail( + singulr_api_base="https://api.test.singulr.ai", + singulr_api_key="test_token_1234", + guardrail_name="test-singulr", + block_on_error=False, + ) + resp = _make_response({"should_block": None}) + inputs = {"texts": ["hi"]} + with patch.object(guardrail.async_handler, "post", return_value=resp): + assert await guardrail._call_api({"guardrail_scope": "request"}) is None + result = await guardrail.apply_guardrail( + inputs=inputs, request_data={"model": "gpt-4o"}, input_type="request" + ) + assert result is inputs + class TestSingulrBlockAction: @pytest.mark.asyncio @@ -622,11 +615,6 @@ class TestSingulrBlockAction: @pytest.mark.asyncio async def test_should_block_true_raises_on_response(self, singulr_guardrail): - """Regression: apply_guardrail's response path compared - should_block (a bool) against the string "block", which is always - False, so a should_block=True response never blocked the assistant's - reply. It must raise on any truthy should_block, matching the - request path.""" resp = _make_response({"should_block": True, "blocking_due_to": "Toxic content detected"}) with patch.object(singulr_guardrail.async_handler, "post", return_value=resp): with pytest.raises(GuardrailRaisedException) as exc_info: @@ -650,11 +638,6 @@ class TestSingulrBlockAction: ) -# --------------------------------------------------------------------------- -# MCP tool call guardrail (pre_mcp_call / post_mcp_call) -# --------------------------------------------------------------------------- - - class TestSingulrMcpRequest: @pytest.mark.asyncio async def test_mcp_tool_name_routes_to_mcp_request_payload(self, singulr_guardrail): @@ -707,6 +690,97 @@ class TestSingulrMcpRequest: ) assert result == {"texts": []} + @pytest.mark.asyncio + async def test_mcp_rest_body_shape_routes_to_mcp_request_payload(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = {"name": "echo", "arguments": {"text": "my ssn is 123-45-6789"}, "server_id": "srv-1"} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["my ssn is 123-45-6789"], "tools": [{"type": "function"}]}, + request_data=request_data, + input_type="request", + logging_obj=_logging_obj("call_mcp_tool"), + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["guardrail_scope"] == "mcp_request" + assert sent_payload["tool_name"] == "echo" + assert sent_payload["tool_arguments"] == {"text": "my ssn is 123-45-6789"} + assert "messages" not in sent_payload + + @pytest.mark.asyncio + async def test_mcp_rest_body_without_arguments_still_routes_to_mcp_request(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": [], "tools": [{"type": "function"}]}, + request_data={"name": "echo", "server_id": "srv-1"}, + input_type="request", + logging_obj=_logging_obj("call_mcp_tool"), + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["guardrail_scope"] == "mcp_request" + assert sent_payload["tool_name"] == "echo" + assert sent_payload["tool_arguments"] is None + + @pytest.mark.asyncio + async def test_non_mapping_tool_arguments_are_forwarded_verbatim(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["raw text"], "tools": [{"type": "function"}]}, + request_data={"name": "echo", "arguments": "raw text", "server_id": "srv-1"}, + input_type="request", + logging_obj=_logging_obj("call_mcp_tool"), + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["guardrail_scope"] == "mcp_request" + assert sent_payload["tool_arguments"] == "raw text" + + @pytest.mark.asyncio + async def test_llm_request_body_keys_cannot_reroute_the_scan_to_mcp(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "my ssn is 123-45-6789"}], + "name": "x", + "arguments": {}, + "mcp_tool_name": "x", + "call_type": "call_mcp_tool", + } + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={ + "texts": ["my ssn is 123-45-6789"], + "structured_messages": [{"role": "user", "content": "my ssn is 123-45-6789"}], + }, + request_data=request_data, + input_type="request", + logging_obj=_logging_obj("acompletion"), + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["guardrail_scope"] == "request" + assert [m["content"] for m in sent_payload["messages"]] == ["my ssn is 123-45-6789"] + + @pytest.mark.asyncio + async def test_llm_response_with_spoofed_mcp_keys_still_scans_the_tool_calls(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = {"model": "gpt-4o", "messages": [], "name": "x", "arguments": {}, "mcp_tool_name": "x"} + tool_call = { + "id": "call_1", + "type": "function", + "function": {"name": "transfer_funds", "arguments": '{"amount": 5000}'}, + } + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": [], "tool_calls": [tool_call]}, + request_data=request_data, + input_type="response", + logging_obj=_logging_obj("acompletion"), + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["guardrail_scope"] == "response" + assert sent_payload["response"]["tool_calls"][0]["function"]["name"] == "transfer_funds" + class TestSingulrMcpResponse: @pytest.mark.asyncio @@ -756,8 +830,6 @@ class TestSingulrMcpResponse: @pytest.mark.asyncio async def test_mcp_response_resolves_metadata_from_nested_litellm_params(self, singulr_guardrail): - """post_mcp_call hands apply_guardrail litellm_logging_obj.model_call_details, - which nests metadata under litellm_params instead of at the top level.""" resp = _make_response({"should_block": False}) auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY) request_data = { @@ -830,10 +902,26 @@ class TestSingulrMcpResponse: ) assert result is inputs - -# --------------------------------------------------------------------------- -# apply_guardrail dispatch (request vs response vs unknown input_type) -# --------------------------------------------------------------------------- + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("request_data", "logging_obj"), + [ + ({"call_type": "call_mcp_tool", "model": "MCP: echo"}, None), + ({"model": "MCP: echo"}, None), + ({"name": "echo", "arguments": {"text": "hi"}}, _logging_obj("call_mcp_tool")), + ], + ids=["post_mcp_call_model_call_details", "logging_only_scratch_request", "rest_pre_call_logger"], + ) + async def test_mcp_response_is_detected_from_each_producer(self, singulr_guardrail, request_data, logging_obj): + resp = _make_response({"should_block": False}) + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["tool output"]}, + request_data=request_data, + input_type="response", + logging_obj=logging_obj, + ) + assert mock_post.call_args.kwargs["json"]["guardrail_scope"] == "mcp_response" class TestSingulrApplyGuardrailDispatch: @@ -850,289 +938,109 @@ class TestSingulrApplyGuardrailDispatch: assert result is inputs -# --------------------------------------------------------------------------- -# logging_only hook -# --------------------------------------------------------------------------- - - class TestSingulrLoggingHook: - @pytest.mark.asyncio - async def test_forwards_request_messages_and_response_text(self, singulr_guardrail): - resp = _make_response({"should_block": False}) - kwargs = {"messages": [{"role": "user", "content": "hi"}], "model": "gpt-4o", "litellm_call_id": "call-1"} - result = {"choices": [{"finish_reason": "stop", "message": {"content": "hello there"}}]} - with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: - await singulr_guardrail.async_logging_hook(kwargs=kwargs, result=result, call_type="acompletion") - - request_payload = mock_post.call_args_list[0].kwargs["json"] - response_payload = mock_post.call_args_list[1].kwargs["json"] - assert request_payload["guardrail_scope"] == "request" - assert request_payload["messages"] == kwargs["messages"] - assert response_payload["guardrail_scope"] == "response" - assert response_payload["response"] == result - - @pytest.mark.asyncio - async def test_forwards_user_metadata_in_both_request_and_response_payloads(self, singulr_guardrail): - resp = _make_response({"should_block": False}) + @staticmethod + def _logged_call(**overrides): kwargs = { - "messages": [{"role": "user", "content": "hi"}], "model": "gpt-4o", + "messages": [{"role": "user", "content": "hi"}], "litellm_call_id": "call-1", - "litellm_metadata": {"user_api_key_alias": "my-key-alias", "user_api_key_org_id": "org-123"}, + "litellm_params": {"metadata": {"user_api_key_alias": "my-key-alias", "user_api_key_org_id": "org-123"}}, + "standard_logging_object": {"guardrail_information": []}, } - result = {"choices": [{"finish_reason": "stop", "message": {"content": "hello there"}}]} - with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: - await singulr_guardrail.async_logging_hook(kwargs=kwargs, result=result, call_type="acompletion") - - request_payload = mock_post.call_args_list[0].kwargs["json"] - response_payload = mock_post.call_args_list[1].kwargs["json"] - expected_metadata = {"user_api_key_alias": "my-key-alias", "user_api_key_org_id": "org-123"} - assert request_payload["metadata"] == expected_metadata - assert response_payload["metadata"] == expected_metadata + return {**kwargs, **overrides} @pytest.mark.asyncio - async def test_forwards_a_real_model_response_without_swallowing_it(self, singulr_guardrail): - """Regression: a normal completion callback passes a ModelResponse, not a - dict. The response payload must carry its actual serialized content instead - of silently dropping it because ModelResponse isn't a Mapping.""" + async def test_scans_request_then_response_as_an_assistant_message(self, logging_only_guardrail): resp = _make_response({"should_block": False}) result = ModelResponse( choices=[{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "hello there"}}] ) - with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: - await singulr_guardrail.async_logging_hook(kwargs={}, result=result, call_type="acompletion") - - response_payload = mock_post.call_args.kwargs["json"] - assert response_payload["guardrail_scope"] == "response" - assert response_payload["response"]["choices"][0]["message"]["content"] == "hello there" - - @pytest.mark.asyncio - async def test_non_serializable_result_falls_back_to_string_report(self, singulr_guardrail): - """A result that pydantic can't serialize to JSON must still get reported, - as a stringified fallback, instead of raising out of the logging_only hook.""" - resp = _make_response({"should_block": False}) - - class Unserializable: - def __repr__(self) -> str: - return "" - - kwargs = {"litellm_metadata": {"user_api_key_alias": "my-key-alias"}} - with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: - await singulr_guardrail.async_logging_hook(kwargs=kwargs, result=Unserializable(), call_type="acompletion") - - response_payload = mock_post.call_args.kwargs["json"] - assert response_payload["response"] == "" - assert response_payload["metadata"] == {"user_api_key_alias": "my-key-alias"} - - @pytest.mark.asyncio - async def test_no_messages_and_no_result_skips_both_api_calls(self, singulr_guardrail): - with patch.object(singulr_guardrail.async_handler, "post") as mock_post: - returned_kwargs, returned_result = await singulr_guardrail.async_logging_hook( - kwargs={}, result=None, call_type="acompletion" + with patch.object(logging_only_guardrail.async_handler, "post", return_value=resp) as mock_post: + updated_kwargs, returned = await logging_only_guardrail.async_logging_hook( + kwargs=self._logged_call(), result=result, call_type="acompletion" ) - mock_post.assert_not_called() - assert returned_result is None - guardrail_information = returned_kwargs["standard_logging_object"]["guardrail_information"] - assert guardrail_information[0]["guardrail_status"] == "success" + + assert returned is result + scopes = [call.kwargs["json"]["guardrail_scope"] for call in mock_post.call_args_list] + assert scopes == ["request", "response"] + request_payload = mock_post.call_args_list[0].kwargs["json"] + response_payload = mock_post.call_args_list[1].kwargs["json"] + assert request_payload["messages"] == [{"role": "user", "content": "hi"}] + assert request_payload["correlation_id"] == "call-1" + assert response_payload["response"] == {"role": "assistant", "content": "hello there", "tool_calls": []} + expected_metadata = {"user_api_key_alias": "my-key-alias", "user_api_key_org_id": "org-123"} + assert request_payload["metadata"] == expected_metadata + assert response_payload["metadata"] == expected_metadata + statuses = [ + entry["guardrail_status"] for entry in updated_kwargs["standard_logging_object"]["guardrail_information"] + ] + assert statuses == ["success", "success"] @pytest.mark.asyncio - async def test_mcp_tool_call_is_not_reported(self, singulr_guardrail): - """MCP traffic is already covered by the pre/post_mcp_call hooks, which send - the richer mcp_request/mcp_response payloads. The logging_only hook sees the - same call again with model="MCP: " and must skip it so Singulr - doesn't get a duplicate, lower-fidelity report of every tool call.""" - kwargs = {"model": "MCP: get_weather", "messages": [{"role": "user", "content": "hi"}]} - with patch.object(singulr_guardrail.async_handler, "post") as mock_post: - updated_kwargs, result = await singulr_guardrail.async_logging_hook( - kwargs=kwargs, result={"choices": []}, call_type="acompletion" - ) - mock_post.assert_not_called() - assert "standard_logging_object" not in updated_kwargs - assert result == {"choices": []} - - @pytest.mark.asyncio - async def test_mcp_list_tools_call_is_not_reported(self, singulr_guardrail): - kwargs = {"model": "MCP: list_tools", "messages": [{"role": "user", "content": "hi"}]} - with patch.object(singulr_guardrail.async_handler, "post") as mock_post: - await singulr_guardrail.async_logging_hook(kwargs=kwargs, result=None, call_type="acompletion") - mock_post.assert_not_called() - - @pytest.mark.asyncio - async def test_non_mcp_model_is_still_reported(self, singulr_guardrail): - """Guard against the skip being too broad: a normal LLM call whose model - merely mentions MCP later in the name must still be reported.""" - resp = _make_response({"should_block": False}) - kwargs = {"model": "gpt-4o-mcp", "messages": [{"role": "user", "content": "hi"}]} - with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: - updated_kwargs, _ = await singulr_guardrail.async_logging_hook( - kwargs=kwargs, result=None, call_type="acompletion" - ) - mock_post.assert_called_once() - assert updated_kwargs["standard_logging_object"]["guardrail_information"][0]["guardrail_status"] == "success" - - @pytest.mark.asyncio - async def test_records_standard_logging_guardrail_information(self, singulr_guardrail): - resp = _make_response({"should_block": False}) - kwargs = {"messages": [{"role": "user", "content": "hi"}]} - with patch.object(singulr_guardrail.async_handler, "post", return_value=resp): - updated_kwargs, _ = await singulr_guardrail.async_logging_hook( - kwargs=kwargs, result=None, call_type="acompletion" - ) - guardrail_information = updated_kwargs["standard_logging_object"]["guardrail_information"] - assert len(guardrail_information) == 1 - assert guardrail_information[0]["guardrail_name"] == "test-singulr" - assert guardrail_information[0]["guardrail_status"] == "success" - - @pytest.mark.asyncio - async def test_request_block_verdict_marks_guardrail_status_intervened(self, singulr_guardrail): - """Regression: a successful HTTP call whose body says should_block is a - real intervention. logging_only can't fail the request, so the verdict - only ever surfaces through guardrail_status, and it used to be recorded - as a plain success.""" + async def test_block_verdict_is_recorded_as_intervened_without_failing_the_call(self, logging_only_guardrail): resp = _make_response({"should_block": True, "blocking_due_to": "pii"}) - kwargs = {"messages": [{"role": "user", "content": "my ssn is 123-45-6789"}]} - with patch.object(singulr_guardrail.async_handler, "post", return_value=resp): - updated_kwargs, result = await singulr_guardrail.async_logging_hook( - kwargs=kwargs, result=None, call_type="acompletion" + with patch.object(logging_only_guardrail.async_handler, "post", return_value=resp): + updated_kwargs, returned = await logging_only_guardrail.async_logging_hook( + kwargs=self._logged_call(messages=[{"role": "user", "content": "my ssn is 123-45-6789"}]), + result=None, + call_type="acompletion", ) - assert result is None - guardrail_information = updated_kwargs["standard_logging_object"]["guardrail_information"] - assert guardrail_information[0]["guardrail_status"] == "guardrail_intervened" + assert returned is None + entries = updated_kwargs["standard_logging_object"]["guardrail_information"] + assert entries[0]["guardrail_status"] == "guardrail_intervened" + assert entries[0]["guardrail_mode"] == "logging_only" + assert "Blocking due to pii" in str(entries[0]["guardrail_response"]) @pytest.mark.asyncio - async def test_response_block_verdict_marks_guardrail_status_intervened(self, singulr_guardrail): - """Only the response leg blocks here, so a request verdict of False must - not mask it.""" - responses = [_make_response({"should_block": False}), _make_response({"should_block": True})] - kwargs = {"messages": [{"role": "user", "content": "hi"}]} - with patch.object(singulr_guardrail.async_handler, "post", side_effect=responses): - updated_kwargs, _ = await singulr_guardrail.async_logging_hook( - kwargs=kwargs, result={"choices": []}, call_type="acompletion" + async def test_vendor_timeout_is_recorded_as_failed_to_respond(self, logging_only_guardrail): + timeout = litellm.Timeout("Singulr timed out", model="gpt-4o", llm_provider="singulr") + with patch.object(logging_only_guardrail.async_handler, "post", side_effect=timeout): + updated_kwargs, returned = await logging_only_guardrail.async_logging_hook( + kwargs=self._logged_call(), result=None, call_type="acompletion" ) - guardrail_information = updated_kwargs["standard_logging_object"]["guardrail_information"] - assert guardrail_information[0]["guardrail_status"] == "guardrail_intervened" + assert returned is None + entries = updated_kwargs["standard_logging_object"]["guardrail_information"] + assert [entry["guardrail_status"] for entry in entries] == ["guardrail_failed_to_respond"] + assert "timed out" in str(entries[0]["guardrail_response"]) @pytest.mark.asyncio - async def test_block_verdict_still_reports_both_legs_and_returns_result(self, singulr_guardrail): - """A block verdict on the request leg is logging-only: it must not - short-circuit the response report or alter what the hook returns.""" - resp = _make_response({"should_block": True}) - kwargs = {"messages": [{"role": "user", "content": "hi"}]} - result = {"choices": [{"finish_reason": "stop", "message": {"content": "hello"}}]} - with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: - returned_kwargs, returned_result = await singulr_guardrail.async_logging_hook( - kwargs=kwargs, result=result, call_type="acompletion" - ) - assert [call.kwargs["json"]["guardrail_scope"] for call in mock_post.call_args_list] == ["request", "response"] - assert returned_result is result - assert returned_kwargs is kwargs + async def test_mcp_tool_result_is_scanned_as_mcp_response(self, logging_only_guardrail): + from mcp.types import CallToolResult, TextContent - @pytest.mark.asyncio - async def test_api_error_marks_guardrail_status_intervened(self, singulr_guardrail): - """With block_on_error=True (the default), a transport failure while - reporting to Singulr raises internally; async_logging_hook must catch - it, mark the status accordingly, and still return (kwargs, result) - instead of propagating -- logging_only must never block the call.""" - kwargs = {"messages": [{"role": "user", "content": "hi"}]} - with patch.object( - singulr_guardrail.async_handler, - "post", - side_effect=httpx.TransportError("connection refused"), - ): - updated_kwargs, result = await singulr_guardrail.async_logging_hook( - kwargs=kwargs, result=None, call_type="acompletion" - ) - assert result is None - guardrail_information = updated_kwargs["standard_logging_object"]["guardrail_information"] - assert guardrail_information[0]["guardrail_status"] == "guardrail_intervened" - - @pytest.mark.asyncio - async def test_unexpected_exception_is_swallowed_without_recording_guardrail_information(self, singulr_guardrail): - """A non-guardrail exception (e.g. a bug in a downstream integration) - must not propagate out of the logging_only hook, and must not record - standard_logging_guardrail_information since no verdict was reached.""" - kwargs = {"messages": [{"role": "user", "content": "hi"}]} - with patch.object(singulr_guardrail.async_handler, "post", side_effect=RuntimeError("boom")): - updated_kwargs, result = await singulr_guardrail.async_logging_hook( - kwargs=kwargs, result=None, call_type="acompletion" - ) - assert result is None - assert "standard_logging_object" not in updated_kwargs - - @pytest.mark.asyncio - async def test_appends_to_existing_guardrail_information_list(self, singulr_guardrail): resp = _make_response({"should_block": False}) - existing_entry = {"guardrail_name": "other-guardrail"} - kwargs = { - "messages": [{"role": "user", "content": "hi"}], - "standard_logging_object": {"guardrail_information": [existing_entry]}, - } - with patch.object(singulr_guardrail.async_handler, "post", return_value=resp): - updated_kwargs, _ = await singulr_guardrail.async_logging_hook( - kwargs=kwargs, result=None, call_type="acompletion" + result = CallToolResult(content=[TextContent(type="text", text="ssn 123-45-6789")]) + with patch.object(logging_only_guardrail.async_handler, "post", return_value=resp) as mock_post: + await logging_only_guardrail.async_logging_hook( + kwargs=self._logged_call(model="MCP: get_customer_record", messages=None), + result=result, + call_type="call_mcp_tool", ) - guardrail_information = updated_kwargs["standard_logging_object"]["guardrail_information"] - assert guardrail_information[0] is existing_entry - assert guardrail_information[1]["guardrail_name"] == "test-singulr" + payloads = [call.kwargs["json"] for call in mock_post.call_args_list] + assert [payload["guardrail_scope"] for payload in payloads] == ["mcp_response"] + assert payloads[0]["tool_result"] == ["ssn 123-45-6789"] + assert payloads[0]["model_name"] == "MCP: get_customer_record" - def test_sync_logging_hook_returns_kwargs_and_result_unchanged_when_loop_running(self, singulr_guardrail): - """logging_hook is the sync entrypoint used outside an event loop; - inside a running loop it must no-op rather than deadlock or raise.""" - import asyncio - - async def _drive(): - kwargs = {"messages": [{"role": "user", "content": "hi"}]} - return singulr_guardrail.logging_hook(kwargs=kwargs, result=None, call_type="acompletion") - - returned_kwargs, returned_result = asyncio.run(_drive()) - assert returned_result is None - assert returned_kwargs == {"messages": [{"role": "user", "content": "hi"}]} - - def test_sync_logging_hook_creates_a_new_event_loop_when_none_is_set(self, singulr_guardrail): - """A thread with no current event loop must get a fresh one instead - of raising RuntimeError out of the sync entrypoint.""" + def test_sync_logging_hook_never_calls_singulr(self, logging_only_guardrail): from concurrent.futures import ThreadPoolExecutor - resp = _make_response({"should_block": False}) + kwargs = {"messages": [{"role": "user", "content": "hi"}], "standard_logging_object": {}} def _run(): - kwargs = {"messages": [{"role": "user", "content": "hi"}]} - with patch.object(singulr_guardrail.async_handler, "post", return_value=resp): - return singulr_guardrail.logging_hook(kwargs=kwargs, result=None, call_type="acompletion") + with patch.object(logging_only_guardrail.async_handler, "post") as mock_post: + returned = logging_only_guardrail.logging_hook(kwargs=kwargs, result=None, call_type="acompletion") + mock_post.assert_not_called() + return returned with ThreadPoolExecutor(max_workers=1) as pool: returned_kwargs, returned_result = pool.submit(_run).result() assert returned_result is None - guardrail_information = returned_kwargs["standard_logging_object"]["guardrail_information"] - assert guardrail_information[0]["guardrail_status"] == "success" - - def test_sync_logging_hook_swallows_unexpected_exception(self, singulr_guardrail): - """A bug surfacing from async_logging_hook itself, not just the - Singulr API call, must not propagate out of the sync entrypoint.""" - from concurrent.futures import ThreadPoolExecutor - - def _run(): - kwargs = {"messages": [{"role": "user", "content": "hi"}]} - with patch.object(singulr_guardrail, "async_logging_hook", side_effect=RuntimeError("boom")): - return singulr_guardrail.logging_hook(kwargs=kwargs, result=None, call_type="acompletion") - - with ThreadPoolExecutor(max_workers=1) as pool: - returned_kwargs, returned_result = pool.submit(_run).result() - assert returned_result is None - assert returned_kwargs == {"messages": [{"role": "user", "content": "hi"}]} - - -# --------------------------------------------------------------------------- -# HTTP call wiring (endpoint, timeout, headers) -# --------------------------------------------------------------------------- + assert returned_kwargs == {"messages": [{"role": "user", "content": "hi"}], "standard_logging_object": {}} class TestSingulrRequestWiring: @pytest.mark.asyncio async def test_sends_configured_timeout_and_calls_the_guard_endpoint(self): - """litellm_params.timeout must reach the httpx call so operators can - tighten or loosen the latency budget instead of being stuck with a - hardcoded 30s regardless of configuration.""" guardrail = SingulrGuardrail( singulr_api_key="test_key", singulr_api_base="https://api.test.singulr.ai", @@ -1168,11 +1076,6 @@ class TestSingulrBuildHeaders: assert "X-Singulr-Guardrail-Id" not in headers -# --------------------------------------------------------------------------- -# Non-JSON / malformed response handling -# --------------------------------------------------------------------------- - - class TestSingulrInvalidResponse: @pytest.mark.asyncio async def test_non_json_response_block_on_error_false_returns_inputs(self): @@ -1217,9 +1120,6 @@ class TestSingulrInvalidResponse: @pytest.mark.asyncio async def test_response_missing_expected_fields_block_on_error_true_raises(self): - """Regression: a response body that fails SingulrGuardrailResponse - validation must raise GuardrailRaisedException instead of letting - pydantic.ValidationError propagate unhandled.""" guardrail = SingulrGuardrail( singulr_api_base="https://api.test.singulr.ai", singulr_api_key="test_token_1234", @@ -1239,11 +1139,6 @@ class TestSingulrInvalidResponse: ) -# --------------------------------------------------------------------------- -# Transport error handling -# --------------------------------------------------------------------------- - - class TestSingulrTransportError: @pytest.mark.asyncio async def test_remote_protocol_error_block_on_error_false_returns_inputs(self): @@ -1287,11 +1182,6 @@ class TestSingulrTransportError: ) -# --------------------------------------------------------------------------- -# HTTP status error handling -# --------------------------------------------------------------------------- - - class TestSingulrHttpStatusError: @pytest.mark.asyncio async def test_http_error_message_names_status_code_not_unreachable(self): @@ -1342,11 +1232,6 @@ class TestSingulrHttpStatusError: assert result is inputs -# --------------------------------------------------------------------------- -# Config model -# --------------------------------------------------------------------------- - - class TestSingulrConfigModel: def test_ui_friendly_name(self): assert SingulrGuardrailConfigModel.ui_friendly_name() == "Singulr" @@ -1355,11 +1240,6 @@ class TestSingulrConfigModel: assert SingulrGuardrail.get_config_model() is SingulrGuardrailConfigModel -# --------------------------------------------------------------------------- -# Initializer and registry -# --------------------------------------------------------------------------- - - class TestSingulrInitializer: def test_guardrail_initializer_registry_has_entry(self): from litellm.proxy.guardrails.guardrail_hooks.singulr import ( @@ -1369,11 +1249,6 @@ class TestSingulrInitializer: assert callable(initialize_guardrail) def test_initialize_guardrail_reads_singulr_prefixed_fields(self): - """Regression: the UI config form (and YAML config) populate the - singulr_-prefixed fields declared on SingulrGuardrailConfigModel, not - the generic api_base/api_key fields. initialize_guardrail must read - those, or a UI-configured singulr_api_base is silently ignored and - the guardrail falls back to the localhost default.""" from litellm.proxy.guardrails.guardrail_hooks.singulr import ( initialize_guardrail, ) @@ -1398,10 +1273,6 @@ class TestSingulrInitializer: assert cb.singulr_guardrail_id == "configured_guardrail_id" def test_initialize_guardrail_wires_timeout(self): - """BaseLitellmParams.timeout exists so operators can override the - per-request latency budget. initialize_guardrail must forward it to - SingulrGuardrail instead of leaving every deployment stuck on the - hardcoded default regardless of configuration.""" from litellm.proxy.guardrails.guardrail_hooks.singulr import ( initialize_guardrail, )