""" Tests for the Rubrik LiteLLM plugin. Covers initialization, apply_guardrail tool blocking (all allowed, all blocked, partial blocking, fail-open), batch logging, and Anthropic format handling. """ import os from typing import Any, Dict from unittest.mock import AsyncMock, Mock, patch import httpx import pytest from litellm.integrations.custom_guardrail import ModifyResponseException from litellm.integrations.rubrik import RubrikLogger from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from tests.test_litellm.integrations.rubrik_test_helpers import ( make_inputs_with_tools, make_tool_call_dict, ) @pytest.fixture def mock_env(): """Set up environment variables for testing.""" with patch.dict( os.environ, { "RUBRIK_WEBHOOK_URL": "http://localhost:8080", "RUBRIK_API_KEY": "test-api-key", }, ): yield @pytest.fixture def handler(mock_env): """Create a RubrikLogger instance for testing.""" with patch("asyncio.create_task", Mock()): return RubrikLogger() # -- Initialization ----------------------------------------------------------- class TestInitialization: def test_init_success(self, mock_env): with patch("asyncio.create_task", Mock()): handler = RubrikLogger() assert ( handler.tool_blocking_endpoint == "http://localhost:8080/v1/after_completion/openai/v1" ) assert handler.logging_endpoint == "http://localhost:8080/v1/litellm/batch" assert handler.key == "test-api-key" assert isinstance(handler.tool_blocking_client, AsyncHTTPHandler) def test_init_with_constructor_params(self): with patch("asyncio.create_task", Mock()): handler = RubrikLogger(api_key="ctor-key", api_base="http://ctor-host:9090") assert handler.key == "ctor-key" assert ( handler.tool_blocking_endpoint == "http://ctor-host:9090/v1/after_completion/openai/v1" ) def test_init_without_url(self): with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValueError, match="Rubrik webhook URL not configured"): RubrikLogger() def test_init_without_api_key(self): with patch.dict( os.environ, {"RUBRIK_WEBHOOK_URL": "http://localhost:8080"}, clear=True ): with patch("asyncio.create_task", Mock()): assert RubrikLogger().key is None def test_trailing_slash_removed(self): with patch.dict(os.environ, {"RUBRIK_WEBHOOK_URL": "http://localhost:8080/"}): with patch("asyncio.create_task", Mock()): assert ( RubrikLogger().tool_blocking_endpoint == "http://localhost:8080/v1/after_completion/openai/v1" ) def test_v1_suffix_stripped_as_substring_not_charset(self): with patch("asyncio.create_task", Mock()): with patch.dict(os.environ, {"RUBRIK_WEBHOOK_URL": "http://host/v1"}): assert ( RubrikLogger().tool_blocking_endpoint == "http://host/v1/after_completion/openai/v1" ) with patch.dict(os.environ, {"RUBRIK_WEBHOOK_URL": "http://host/v11"}): assert ( RubrikLogger().tool_blocking_endpoint == "http://host/v11/v1/after_completion/openai/v1" ) def test_sampling_rate_fractional(self): with patch("asyncio.create_task", Mock()): with patch.dict( os.environ, {"RUBRIK_WEBHOOK_URL": "http://host", "RUBRIK_SAMPLING_RATE": "0.5"}, ): assert RubrikLogger().sampling_rate == 0.5 def test_sampling_rate_invalid_ignored(self): with patch("asyncio.create_task", Mock()): with patch.dict( os.environ, {"RUBRIK_WEBHOOK_URL": "http://host", "RUBRIK_SAMPLING_RATE": "abc"}, ): assert RubrikLogger().sampling_rate == 1.0 def test_sampling_rate_clamped(self): with patch("asyncio.create_task", Mock()): with patch.dict( os.environ, {"RUBRIK_WEBHOOK_URL": "http://host", "RUBRIK_SAMPLING_RATE": "2.0"}, ): assert RubrikLogger().sampling_rate == 1.0 with patch.dict( os.environ, {"RUBRIK_WEBHOOK_URL": "http://host", "RUBRIK_SAMPLING_RATE": "-0.5"}, ): assert RubrikLogger().sampling_rate == 0.0 def test_batch_size_invalid_ignored(self): with patch("asyncio.create_task", Mock()): with patch.dict( os.environ, {"RUBRIK_WEBHOOK_URL": "http://host", "RUBRIK_BATCH_SIZE": "abc"}, ): # Should use default without crashing assert isinstance(RubrikLogger().batch_size, int) def test_batch_size_valid(self): with patch("asyncio.create_task", Mock()): with patch.dict( os.environ, {"RUBRIK_WEBHOOK_URL": "http://host", "RUBRIK_BATCH_SIZE": "256"}, ): assert RubrikLogger().batch_size == 256 def test_init_outside_event_loop_does_not_raise(self): """Instantiation without a running event loop must not raise RuntimeError.""" with patch.dict( os.environ, {"RUBRIK_WEBHOOK_URL": "http://localhost:8080", "RUBRIK_API_KEY": "k"}, ): # Do NOT patch asyncio.create_task — the real call should be # guarded and fall back gracefully when there is no event loop. handler = RubrikLogger() assert handler.tool_blocking_endpoint.startswith("http://localhost:8080") # Without a running loop at init, the periodic flush task should be # deferred so batches still get drained once a log event arrives. assert handler._flush_task is None @pytest.mark.asyncio async def test_periodic_flush_task_started_lazily_on_first_log(self, mock_env): """Loggers instantiated outside an event loop must still start the periodic flush task on first use to drain low-traffic batches.""" # Simulate sync-init by hiding the running loop from the constructor. with patch( "litellm.integrations.rubrik.asyncio.get_running_loop", side_effect=RuntimeError("no running loop"), ): handler = RubrikLogger() assert handler._flush_task is None kwargs = { "standard_logging_object": { "messages": [{"role": "user", "content": "hi"}], "id": "litellm-id", }, "litellm_call_id": "litellm-id", "litellm_params": {}, } with patch.object(handler, "_log_batch_to_rubrik", AsyncMock()): await handler.async_log_success_event(kwargs, None, None, None) assert handler._flush_task is not None handler._flush_task.cancel() def test_event_hook_defaults_to_post_call_when_none_passed(self, mock_env): """`initialize_guardrail` always passes ``event_hook=litellm_params.mode`` (which is ``None`` when the user omits ``mode``). The logger must coerce a None ``event_hook`` to ``post_call`` rather than leaving it as None, which would otherwise cause the guardrail to run on every event hook.""" from litellm.types.guardrails import GuardrailEventHooks with patch("asyncio.create_task", Mock()): handler = RubrikLogger(event_hook=None) assert handler.event_hook == GuardrailEventHooks.post_call def test_explicit_event_hook_preserved(self, mock_env): from litellm.types.guardrails import GuardrailEventHooks with patch("asyncio.create_task", Mock()): handler = RubrikLogger(event_hook=GuardrailEventHooks.pre_call) assert handler.event_hook == GuardrailEventHooks.pre_call def test_default_on_defaults_to_true_when_none_passed(self, mock_env): """`initialize_guardrail` always passes ``default_on=litellm_params.default_on`` (which is ``None`` when the user omits ``default_on``). The logger must coerce a None ``default_on`` to True, otherwise ``should_run_guardrail`` (which checks ``self.default_on is True``) silently skips the guardrail.""" with patch("asyncio.create_task", Mock()): handler = RubrikLogger(default_on=None) assert handler.default_on is True def test_explicit_default_on_false_preserved(self, mock_env): """A user explicitly setting ``default_on: false`` in their guardrail config must NOT be silently overridden to True.""" with patch("asyncio.create_task", Mock()): handler = RubrikLogger(default_on=False) assert handler.default_on is False def test_explicit_default_on_true_preserved(self, mock_env): with patch("asyncio.create_task", Mock()): handler = RubrikLogger(default_on=True) assert handler.default_on is True def test_headers_with_api_key(self, handler): assert handler._headers["Authorization"] == "Bearer test-api-key" assert handler._headers["Content-Type"] == "application/json" def test_headers_without_api_key(self): with patch.dict(os.environ, {"RUBRIK_WEBHOOK_URL": "http://host"}, clear=True): with patch("asyncio.create_task", Mock()): h = RubrikLogger() assert "Authorization" not in h._headers # -- Batch Logging ------------------------------------------------------------ @pytest.mark.asyncio class TestBatchLogging: async def test_log_success_event_appends_to_queue(self, handler): kwargs = { "standard_logging_object": { "messages": [{"role": "user", "content": "hi"}], "response": "hello", }, } await handler.async_log_success_event( kwargs=kwargs, response_obj=None, start_time=None, end_time=None ) assert len(handler.log_queue) == 1 async def test_log_failure_event_appends_to_queue(self, handler): kwargs = { "standard_logging_object": { "messages": [{"role": "user", "content": "hi"}], "response": "error", }, } await handler.async_log_failure_event( kwargs=kwargs, response_obj=None, start_time=None, end_time=None ) assert len(handler.log_queue) == 1 async def test_log_success_event_sampling_skips(self, handler): handler.sampling_rate = 0.0 kwargs = { "standard_logging_object": { "messages": [{"role": "user", "content": "hi"}], "response": "hello", }, } await handler.async_log_success_event( kwargs=kwargs, response_obj=None, start_time=None, end_time=None ) assert len(handler.log_queue) == 0 async def test_flush_queue_sends_batch(self, handler): handler.log_queue = [{"msg": "a"}, {"msg": "b"}] mock_response = Mock() mock_response.status_code = 200 handler.async_httpx_client = AsyncMock() handler.async_httpx_client.post = AsyncMock(return_value=mock_response) await handler.flush_queue() handler.async_httpx_client.post.assert_called_once() assert len(handler.log_queue) == 0 async def test_flush_queue_preserves_events_added_during_send(self, handler): handler.log_queue = [{"msg": "a"}, {"msg": "b"}] async def mock_post(*_args, **_kwargs): handler.log_queue.append({"msg": "c"}) mock_response = Mock() mock_response.raise_for_status = Mock() return mock_response handler.async_httpx_client = AsyncMock() handler.async_httpx_client.post = mock_post await handler.flush_queue() assert handler.log_queue == [{"msg": "c"}] async def test_async_send_batch_does_not_drain_events(self, handler): handler.log_queue = [{"msg": "a"}, {"msg": "b"}] async def mock_post(*_args, **_kwargs): handler.log_queue.append({"msg": "c"}) mock_response = Mock() mock_response.raise_for_status = Mock() return mock_response handler.async_httpx_client = AsyncMock() handler.async_httpx_client.post = mock_post await handler.async_send_batch() assert handler.log_queue == [{"msg": "a"}, {"msg": "b"}, {"msg": "c"}] async def test_log_batch_error_does_not_crash_and_preserves_events(self, handler): """A failed batch send must not crash the caller AND must preserve the original events in the queue so they can be retried on the next flush. Previously the events were silently dropped on HTTP 5xx / network errors. """ handler.log_queue = [{"msg": "a"}] mock_response = Mock() mock_response.status_code = 500 mock_response.text = "Internal Server Error" mock_response.raise_for_status = Mock( side_effect=httpx.HTTPStatusError( "err", request=Mock(), response=mock_response ) ) handler.async_httpx_client = AsyncMock() handler.async_httpx_client.post = AsyncMock(return_value=mock_response) await handler.flush_queue() assert handler.log_queue == [{"msg": "a"}] async def test_log_batch_network_error_preserves_events(self, handler): """Network/timeout errors must also preserve the in-flight events.""" handler.log_queue = [{"msg": "a"}, {"msg": "b"}] handler.async_httpx_client = AsyncMock() handler.async_httpx_client.post = AsyncMock( side_effect=httpx.TimeoutException("timeout") ) await handler.flush_queue() assert handler.log_queue == [{"msg": "a"}, {"msg": "b"}] async def test_enqueue_drops_oldest_when_queue_exceeds_max_size(self, handler): """A sustained Rubrik webhook outage must not let the in-memory retry queue grow without bound. Once max_queue_size is exceeded, the oldest events are dropped to make room for new ones.""" handler.max_queue_size = 3 handler.batch_size = 10**6 # disable size-triggered flush handler.flush_queue = AsyncMock() for i in range(5): await handler._enqueue_log_event( kwargs={ "standard_logging_object": { "messages": [{"role": "user", "content": f"hi-{i}"}], "response": "hello", }, }, event_type="success", ) assert len(handler.log_queue) == 3 retained = [item["messages"][0]["content"] for item in handler.log_queue] assert retained == ["hi-2", "hi-3", "hi-4"] async def test_log_batch_failure_preserves_events_added_during_send(self, handler): """Failure must preserve both the snapshot AND events appended mid-flush.""" handler.log_queue = [{"msg": "a"}, {"msg": "b"}] async def mock_post(*_args, **_kwargs): handler.log_queue.append({"msg": "c"}) mock_response = Mock() mock_response.status_code = 500 mock_response.text = "boom" mock_response.raise_for_status = Mock( side_effect=httpx.HTTPStatusError( "err", request=Mock(), response=mock_response ) ) return mock_response handler.async_httpx_client = AsyncMock() handler.async_httpx_client.post = mock_post await handler.flush_queue() assert handler.log_queue == [{"msg": "a"}, {"msg": "b"}, {"msg": "c"}] async def test_system_prompt_prepended_to_messages(self, handler): kwargs = { "standard_logging_object": { "messages": [{"role": "user", "content": "hi"}], "response": "hello", }, "system": "You are a helpful assistant.", } await handler.async_log_success_event( kwargs=kwargs, response_obj=None, start_time=None, end_time=None ) assert len(handler.log_queue) == 1 msgs = handler.log_queue[0]["messages"] assert msgs[0]["role"] == "system" assert msgs[0]["content"] == "You are a helpful assistant." async def test_system_prompt_with_dict_messages(self, handler): kwargs = { "standard_logging_object": { "messages": {"role": "user", "content": "hi"}, "response": "hello", }, "system": "Be concise.", } await handler.async_log_success_event( kwargs=kwargs, response_obj=None, start_time=None, end_time=None ) assert len(handler.log_queue) == 1 msgs = handler.log_queue[0]["messages"] assert isinstance(msgs, list) assert msgs[0]["role"] == "system" assert msgs[1] == {"role": "user", "content": "hi"} async def test_anthropic_id_normalization(self, handler): kwargs = { "standard_logging_object": { "id": "chatcmpl-original", "messages": [{"role": "user", "content": "hi"}], "response": "hello", }, "litellm_params": { "proxy_server_request": { "url": "http://proxy/v1/messages", }, }, "litellm_call_id": "litellm-call-123", } await handler.async_log_success_event( kwargs=kwargs, response_obj=None, start_time=None, end_time=None ) assert handler.log_queue[0]["id"] == "litellm-call-123" async def test_non_anthropic_id_unchanged(self, handler): kwargs = { "standard_logging_object": { "id": "chatcmpl-original", "messages": [{"role": "user", "content": "hi"}], "response": "hello", }, "litellm_params": { "proxy_server_request": { "url": "http://proxy/v1/chat/completions", }, }, "litellm_call_id": "litellm-call-123", } await handler.async_log_success_event( kwargs=kwargs, response_obj=None, start_time=None, end_time=None ) assert handler.log_queue[0]["id"] == "chatcmpl-original" async def test_payload_deep_copied_not_mutated(self, handler): """Verify the shared standard_logging_object is not mutated.""" original_payload = { "id": "original-id", "messages": [{"role": "user", "content": "hi"}], "response": "hello", } kwargs = { "standard_logging_object": original_payload, "system": "System prompt.", } await handler.async_log_success_event( kwargs=kwargs, response_obj=None, start_time=None, end_time=None ) # Original payload should NOT have been mutated assert original_payload["id"] == "original-id" assert len(original_payload["messages"]) == 1 # -- Tool Blocking (apply_guardrail) ------------------------------------------ def _mock_service_response(response_json): """Create a mock tool blocking client that returns the given JSON.""" async def mock_post(*_args, **kwargs): mock_resp = Mock() mock_resp.json.return_value = response_json mock_resp.raise_for_status = Mock() return mock_resp mock_client = AsyncMock() mock_client.post = mock_post return mock_client def _echo_service(): """Create a mock tool blocking client that echoes the payload back.""" async def mock_post(*_args, **kwargs): mock_resp = Mock() mock_resp.json.return_value = kwargs.get("json", {}).get("response", {}) mock_resp.raise_for_status = Mock() return mock_resp mock_client = AsyncMock() mock_client.post = mock_post return mock_client @pytest.mark.asyncio class TestApplyGuardrail: async def test_skips_requests(self, handler): inputs = make_inputs_with_tools([make_tool_call_dict("call_1", "test_tool")]) result = await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="request" ) assert result is inputs async def test_no_tool_calls(self, handler): from litellm.types.utils import GenericGuardrailAPIInputs inputs = GenericGuardrailAPIInputs(texts=["hello"]) result = await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="response" ) assert result is inputs async def test_all_allowed(self, handler): tc1 = make_tool_call_dict("call_1", "get_weather") tc2 = make_tool_call_dict("call_2", "get_time") inputs = make_inputs_with_tools([tc1, tc2]) handler.tool_blocking_client = _echo_service() result = await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="response" ) assert result is inputs async def test_all_blocked(self, handler): tc1 = make_tool_call_dict("call_1", "delete_table") tc2 = make_tool_call_dict("call_2", "drop_database") inputs = make_inputs_with_tools([tc1, tc2]) handler.tool_blocking_client = _mock_service_response( { "choices": [ { "message": { "role": "assistant", "content": "Tool blocked by policy", "tool_calls": [], } } ], } ) with pytest.raises(ModifyResponseException) as exc_info: await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="response" ) assert "Tool blocked by policy" in exc_info.value.message async def test_partial_blocking(self, handler): tc_blocked = make_tool_call_dict("call_A", "blocked_tool") tc_allowed = make_tool_call_dict("call_B", "allowed_tool") inputs = make_inputs_with_tools([tc_blocked, tc_allowed]) async def mock_post(*_args, **kwargs): payload = kwargs.get("json", {}).get("response", {}) all_tcs = payload["choices"][0]["message"]["tool_calls"] allowed = [tc for tc in all_tcs if tc.get("id") == "call_B"] mock_resp = Mock() mock_resp.json.return_value = { "choices": [ { "message": { "role": "assistant", "content": "blocked", "tool_calls": allowed, } } ], } mock_resp.raise_for_status = Mock() return mock_resp mock_client = AsyncMock() mock_client.post = mock_post handler.tool_blocking_client = mock_client with pytest.raises(ModifyResponseException): await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="response" ) async def test_service_failure_fail_open(self, handler): tc1 = make_tool_call_dict("call_1", "test_tool") inputs = make_inputs_with_tools([tc1]) mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=httpx.TimeoutException("Timeout")) handler.tool_blocking_client = mock_client result = await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="response" ) assert result is inputs async def test_service_empty_choices_fail_open(self, handler): tc1 = make_tool_call_dict("call_1", "test_tool") inputs = make_inputs_with_tools([tc1]) handler.tool_blocking_client = _mock_service_response({"choices": []}) result = await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="response" ) assert result is inputs async def test_blocking_service_payload_format(self, handler): tc1 = make_tool_call_dict("call_1", "get_weather", '{"location": "SF"}') tc2 = make_tool_call_dict("call_2", "send_email", '{"to": "user@example.com"}') inputs = make_inputs_with_tools([tc1, tc2]) captured_payload: Dict[str, Any] = {} async def mock_post(*_args, **kwargs): captured_payload.update(kwargs.get("json", {})) mock_resp = Mock() mock_resp.json.return_value = captured_payload.get("response", {}) mock_resp.raise_for_status = Mock() return mock_resp mock_client = AsyncMock() mock_client.post = mock_post handler.tool_blocking_client = mock_client await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="response" ) # Verify envelope structure assert "request" in captured_payload assert "response" in captured_payload response_data = captured_payload["response"] message = response_data["choices"][0]["message"] assert message["role"] == "assistant" assert len(message["tool_calls"]) == 2 assert message["tool_calls"][0]["id"] == "call_1" assert message["tool_calls"][0]["function"]["name"] == "get_weather" assert message["tool_calls"][1]["id"] == "call_2" assert message["tool_calls"][1]["function"]["name"] == "send_email" async def test_request_data_included_in_envelope(self, handler): tc = make_tool_call_dict("call_1", "test_tool") inputs = make_inputs_with_tools([tc]) captured_payload: Dict[str, Any] = {} async def mock_post(*_args, **kwargs): captured_payload.update(kwargs.get("json", {})) mock_resp = Mock() mock_resp.json.return_value = captured_payload.get("response", {}) mock_resp.raise_for_status = Mock() return mock_resp mock_client = AsyncMock() mock_client.post = mock_post handler.tool_blocking_client = mock_client logging_obj = Mock() logging_obj.model_call_details = { "messages": [{"role": "user", "content": "hi"}], "model": "gpt-4", "litellm_params": { "proxy_server_request": {"url": "/chat/completions"}, }, } await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="response", logging_obj=logging_obj, ) req = captured_payload["request"] assert req["model"] == "gpt-4" assert req["messages"] == [{"role": "user", "content": "hi"}] async def test_proxy_server_request_headers_stripped(self, handler): tc = make_tool_call_dict("call_1", "test_tool") inputs = make_inputs_with_tools([tc]) captured_payload: Dict[str, Any] = {} async def mock_post(*_args, **kwargs): captured_payload.update(kwargs.get("json", {})) mock_resp = Mock() mock_resp.json.return_value = captured_payload.get("response", {}) mock_resp.raise_for_status = Mock() return mock_resp mock_client = AsyncMock() mock_client.post = mock_post handler.tool_blocking_client = mock_client logging_obj = Mock() logging_obj.model_call_details = { "messages": [{"role": "user", "content": "hi"}], "model": "gpt-4", "litellm_params": { "proxy_server_request": { "url": "/chat/completions", "method": "POST", "headers": { "authorization": "Bearer sk-litellm-secret", "cookie": "session=abc", "x-api-key": "leaked-key", }, "body": {"api_key": "sk-upstream-secret"}, }, }, } await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="response", logging_obj=logging_obj, ) forwarded = captured_payload["request"]["proxy_server_request"] assert forwarded == {"url": "/chat/completions", "method": "POST"} # -- Anthropic format ---------------------------------------------------------- @pytest.mark.asyncio class TestApplyGuardrailAnthropicFormat: """Verify blocking works correctly regardless of original provider format. The framework converts Anthropic tool_use blocks to OpenAI-format tool_calls before calling apply_guardrail. """ async def test_single_tool_allowed(self, handler): tc = make_tool_call_dict( "toolu_123", "get_weather", '{"location": "Portland, OR"}' ) inputs = make_inputs_with_tools([tc], texts=["I'll check the weather."]) handler.tool_blocking_client = _echo_service() result = await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="response" ) assert result is inputs async def test_single_tool_blocked(self, handler): tc = make_tool_call_dict("toolu_123", "dangerous_tool", '{"arg": "value"}') inputs = make_inputs_with_tools([tc]) handler.tool_blocking_client = _mock_service_response( { "choices": [ { "message": { "role": "assistant", "content": "blocked", "tool_calls": [], } } ], } ) with pytest.raises(ModifyResponseException): await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="response" ) async def test_text_only_response_no_blocking(self, handler): from litellm.types.utils import GenericGuardrailAPIInputs inputs = GenericGuardrailAPIInputs(texts=["Hello! I'm Claude."]) mock_client = AsyncMock() mock_client.post = AsyncMock() handler.tool_blocking_client = mock_client result = await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="response" ) assert result is inputs mock_client.post.assert_not_called() async def test_service_failure_preserves_tools(self, handler): tc = make_tool_call_dict("toolu_123", "get_weather", '{"location": "SF"}') inputs = make_inputs_with_tools([tc]) mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=httpx.TimeoutException("Timeout")) handler.tool_blocking_client = mock_client result = await handler.apply_guardrail( inputs=inputs, request_data={}, input_type="response" ) assert result is inputs # -- Normalize tool calls ------------------------------------------------------ class TestNormalizeToolCalls: def test_dict_input(self): tc = make_tool_call_dict("call_1", "test", '{"a": 1}') result = RubrikLogger._normalize_tool_calls([tc]) assert len(result) == 1 assert result[0].id == "call_1" assert result[0].function.name == "test" assert result[0].function.arguments == '{"a": 1}' def test_typed_object_input(self): from litellm.types.utils import ChatCompletionMessageToolCall, Function tc = ChatCompletionMessageToolCall( id="call_2", type="function", function=Function(name="fn", arguments="{}"), ) result = RubrikLogger._normalize_tool_calls([tc]) assert len(result) == 1 assert result[0].id == "call_2" assert result[0].function.name == "fn" def test_unsupported_type_raises(self): with pytest.raises(TypeError, match="Cannot normalize"): RubrikLogger._normalize_tool_calls(["not_a_tool_call"]) # -- Extract blocked tools ----------------------------------------------------- class TestExtractBlockedTools: def test_all_allowed_returns_none(self): from litellm.types.utils import ChatCompletionMessageToolCall, Function tc = ChatCompletionMessageToolCall( id="call_1", type="function", function=Function(name="fn", arguments="{}") ) service_resp = { "choices": [ { "message": { "tool_calls": [{"id": "call_1"}], "content": "", } } ] } result = RubrikLogger._extract_blocked_tools(service_resp, [tc]) assert result is None def test_some_blocked_returns_explanation(self): from litellm.types.utils import ChatCompletionMessageToolCall, Function tc1 = ChatCompletionMessageToolCall( id="call_1", type="function", function=Function(name="fn1", arguments="{}"), ) tc2 = ChatCompletionMessageToolCall( id="call_2", type="function", function=Function(name="fn2", arguments="{}"), ) service_resp = { "choices": [ { "message": { "tool_calls": [{"id": "call_1"}], "content": "blocked fn2", } } ] } result = RubrikLogger._extract_blocked_tools(service_resp, [tc1, tc2]) assert result is not None assert "blocked fn2" in result def test_empty_choices_raises(self): with pytest.raises(Exception, match="empty response"): RubrikLogger._extract_blocked_tools({"choices": []}, []) def test_null_tool_calls_treated_as_all_blocked(self): from litellm.types.utils import ChatCompletionMessageToolCall, Function tc = ChatCompletionMessageToolCall( id="call_1", type="function", function=Function(name="fn", arguments="{}") ) service_resp = { "choices": [ { "message": { "tool_calls": None, "content": "blocked everything", } } ] } result = RubrikLogger._extract_blocked_tools(service_resp, [tc]) assert result is not None assert "blocked everything" in result def test_duplicate_ids_block_when_only_one_returned(self): from litellm.types.utils import ChatCompletionMessageToolCall, Function tc1 = ChatCompletionMessageToolCall( id="call_dup", type="function", function=Function(name="fn", arguments="{}"), ) tc2 = ChatCompletionMessageToolCall( id="call_dup", type="function", function=Function(name="fn", arguments="{}"), ) service_resp = { "choices": [ { "message": { "tool_calls": [{"id": "call_dup"}], "content": "blocked duplicate", } } ] } result = RubrikLogger._extract_blocked_tools(service_resp, [tc1, tc2]) assert result is not None assert "blocked duplicate" in result # -- Sanitize proxy server request ------------------------------------------- class TestSanitizeProxyServerRequest: def test_drops_headers_and_body(self): proxy_request = { "url": "/chat/completions", "method": "POST", "headers": { "authorization": "Bearer sk-litellm-secret", "cookie": "session=abc", "content-type": "application/json", }, "body": {"api_key": "sk-upstream-secret", "model": "gpt-4"}, } result = RubrikLogger._sanitize_proxy_server_request(proxy_request) assert result == {"url": "/chat/completions", "method": "POST"} def test_none_passthrough(self): assert RubrikLogger._sanitize_proxy_server_request(None) is None def test_non_dict_passthrough(self): assert RubrikLogger._sanitize_proxy_server_request("not a dict") == "not a dict" def test_partial_dict(self): result = RubrikLogger._sanitize_proxy_server_request({"url": "/v1/messages"}) assert result == {"url": "/v1/messages"} # -- Resolve model ------------------------------------------------------------- class TestResolveModel: def test_model_from_response(self): from unittest.mock import Mock response = Mock() response.model = "gpt-4" result = RubrikLogger._resolve_model({"response": response}, {}) assert result == "gpt-4" def test_model_from_call_details(self): result = RubrikLogger._resolve_model({}, {"model": "claude-3"}) assert result == "claude-3" def test_fallback_to_unknown(self): result = RubrikLogger._resolve_model({}, {}) assert result == "unknown" def test_empty_model_on_response_returns_unknown(self): from unittest.mock import Mock response = Mock() response.model = "" result = RubrikLogger._resolve_model( {"response": response}, {"model": "fallback"} ) assert result == "unknown"