diff --git a/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py b/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py index 3865ba4ed0e..b733e9c1255 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py +++ b/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py @@ -1,4 +1,8 @@ +import asyncio 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 @@ -18,20 +22,29 @@ from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, ) +from litellm.proxy._types import UserAPIKeyAuth from litellm.types.guardrails import GuardrailEventHooks from litellm.types.proxy.guardrails.guardrail_hooks.base import ( GuardrailConfigModel, ) from litellm.types.proxy.guardrails.guardrail_hooks.singulr import ( + AssistantMessage, SingulrGuardrailPayload, - SingulrGuardrailRequest, SingulrGuardrailResponse, + SingulrMcpGuardrailPayload, + ToolCall, + ToolCallFunction, +) +from litellm.types.utils import ( + GenericGuardrailAPIInputs, + GuardrailStatus, + StandardLoggingGuardrailInformation, ) -from litellm.types.utils import GenericGuardrailAPIInputs _DEFAULT_API_BASE: Final = "http://localhost:8003" _GUARD_ENDPOINT: Final = "/api/v1/ai-gateway/litellm" _DEFAULT_TIMEOUT: Final = 30.0 +_EMPTY_MAPPING: Final[Mapping[str, Any]] = MappingProxyType({}) class SingulrGuardrail(CustomGuardrail): @@ -46,8 +59,8 @@ class SingulrGuardrail(CustomGuardrail): **kwargs: Any, ) -> None: self.singulr_api_key = singulr_api_key or os.environ.get("SINGULR_API_KEY") - self.singulr_api_base = (singulr_api_base or os.environ.get("SINGULR_API_BASE") or _DEFAULT_API_BASE).rstrip( - "/" + self.singulr_api_base = ( + (singulr_api_base or os.environ.get("SINGULR_API_BASE") or _DEFAULT_API_BASE).strip().rstrip("/") ) parsed: Final = urlparse(self.singulr_api_base) if parsed.scheme == "http" and parsed.hostname not in ( @@ -80,6 +93,9 @@ class SingulrGuardrail(CustomGuardrail): kwargs["supported_event_hooks"] = [ GuardrailEventHooks.pre_call, GuardrailEventHooks.post_call, + GuardrailEventHooks.logging_only, + GuardrailEventHooks.pre_mcp_call, + GuardrailEventHooks.post_mcp_call, ] super().__init__(**kwargs) @@ -92,53 +108,78 @@ class SingulrGuardrail(CustomGuardrail): return SingulrGuardrailConfigModel - def _build_payload( - self, - request_data: dict[str, Any], - inputs: GenericGuardrailAPIInputs, - input_type: str, - ) -> dict[str, Any]: - if not request_data: - texts: Final = inputs.get("texts", []) + @staticmethod + def _metadata_containers(request_data: Mapping[str, Any]) -> tuple[Mapping[str, Any], ...]: + """Candidate metadata dicts to check, in priority order. - payload = SingulrGuardrailPayload( - input_type=input_type, - is_playground_request=True, - playground_text=texts[0] if texts else None, + 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 + for container in ( + request_data.get("litellm_metadata"), + request_data.get("metadata"), + litellm_params.get("litellm_metadata") if litellm_params else None, + litellm_params.get("metadata") if litellm_params else None, ) - else: - response: Final = request_data.get("response") - singulr_req_object: Final = SingulrGuardrailRequest( - model=request_data.get("model"), - messages=request_data.get("messages"), - tools=request_data.get("tools"), - model_response=response.model_dump(mode="json") if input_type == "response" and response else None, - litellm_metadata=request_data.get("litellm_metadata"), - ) - payload = SingulrGuardrailPayload( - litellm_call_id=request_data.get("litellm_call_id"), - request_data=singulr_req_object, - input_type=input_type, - ) - - return payload.model_dump(mode="json") - - def _build_headers(self) -> dict[str, str]: - return dict( - (header, value) - for header, value in ( - ("Content-Type", "application/json"), - ("X-Singulr-Gateway-Token", self.singulr_api_key), - ( - "X-Singulr-Enforcement-Entity-Id", - self.singulr_application_id or "", - ), - ("X-Singulr-Guardrail-Id", self.singulr_guardrail_id or ""), - ) - if value + if container ) - async def _call_api(self, payload: dict[str, Any]) -> SingulrGuardrailResponse | None: + @classmethod + def _resolve_metadata_value(cls, request_data: Mapping[str, Any], key: str) -> str | None: + for container in cls._metadata_containers(request_data=request_data): + value = container.get(key) + if value: + return value + return None + + @classmethod + def _resolve_user_role_from_request_data(cls, request_data: Mapping[str, Any]) -> str | None: + for container in cls._metadata_containers(request_data=request_data): + auth = container.get("user_api_key_auth") + if isinstance(auth, UserAPIKeyAuth) and auth.user_role: + return auth.user_role.value + return None + + @classmethod + def _build_metadata(cls, request_data: Mapping[str, Any]) -> Mapping[str, Any] | None: + fields: Final = ( + "user_api_key_alias", + "user_api_key_user_id", + "user_api_key_user_email", + "user_api_key_org_id", + "user_api_key_org_alias", + "user_api_key_team_id", + "user_api_key_team_alias", + ) + resolved: Final = ( + *((field, cls._resolve_metadata_value(request_data=request_data, key=field)) for field in fields), + ("user_api_key_user_role", cls._resolve_user_role_from_request_data(request_data=request_data)), + ) + if not any(value for _, value in resolved): + return None + return {key: value for key, value in resolved if value} # mutable-ok: short-lived JSON payload dict + + @staticmethod + def _build_user_message(text: str) -> Mapping[str, Any]: + return {"role": "user", "content": text} # mutable-ok: short-lived JSON payload dict + + def _build_headers(self) -> Mapping[str, str]: + all_headers: Final = MappingProxyType( + { + "Content-Type": "application/json", + "X-Singulr-Gateway-Token": self.singulr_api_key, + "X-Singulr-Enforcement-Entity-Id": self.singulr_application_id, + "X-Singulr-Guardrail-Id": self.singulr_guardrail_id, + } + ) + return MappingProxyType({header: value for header, value in all_headers.items() if value}) + + async def _call_api(self, payload: Mapping[str, Any]) -> SingulrGuardrailResponse | None: endpoint: Final = f"{self.singulr_api_base}{_GUARD_ENDPOINT}" verbose_proxy_logger.debug("Singulr: %s", endpoint) @@ -163,7 +204,7 @@ class SingulrGuardrail(CustomGuardrail): if self.block_on_error: raise GuardrailRaisedException( guardrail_name=self.guardrail_name, - message=(f"Singulr API returned HTTP {exc.response.status_code}: {exc.response.text}"), + message=f"Singulr API returned HTTP {exc.response.status_code}: {exc.response.text}", ) from exc return None @@ -185,33 +226,282 @@ class SingulrGuardrail(CustomGuardrail): ) from exc return None + async def _apply_guardrail_on_request( + self, + inputs: GenericGuardrailAPIInputs, + texts: Sequence[str], + structured_messages: Sequence[Any], + request_data: Mapping[str, Any], + ) -> GenericGuardrailAPIInputs: + messages: Final = ( + tuple(structured_messages) + if structured_messages + else tuple(self._build_user_message(text) for text in texts) + ) + + images: Final = inputs.get("images") + tools: Final = inputs.get("tools") + + if not messages and not images and not tools: + verbose_proxy_logger.debug("Singulr: No messages, images, or tools to check after filtering") + return inputs + + metadata: Final = self._build_metadata(request_data=request_data) + + singulr_req_obj = SingulrGuardrailPayload( + correlation_id=request_data.get("litellm_call_id"), + model_name=inputs.get("model"), + guardrail_scope="request", + messages=messages, + images=images, + tools=tools, + metadata=metadata, + ) + payload = singulr_req_obj.model_dump(mode="json") + guardrail_resp = await self._call_api(payload) + + if guardrail_resp is None: + return inputs + + if guardrail_resp.should_block: + raise GuardrailRaisedException( + guardrail_name=self.guardrail_name, + status_code=400, + message=f"Blocked by Singulr, Blocking due to {guardrail_resp.blocking_due_to or 'unknown'}", + ) + return inputs + + 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"), + mcp_server_name=request_data.get("mcp_server_name"), + metadata=metadata, + ) + payload = singulr_mcp_obj.model_dump(mode="json") + guardrail_resp = await self._call_api(payload) + + if guardrail_resp is None: + return + + if guardrail_resp.should_block: + raise GuardrailRaisedException( + guardrail_name=self.guardrail_name, + status_code=400, + message=f"Blocked by Singulr, Blocking due to {guardrail_resp.blocking_due_to or 'unknown'}", + ) + + async def _apply_guardrail_on_mcp_response( + self, inputs: GenericGuardrailAPIInputs, texts: Sequence[str], request_data: Mapping[str, Any] + ) -> GenericGuardrailAPIInputs: + if not texts: + return inputs + + metadata: Final = self._build_metadata(request_data=request_data) + + singulr_mcp_obj = SingulrMcpGuardrailPayload( + model_name=request_data.get("model"), + guardrail_scope="mcp_response", + tool_result=texts, + metadata=metadata, + ) + payload = singulr_mcp_obj.model_dump(mode="json") + guardrail_resp = await self._call_api(payload) + + if guardrail_resp is None: + return inputs + + if guardrail_resp.should_block: + raise GuardrailRaisedException( + guardrail_name=self.guardrail_name, + status_code=400, + message=f"Blocked by Singulr, Blocking due to {guardrail_resp.blocking_due_to or 'unknown'}", + ) + + return inputs + + @staticmethod + def _build_tool_call(tool_call: Mapping[str, Any]) -> "ToolCall | None": + tool_call_id: Final = tool_call.get("id") + fun: Final = tool_call.get("function") + if not tool_call_id or not fun: + return None + func_name: Final = fun.get("name") + args: Final = fun.get("arguments") + if not func_name or args is None: + return None + return ToolCall( + id=tool_call_id, + type=tool_call.get("type"), + function=ToolCallFunction(name=func_name, arguments=args), + ) + + async def _apply_guardrail_on_response( + self, inputs: GenericGuardrailAPIInputs, texts: Sequence[str], request_data: Mapping[str, Any] + ) -> GenericGuardrailAPIInputs: + combined_texts: Final = "\n".join(texts) if texts else None + + tool_calls: Final = inputs.get("tool_calls", ()) + tool_calls_res: Final = tuple( + tool_call_res + for tool_call_res in (self._build_tool_call(tool_call) for tool_call in tool_calls) + if tool_call_res is not None + ) + + assistant_message: Final = AssistantMessage( + role="assistant", + content=combined_texts, + tool_calls=tool_calls_res, + ) + + metadata: Final = self._build_metadata(request_data=request_data) + + singulr_resp_obj = SingulrGuardrailPayload( + correlation_id=request_data.get("litellm_call_id"), + guardrail_scope="response", + messages=request_data.get("messages"), + images=inputs.get("images"), + response=assistant_message, + metadata=metadata, + ) + + payload = singulr_resp_obj.model_dump(mode="json") + guardrail_resp = await self._call_api(payload) + + if guardrail_resp is None: + return inputs + + if guardrail_resp.should_block: + raise GuardrailRaisedException( + guardrail_name=self.guardrail_name, + status_code=400, + message=f"Blocked by Singulr, Blocking due to {guardrail_resp.blocking_due_to or 'unknown'}", + ) + return inputs + + 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]: + start_time: Final = datetime.now(timezone.utc) + guardrail_status: GuardrailStatus = "success" + try: + messages: Final = kwargs.get("messages") or () + if messages: + request_metadata: Final = self._build_metadata(request_data=kwargs) + singulr_req_obj = SingulrGuardrailPayload( + correlation_id=kwargs.get("litellm_call_id"), + model_name=kwargs.get("model"), + guardrail_scope="request", + messages=messages, + metadata=request_metadata, + ) + payload_req = singulr_req_obj.model_dump(mode="json") + await self._call_api(payload_req) + + if result: + response_metadata: Final = self._build_metadata(request_data=kwargs) + singulr_res_obj = SingulrGuardrailPayload( + correlation_id=kwargs.get("litellm_call_id"), + guardrail_scope="response", + response=result, + metadata=response_metadata, + ) + try: + payload = singulr_res_obj.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) + payload = { + "correlation_id": kwargs.get("litellm_call_id"), + "guardrail_scope": "response", + "response": str(result), + "metadata": response_metadata, + } + await self._call_api(payload) + except GuardrailRaisedException: + guardrail_status = "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 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, inputs: GenericGuardrailAPIInputs, - request_data: dict, + request_data: dict, # mutable-ok: required by CustomGuardrail.apply_guardrail override signature input_type: str, logging_obj: "LiteLLMLoggingObj | None" = None, ) -> GenericGuardrailAPIInputs: - payload: Final = self._build_payload(request_data, inputs, input_type) - if not payload: - return inputs - - result: Final = await self._call_api(payload) - if result is None: - return inputs + texts: Final = inputs.get("texts", ()) + structured_messages: Final = inputs.get("structured_messages", ()) verbose_proxy_logger.debug( - "Singulr: should_block=%s blocking_due_to=%s", - result.should_block, - result.blocking_due_to, + "Singulr Guardrail: apply_guardrail called with input_type=%s, texts=%d, structured_messages=%d", + input_type, + len(texts), + len(structured_messages), ) - if result.should_block: - raise GuardrailRaisedException( - guardrail_name=self.guardrail_name, - message=f"Blocked by Singulr: {result.blocking_due_to or 'unknown'}", - blocked_content=True, + if input_type == "request": + if request_data.get("mcp_tool_name"): + 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": + return await self._apply_guardrail_on_mcp_response( + inputs=inputs, texts=texts, request_data=request_data + ) + return await self._apply_guardrail_on_response(inputs=inputs, texts=texts, request_data=request_data) return inputs diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/singulr.py b/litellm/types/proxy/guardrails/guardrail_hooks/singulr.py index d0d19d191c1..59d6ed1b85f 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/singulr.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/singulr.py @@ -1,30 +1,59 @@ -from typing import Any +from collections.abc import Mapping, Sequence +from typing import Any, Literal from pydantic import BaseModel, Field from .base import GuardrailConfigModel -class SingulrGuardrailRequest(BaseModel): - model: str | None = None - messages: list[dict[str, Any]] | None = None - tools: list[dict[str, Any]] | None = None - model_response: dict[str, Any] | None = None - litellm_metadata: dict[str, Any] | None = None +class ContentBlock(BaseModel): + type: str | None = None + text: str | None = None + + +class ToolCallFunction(BaseModel): + name: str + arguments: str + + +class ToolCall(BaseModel): + id: str + type: Literal["function"] = "function" + function: ToolCallFunction + + +class AssistantMessage(BaseModel): + role: Literal["assistant"] = "assistant" + content: str | Sequence[ContentBlock] | None = None + tool_calls: Sequence[ToolCall] | None = None class SingulrGuardrailPayload(BaseModel): - litellm_call_id: str | None = None - request_data: SingulrGuardrailRequest | None = None - input_type: str - is_playground_request: bool | None = None - playground_text: str | None = None + correlation_id: str | None = None + model_name: str | None = None + model_provider_name: str | None = None + guardrail_scope: str | None = None + messages: Sequence[Any] | 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 + + +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 + mcp_server_name: str | None = None + tool_result: Sequence[str] | None = None + metadata: Mapping[str, Any] | None = None class SingulrGuardrailResponse(BaseModel): """Response returned by the Singulr guardrail API.""" - should_block: bool = False + should_block: bool | None = None blocking_due_to: str | None = None diff --git a/test-quality-budget.json b/test-quality-budget.json index 4a7bc7edff2..0350d813743 100644 --- a/test-quality-budget.json +++ b/test-quality-budget.json @@ -3,7 +3,7 @@ "limit": 744 }, "TQ002": { - "limit": 742 + "limit": 741 }, "TQ003": { "limit": 62 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 14d8e90e027..457b7c5c7f4 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_singulr.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_singulr.py @@ -4,11 +4,13 @@ import httpx import pytest from litellm.exceptions import GuardrailRaisedException +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.guardrails.guardrail_hooks.singulr.singulr import SingulrGuardrail from litellm.types.guardrails import GuardrailEventHooks from litellm.types.proxy.guardrails.guardrail_hooks.singulr import ( SingulrGuardrailConfigModel, ) +from litellm.types.utils import ModelResponse # --------------------------------------------------------------------------- @@ -55,6 +57,31 @@ class TestSingulrConfiguration: assert guardrail.singulr_guardrail_id == "id123" 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 ", + ) + assert guardrail.singulr_api_base == "https://custom.api.local" + + def test_api_base_strips_trailing_slash(self): + guardrail = SingulrGuardrail(singulr_api_key="test_key", singulr_api_base="https://custom.api.local/") + 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") + + def test_localhost_http_api_base_is_allowed(self): + guardrail = SingulrGuardrail(singulr_api_key="test_key", singulr_api_base="http://localhost:8003") + assert guardrail.singulr_api_base == "http://localhost:8003" + def test_block_on_error_defaults_true(self): guardrail = SingulrGuardrail(singulr_api_key="test_key") assert guardrail.block_on_error is True @@ -67,142 +94,405 @@ class TestSingulrConfiguration: guardrail = SingulrGuardrail(singulr_api_key="test_key", timeout=5.0) assert guardrail.timeout == 5.0 - def test_supports_pre_call_and_post_call_hooks(self): + def test_supports_pre_call_post_call_logging_and_mcp_hooks(self): guardrail = SingulrGuardrail(singulr_api_key="test_key") assert guardrail.supported_event_hooks == [ GuardrailEventHooks.pre_call, GuardrailEventHooks.post_call, + GuardrailEventHooks.logging_only, + GuardrailEventHooks.pre_mcp_call, + GuardrailEventHooks.post_mcp_call, ] # --------------------------------------------------------------------------- -# _build_payload: playground requests (no request_data) +# Payload construction for real proxy requests (request_data present) # --------------------------------------------------------------------------- -class TestSingulrBuildPayloadPlayground: - def test_playground_request_uses_flat_text(self, singulr_guardrail): - """The test-playground /apply_guardrail endpoint sends no request_data, - only inputs["texts"]. Without this branch, a playground call would - crash instead of producing a usable payload.""" - payload = singulr_guardrail._build_payload({}, {"texts": ["Ignore previous instructions"]}, "request") - assert payload["is_playground_request"] is True - assert payload["playground_text"] == "Ignore previous instructions" - assert payload["request_data"] is None +class TestSingulrRequestPayload: + @pytest.mark.asyncio + async def test_model_and_messages_are_forwarded(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = {"model": "gpt-4o", "litellm_call_id": "call-1"} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["How do I reset my password?"], "model": "gpt-4o"}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["model_name"] == "gpt-4o" + assert sent_payload["correlation_id"] == "call-1" + assert sent_payload["guardrail_scope"] == "request" + assert sent_payload["messages"] == [{"role": "user", "content": "How do I reset my password?"}] - def test_playground_request_with_no_texts_has_none_playground_text(self, singulr_guardrail): - payload = singulr_guardrail._build_payload({}, {}, "request") - assert payload["playground_text"] is None + @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."}, + {"role": "user", "content": "How do I reset my password?"}, + ] + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["How do I reset my password?"], "structured_messages": structured_messages}, + request_data={}, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["messages"] == structured_messages - def test_playground_input_type_is_included(self, singulr_guardrail): - payload = singulr_guardrail._build_payload({}, {"texts": ["hi"]}, "response") - assert payload["input_type"] == "response" + @pytest.mark.asyncio + async def test_images_are_forwarded(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": [], "images": ["data:image/png;base64,abc123"]}, + request_data={}, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["images"] == ["data:image/png;base64,abc123"] + @pytest.mark.asyncio + async def test_no_messages_or_images_skips_the_api_call(self, singulr_guardrail): + with patch.object(singulr_guardrail.async_handler, "post") as mock_post: + result = await singulr_guardrail.apply_guardrail( + inputs={"texts": []}, + request_data={}, + input_type="request", + ) + mock_post.assert_not_called() + assert result == {"texts": []} -# --------------------------------------------------------------------------- -# _build_payload: real proxy requests (request_data present) -# --------------------------------------------------------------------------- + @pytest.mark.asyncio + @pytest.mark.parametrize( + "extra_inputs", + [ + {"tools": [{"type": "function", "function": {"name": "delete_file", "description": "", "parameters": {}}}]}, + {"images": ["data:image/png;base64,abc123"]}, + ], + 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( + inputs={"texts": [], **extra_inputs}, + request_data={}, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + for key, value in extra_inputs.items(): + assert sent_payload[key] == value + @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 = [ + { + "type": "function", + "function": {"name": "search_docs", "description": "Search internal docs", "parameters": {}}, + } + ] + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["How do I reset my password?"], "tools": tools}, + request_data={}, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["tools"] == tools -class TestSingulrBuildPayloadRequestData: - def test_model_messages_and_tools_are_forwarded(self, singulr_guardrail): + @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 = [ + { + "type": "mcp", + "server_label": "docs-server", + "server_url": "https://mcp.example.com", + "allowed_tools": ["search_docs"], + } + ] + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["How do I reset my password?"], "tools": tools}, + request_data={}, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["tools"] == tools + + @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: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] == {"user_api_key_alias": "my-key-alias"} + + @pytest.mark.asyncio + async def test_falls_back_to_regular_metadata_for_key_alias(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = {"metadata": {"user_api_key_alias": "fallback-alias"}} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] == {"user_api_key_alias": "fallback-alias"} + + @pytest.mark.asyncio + async def test_user_api_key_user_id_is_forwarded_in_metadata(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = {"litellm_metadata": {"user_api_key_user_id": "my-user-id"}} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] == {"user_api_key_user_id": "my-user-id"} + + @pytest.mark.asyncio + async def test_falls_back_to_regular_metadata_for_user_id(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = {"metadata": {"user_api_key_user_id": "fallback-user-id"}} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] == {"user_api_key_user_id": "fallback-user-id"} + + @pytest.mark.asyncio + async def test_user_api_key_user_email_is_forwarded_in_metadata(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = {"litellm_metadata": {"user_api_key_user_email": "user@example.com"}} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] == {"user_api_key_user_email": "user@example.com"} + + @pytest.mark.asyncio + async def test_user_api_key_organization_alias_is_forwarded_in_metadata(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = {"litellm_metadata": {"user_api_key_org_alias": "Acme Org"}} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] == {"user_api_key_org_alias": "Acme Org"} + + @pytest.mark.asyncio + async def test_user_api_key_team_alias_is_forwarded_in_metadata(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = {"litellm_metadata": {"user_api_key_team_alias": "AI Content Security Team"}} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] == {"user_api_key_team_alias": "AI Content Security Team"} + + @pytest.mark.asyncio + async def test_user_api_key_org_id_is_forwarded_in_metadata(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = {"litellm_metadata": {"user_api_key_org_id": "org-123"}} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] == {"user_api_key_org_id": "org-123"} + + @pytest.mark.asyncio + async def test_user_api_key_team_id_is_forwarded_in_metadata(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = {"litellm_metadata": {"user_api_key_team_id": "team-456"}} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] == {"user_api_key_team_id": "team-456"} + + @pytest.mark.asyncio + async def test_user_api_key_user_role_is_forwarded_in_metadata(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY) + request_data = {"litellm_metadata": {"user_api_key_auth": auth}} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] == {"user_api_key_user_role": LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value} + + @pytest.mark.asyncio + async def test_no_user_role_available_omits_role_from_metadata(self, singulr_guardrail): + 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: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert "user_api_key_user_role" not in sent_payload["metadata"] + + @pytest.mark.asyncio + async def test_all_user_metadata_fields_forwarded_together(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY) request_data = { - "model": "gpt-4o", - "messages": [{"role": "user", "content": "How do I reset my password?"}], - "tools": [{"type": "function", "function": {"name": "get_weather"}}], + "litellm_metadata": { + "user_api_key_alias": "my-key-alias", + "user_api_key_user_id": "my-user-id", + "user_api_key_user_email": "user@example.com", + "user_api_key_org_id": "org-123", + "user_api_key_org_alias": "Acme Org", + "user_api_key_team_id": "team-456", + "user_api_key_team_alias": "AI Content Security Team", + "user_api_key_auth": auth, + } + } + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] == { + "user_api_key_alias": "my-key-alias", + "user_api_key_user_id": "my-user-id", + "user_api_key_user_email": "user@example.com", + "user_api_key_org_id": "org-123", + "user_api_key_org_alias": "Acme Org", + "user_api_key_team_id": "team-456", + "user_api_key_team_alias": "AI Content Security Team", + "user_api_key_user_role": LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value, } - payload = singulr_guardrail._build_payload(request_data, {"texts": []}, "request") - assert payload["request_data"]["model"] == "gpt-4o" - assert payload["request_data"]["messages"] == request_data["messages"] - assert payload["request_data"]["tools"] == request_data["tools"] - assert payload["is_playground_request"] is None - def test_model_response_absent_on_request_side(self, singulr_guardrail): - """The response hasn't happened yet at request time, so model_response - must not be forwarded even if request_data carries a stale response - object from a previous call.""" - from litellm.types.utils import ModelResponse + @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( + inputs={"texts": ["hi"]}, + request_data={}, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] is None - request_data = {"model": "gpt-4o", "response": ModelResponse()} - payload = singulr_guardrail._build_payload(request_data, {"texts": []}, "request") - assert payload["request_data"]["model_response"] is None - def test_model_response_is_forwarded_and_json_serializable(self, singulr_guardrail): - """Regression: request_data["response"] is a ModelResponse (pydantic) - object containing nested non-JSON-safe values (e.g. a `created` - unix timestamp is fine, but nested pydantic submodels are not plain - dicts). Without mode="json" on both the inner and outer dumps, this - payload cannot be sent via httpx's json= kwarg.""" - import json as _json +# --------------------------------------------------------------------------- +# Payload construction for responses +# --------------------------------------------------------------------------- - from litellm.types.utils import Choices, Message, ModelResponse, Usage - response = ModelResponse( - choices=[Choices(message=Message(role="assistant", content="Go to settings."))], - usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15), - ) - request_data = {"model": "gpt-4o", "response": response} - payload = singulr_guardrail._build_payload(request_data, {"texts": ["Go to settings."]}, "response") - - # Must not raise - this is what httpx's json= kwarg effectively does. - serialized = _json.dumps(payload) - assert "Go to settings." in serialized - assert payload["request_data"]["model_response"]["choices"][0]["message"]["content"] == "Go to settings." - - def test_model_requested_tool_calls_are_forwarded_in_model_response(self, singulr_guardrail): - """Tool calls the model requests arrive inside response.choices[].message.tool_calls. - They must survive the dump so Singulr can inspect what tools the - model is trying to invoke.""" - from litellm.types.utils import Choices, Message, ModelResponse - - response = ModelResponse( - choices=[ - Choices( - message=Message( - role="assistant", - content=None, - tool_calls=[ - { - "id": "call_1", - "type": "function", - "function": {"name": "get_current_time", "arguments": "{}"}, - } - ], - ) - ) +class TestSingulrResponsePayload: + @pytest.mark.asyncio + async def test_assistant_text_and_tool_calls_are_forwarded(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + inputs = { + "texts": ["Go to settings."], + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "get_current_time", "arguments": "{}"}, + } ], - ) - request_data = {"model": "gpt-4o", "response": response} - payload = singulr_guardrail._build_payload(request_data, {"texts": []}, "response") - - tool_calls = payload["request_data"]["model_response"]["choices"][0]["message"]["tool_calls"] - assert tool_calls[0]["function"]["name"] == "get_current_time" - - def test_litellm_metadata_is_forwarded(self, singulr_guardrail): - request_data = {"model": "gpt-4o", "litellm_metadata": {"user_api_key_hash": "abc123"}} - payload = singulr_guardrail._build_payload(request_data, {"texts": []}, "request") - assert payload["request_data"]["litellm_metadata"] == {"user_api_key_hash": "abc123"} - - def test_internal_logging_object_is_not_forwarded(self, singulr_guardrail): - """Regression: request_data can carry internal proxy objects (e.g. the - Logging instance) that aren't JSON-serializable at all. _build_payload - must only pull known request/response fields out of request_data, - not dump it wholesale, or this crashes on every real proxy call.""" - import json as _json - - class _NotSerializable: - pass - - request_data = { - "model": "gpt-4o", - "messages": [{"role": "user", "content": "hi"}], - "litellm_logging_obj": _NotSerializable(), } - payload = singulr_guardrail._build_payload(request_data, {"texts": ["hi"]}, "request") + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs=inputs, + request_data={}, + input_type="response", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["guardrail_scope"] == "response" + assert sent_payload["response"]["content"] == "Go to settings." + assert sent_payload["response"]["tool_calls"][0]["function"]["name"] == "get_current_time" - # Must not raise. - _json.dumps(payload) - assert "litellm_logging_obj" not in payload["request_data"] + @pytest.mark.asyncio + async def test_response_images_are_forwarded(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + inputs = {"texts": ["ok"], "images": ["data:image/png;base64,xyz"]} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs=inputs, + request_data={}, + input_type="response", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["images"] == ["data:image/png;base64,xyz"] + + @pytest.mark.asyncio + async def test_incomplete_tool_calls_are_dropped(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + inputs = { + "texts": [], + "tool_calls": [ + {"id": None, "type": "function", "function": {"name": "f", "arguments": "{}"}}, + {"id": "call_2", "type": "function", "function": None}, + ], + } + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs=inputs, + request_data={}, + input_type="response", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["response"]["tool_calls"] == [] # --------------------------------------------------------------------------- @@ -212,8 +502,15 @@ class TestSingulrBuildPayloadRequestData: class TestSingulrAllowAction: @pytest.mark.asyncio - async def test_allow_returns_inputs_unchanged(self, singulr_guardrail): - resp = _make_response({"should_block": False}) + @pytest.mark.parametrize( + "guard_response", + [{"should_block": False}, {}], + 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): result = await singulr_guardrail.apply_guardrail( @@ -223,18 +520,23 @@ class TestSingulrAllowAction: ) assert result is inputs + @pytest.mark.asyncio + async def test_should_block_false_returns_inputs_unchanged_on_response(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + inputs = {"texts": ["Here is your answer."]} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp): + result = await singulr_guardrail.apply_guardrail( + inputs=inputs, + request_data={}, + input_type="response", + ) + assert result is inputs + class TestSingulrBlockAction: @pytest.mark.asyncio - async def test_block_raises_guardrail_exception(self, singulr_guardrail): - """Regression: a should_block=True response must stop the request - instead of silently letting it through.""" - resp = _make_response( - { - "should_block": True, - "blocking_due_to": "PII Information detected", - } - ) + async def test_should_block_true_raises_on_request(self, singulr_guardrail): + resp = _make_response({"should_block": True, "blocking_due_to": "PII Information detected"}) with patch.object(singulr_guardrail.async_handler, "post", return_value=resp): with pytest.raises(GuardrailRaisedException) as exc_info: await singulr_guardrail.apply_guardrail( @@ -244,6 +546,23 @@ class TestSingulrBlockAction: ) assert "PII Information detected" in str(exc_info.value) + @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: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["Here is something toxic."]}, + request_data={}, + input_type="response", + ) + assert "Toxic content detected" in str(exc_info.value) + @pytest.mark.asyncio async def test_block_without_reason_uses_unknown_placeholder(self, singulr_guardrail): resp = _make_response({"should_block": True}) @@ -256,6 +575,300 @@ 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): + resp = _make_response({"should_block": False}) + request_data = { + "mcp_tool_name": "search_docs", + "mcp_arguments": {"query": "reset password"}, + "mcp_server_name": "docs-server", + } + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + result = await singulr_guardrail.apply_guardrail( + inputs={"texts": []}, + request_data=request_data, + input_type="request", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["guardrail_scope"] == "mcp_request" + assert sent_payload["tool_name"] == "search_docs" + assert sent_payload["tool_arguments"] == {"query": "reset password"} + assert sent_payload["mcp_server_name"] == "docs-server" + assert result == {"texts": []} + + @pytest.mark.asyncio + async def test_mcp_request_should_block_true_raises(self, singulr_guardrail): + resp = _make_response({"should_block": True, "blocking_due_to": "Disallowed tool"}) + request_data = {"mcp_tool_name": "delete_file", "mcp_arguments": {"path": "/etc/passwd"}} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp): + with pytest.raises(GuardrailRaisedException, match="Disallowed tool"): + await singulr_guardrail.apply_guardrail( + inputs={"texts": []}, + request_data=request_data, + input_type="request", + ) + + +class TestSingulrMcpResponse: + @pytest.mark.asyncio + async def test_call_mcp_tool_response_routes_to_mcp_response_payload(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = { + "call_type": "call_mcp_tool", + "mcp_tool_name": "search_docs", + "mcp_server_name": "docs-server", + "model": "MCP: docs-server", + } + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["Result: password reset link sent."]}, + request_data=request_data, + input_type="response", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["guardrail_scope"] == "mcp_response" + assert sent_payload["model_name"] == "MCP: docs-server" + assert sent_payload["tool_result"] == ["Result: password reset link sent."] + + @pytest.mark.asyncio + async def test_mcp_response_with_no_texts_skips_the_api_call(self, singulr_guardrail): + request_data = {"call_type": "call_mcp_tool", "mcp_tool_name": "search_docs"} + with patch.object(singulr_guardrail.async_handler, "post") as mock_post: + result = await singulr_guardrail.apply_guardrail( + inputs={"texts": []}, + request_data=request_data, + input_type="response", + ) + mock_post.assert_not_called() + assert result == {"texts": []} + + @pytest.mark.asyncio + async def test_mcp_response_should_block_true_raises(self, singulr_guardrail): + resp = _make_response({"should_block": True, "blocking_due_to": "Sensitive tool output"}) + request_data = {"call_type": "call_mcp_tool", "mcp_tool_name": "search_docs"} + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp): + with pytest.raises(GuardrailRaisedException, match="Sensitive tool output"): + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["leaked secret"]}, + request_data=request_data, + input_type="response", + ) + + @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 = { + "call_type": "call_mcp_tool", + "mcp_tool_name": "search_docs", + "litellm_params": { + "metadata": { + "user_api_key_alias": "my-key-alias", + "user_api_key_user_id": "my-user-id", + "user_api_key_user_email": "user@example.com", + "user_api_key_org_id": "org-123", + "user_api_key_org_alias": "Acme Org", + "user_api_key_team_id": "team-456", + "user_api_key_team_alias": "AI Content Security Team", + "user_api_key_auth": auth, + } + }, + } + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["Result: password reset link sent."]}, + request_data=request_data, + input_type="response", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] == { + "user_api_key_alias": "my-key-alias", + "user_api_key_user_id": "my-user-id", + "user_api_key_user_email": "user@example.com", + "user_api_key_org_id": "org-123", + "user_api_key_org_alias": "Acme Org", + "user_api_key_team_id": "team-456", + "user_api_key_team_alias": "AI Content Security Team", + "user_api_key_user_role": LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value, + } + + @pytest.mark.asyncio + async def test_mcp_response_prefers_top_level_metadata_over_nested_litellm_params(self, singulr_guardrail): + resp = _make_response({"should_block": False}) + request_data = { + "call_type": "call_mcp_tool", + "mcp_tool_name": "search_docs", + "litellm_metadata": {"user_api_key_alias": "top-level-alias"}, + "litellm_params": {"metadata": {"user_api_key_alias": "nested-alias"}}, + } + with patch.object(singulr_guardrail.async_handler, "post", return_value=resp) as mock_post: + await singulr_guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="response", + ) + sent_payload = mock_post.call_args.kwargs["json"] + assert sent_payload["metadata"] == {"user_api_key_alias": "top-level-alias"} + + +# --------------------------------------------------------------------------- +# apply_guardrail dispatch (request vs response vs unknown input_type) +# --------------------------------------------------------------------------- + + +class TestSingulrApplyGuardrailDispatch: + @pytest.mark.asyncio + async def test_unknown_input_type_returns_inputs_unchanged(self, singulr_guardrail): + with patch.object(singulr_guardrail.async_handler, "post") as mock_post: + inputs = {"texts": ["hi"]} + result = await singulr_guardrail.apply_guardrail( + inputs=inputs, + request_data={}, + input_type="unsupported", + ) + mock_post.assert_not_called() + 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}) + kwargs = { + "messages": [{"role": "user", "content": "hi"}], + "model": "gpt-4o", + "litellm_call_id": "call-1", + "litellm_metadata": {"user_api_key_alias": "my-key-alias", "user_api_key_org_id": "org-123"}, + } + 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 + + @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.""" + 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" + ) + 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" + + @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_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" + + 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"}]} + + # --------------------------------------------------------------------------- # HTTP call wiring (endpoint, timeout, headers) # --------------------------------------------------------------------------- @@ -263,7 +876,7 @@ class TestSingulrBlockAction: class TestSingulrRequestWiring: @pytest.mark.asyncio - async def test_sends_configured_timeout(self): + 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.""" @@ -279,7 +892,9 @@ class TestSingulrRequestWiring: request_data={}, input_type="request", ) - assert mock_post.call_args.kwargs["timeout"] == 5.0 + call_kwargs = mock_post.call_args.kwargs + assert call_kwargs["timeout"] == 5.0 + assert call_kwargs["url"] == "https://api.test.singulr.ai/api/v1/ai-gateway/litellm" class TestSingulrBuildHeaders: @@ -350,17 +965,19 @@ 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 (e.g. should_block is a string, not a bool) must raise - GuardrailRaisedException instead of letting pydantic.ValidationError - propagate unhandled.""" + 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", guardrail_name="test-singulr", block_on_error=True, ) - resp = _make_response({"should_block": "not-a-bool"}) - with patch.object(guardrail.async_handler, "post", return_value=resp): + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json.side_effect = ValueError("not valid json") + + with patch.object(guardrail.async_handler, "post", return_value=mock_resp): with pytest.raises(GuardrailRaisedException): await guardrail.apply_guardrail( inputs={"texts": ["test"]},