diff --git a/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py b/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py index a188a77ff3b..e0dcdf02069 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py +++ b/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py @@ -44,9 +44,10 @@ from litellm.types.utils import ( ) _DEFAULT_API_BASE: Final = "http://localhost:8003" -_GUARD_ENDPOINT: Final = "/api/v1/ai-gateway/litellm" +_GUARD_ENDPOINT: Final = "/api/v1/ai-gateway/litellm-v2" _DEFAULT_TIMEOUT: Final = 30.0 _EMPTY_MAPPING: Final[Mapping[str, Any]] = MappingProxyType({}) +_MCP_MODEL_PREFIX: Final = "MCP:" class _CustomGuardrailOptions(TypedDict, total=False, extra_items=object): @@ -375,6 +376,7 @@ class SingulrGuardrail(CustomGuardrail): singulr_resp_obj = SingulrGuardrailPayload( correlation_id=request_data.get("litellm_call_id"), guardrail_scope="response", + model_name=request_data.get("model"), messages=request_data.get("messages"), images=inputs.get("images"), response=assistant_message, @@ -405,6 +407,7 @@ class SingulrGuardrail(CustomGuardrail): try: return SingulrGuardrailPayload( correlation_id=kwargs.get("litellm_call_id"), + model_name=kwargs.get("model"), guardrail_scope="response", response=result, metadata=metadata, @@ -459,12 +462,21 @@ class SingulrGuardrail(CustomGuardrail): 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: 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 346fd894951..7a228a7c3fb 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_singulr.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_singulr.py @@ -935,6 +935,41 @@ class TestSingulrLoggingHook: guardrail_information = returned_kwargs["standard_logging_object"]["guardrail_information"] assert guardrail_information[0]["guardrail_status"] == "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}) @@ -1112,7 +1147,7 @@ class TestSingulrRequestWiring: ) 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" + assert call_kwargs["url"] == "https://api.test.singulr.ai/api/v1/ai-gateway/litellm-v2" class TestSingulrBuildHeaders: