diff --git a/litellm/proxy/policy_engine/policy_resolver.py b/litellm/proxy/policy_engine/policy_resolver.py index 10e4771bd12..7ef6f5fa561 100644 --- a/litellm/proxy/policy_engine/policy_resolver.py +++ b/litellm/proxy/policy_engine/policy_resolver.py @@ -48,10 +48,11 @@ def _list_gated_stages_for_guardrail(guardrail_name: str) -> frozenset[str] | No request tags). Stages are unioned across every registered callback carrying the name: - one guardrail_name can map to several callbacks with different hooks - (e.g. Presidio registers a post_call output-masking sibling alongside the - configured one, and duplicate-name deployments are supported for load - balancing), and stripping the name gates all of them. + one guardrail_name can back several callbacks with different hooks, via + load-balanced duplicate-name deployments (init_guardrails) or sibling + registrations from a single initializer (presidio registers post_call + output callbacks next to the primary), and stripping the name gates + all of them. An unregistered name or an event_hook of None yields an empty set: the flat list never gates such a guardrail, so dropping the name from the diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 127ce28b148..839beab4d50 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -4398,13 +4398,13 @@ async def test_pipeline_keeps_guardrail_with_stage_no_pipeline_can_cover(monkeyp @pytest.mark.asyncio -async def test_pipeline_keeps_guardrail_when_sibling_callback_adds_stages(monkeypatch): +async def test_pipeline_keeps_guardrail_when_same_name_deployment_adds_stages(monkeypatch): """ - One guardrail_name can map to several registered callbacks (Presidio adds - a post_call output-masking sibling; duplicate-name deployments are a - supported load-balancing setup). Coverage must union stages across all of - them, so a pre_call pipeline does not strip a name whose sibling still - needs the flat list at post_call. + One guardrail_name can back several callbacks with different hooks: + load-balanced duplicate-name deployments, and presidio's initializer + registering post_call output siblings next to the primary. Coverage must + union stages across all of them, so a pre_call pipeline does not strip a + name another callback still needs in the flat list at post_call. """ from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.types.guardrails import GuardrailEventHooks @@ -4430,6 +4430,35 @@ async def test_pipeline_keeps_guardrail_when_sibling_callback_adds_stages(monkey assert data["metadata"]["guardrails"] == ["word_guard"] +@pytest.mark.asyncio +async def test_pipeline_strips_guardrail_with_none_event_hook(monkeypatch): + """ + A callback with event_hook None runs at every stage without consulting the + flat guardrails list, so dropping the name cannot suppress it and the + pre-LIT-6536 dedup behavior stands. + """ + from litellm.integrations.custom_guardrail import CustomGuardrail + + guardrail = CustomGuardrail(guardrail_name="word_guard", event_hook=None) + policy_registry, attachment_registry = _policy_engine_pipeline_registries( + {"input-pipeline": _word_guard_pipeline_policy("pre_call")}, + monkeypatch, + callbacks=[guardrail], + ) + + data = {"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}], "metadata": {}} + try: + await add_guardrails_from_policy_engine( + data=data, + metadata_variable_name="metadata", + user_api_key_dict=UserAPIKeyAuth(api_key="test-key"), + ) + finally: + _reset_policy_engine_registries(policy_registry, attachment_registry) + + assert data["metadata"]["guardrails"] == [] + + @pytest.mark.asyncio async def test_pipeline_keeps_guardrail_with_tag_based_mode(monkeypatch): """