From 78aedf990deac1a396402d71c3b01afc23063d91 Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 14:41:22 -0800 Subject: [PATCH] fix(proxy): keep a skipped guardrail out of the logged applied guardrails The logged applied_guardrails also lists every default_on guardrail and the names in the request's metadata.guardrails, so a skipped guardrail is taken off that list when the walk ends. The new integration test pins the header on every route. --- litellm/proxy/common_request_processing.py | 12 +++++-- .../test_unknown_model_guardrail_skip.py | 30 +++++++++++++++-- .../proxy/test_common_request_processing.py | 33 +++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 066d5169e9a..8dafde140b0 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -2667,7 +2667,7 @@ class ProxyBaseLLMRequestProcessing: ) finally: if routing_probe is not None and routing_probe.refusal is not None: - self._redact_request_its_skipped_guardrails_never_scanned() + self._redact_request_its_skipped_guardrails_never_scanned(routing_probe.skipped_guardrails) await _enforce_guardrail_added_tag_budgets( data=self.data, tags_before_guardrails=self._tags_before_guardrails, @@ -2703,15 +2703,21 @@ class ProxyBaseLLMRequestProcessing: return self.data, logging_obj - def _redact_request_its_skipped_guardrails_never_scanned(self) -> None: + def _redact_request_its_skipped_guardrails_never_scanned(self, skipped_guardrails: frozenset[str]) -> None: """Redact the request body, its logged snapshot and the logged messages, so failure callbacks never see - content a skipped masking guardrail would have rewritten. + content a skipped masking guardrail would have rewritten, and leave the skipped guardrails out of the + logged ``applied_guardrails``. """ self.data.update(without_unscanned_content(self.data)) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # self.data is the untyped request dict refresh_proxy_server_request_body_snapshot(self.data) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # self.data is the untyped request dict logging_obj: Final = self.data.get("litellm_logging_obj") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # self.data is the untyped request dict if isinstance(logging_obj, LiteLLMLoggingObj): logging_obj.update_messages([{"role": "user", "content": REDACTED_BY_LITELLM}]) + logging_obj.model_call_details["applied_guardrails"] = [ + name + for name in logging_obj.model_call_details.get("applied_guardrails") or () + if name not in skipped_guardrails + ] async def _pre_call_with_fallbacks( self, diff --git a/tests/integration/authorization/test_unknown_model_guardrail_skip.py b/tests/integration/authorization/test_unknown_model_guardrail_skip.py index d502eaa07ca..b2fbc326112 100644 --- a/tests/integration/authorization/test_unknown_model_guardrail_skip.py +++ b/tests/integration/authorization/test_unknown_model_guardrail_skip.py @@ -65,13 +65,13 @@ def _operator_verdict(action: str) -> Callable[[Request], Reply]: return reply -def _airs(policy_url: str) -> dict[str, JsonValue]: +def _airs(policy_url: str, default_on: bool = True) -> dict[str, JsonValue]: return { "guardrail_name": "airs-" + uuid.uuid4().hex[:8], "litellm_params": { "guardrail": "panw_prisma_airs", "mode": "pre_call", - "default_on": True, + "default_on": default_on, "api_base": policy_url, "api_key": "synthetic-panw-key", "profile_name": "synthetic-profile", @@ -186,6 +186,32 @@ def test_unknown_model_burst_while_the_scanner_is_down_never_reaches_it(gateway: assert [request.target for request in upstream.drain() if request.method == "POST"] == [] +def test_opt_in_guardrail_is_named_in_the_header_only_when_it_ran(gateway: Gateway, tmp_path: Path) -> None: + model: Final = "no-such-model-" + uuid.uuid4().hex[:8] + with wire_server(_scanner("allow")) as policy, wire_server(_provider) as upstream: + guardrail: Final = _airs(policy.url, default_on=False) + opt_in: Final = {"guardrails": [guardrail["guardrail_name"]]} + with owned_proxy(gateway, tmp_path, {}, config=_config(tmp_path, guardrail)) as candidate: + with candidate.scenario() as scenario: + routable: Final = scenario.model( + model="openai/gpt-4.1-mini", api_base=upstream.url + "/v1", api_key="synthetic-key" + ) + ran: Final = candidate.request( + "POST", "/v1/chat/completions", {**_requests(routable)[0][1], "metadata": opt_in} + ) + assert ran.status_code == 200, ran.text + assert ran.headers["x-litellm-applied-guardrails"] == guardrail["guardrail_name"] + assert len(policy.drain()) == 1 + + for target, body in _requests(model): + refused = candidate.request("POST", target, {**body, "metadata": opt_in}) + + assert refused.status_code == 400, refused.text + assert "Invalid model name" in refused.text + assert "x-litellm-applied-guardrails" not in refused.headers, target + assert policy.drain() == () + + def test_operator_guardrail_and_the_guardrail_before_it_still_run_for_an_unknown_model( gateway: Gateway, tmp_path: Path ) -> None: diff --git a/tests/unit/proxy/test_common_request_processing.py b/tests/unit/proxy/test_common_request_processing.py index 70170be7ef5..8b2c4a2adfd 100644 --- a/tests/unit/proxy/test_common_request_processing.py +++ b/tests/unit/proxy/test_common_request_processing.py @@ -13137,6 +13137,7 @@ class _FailureLogRecorder(CustomLogger): "request_model": logged_request.get("model"), "request_messages": logged_request.get("messages"), "request_has_call_id": "litellm_call_id" in logged_request, + "applied_guardrails": (standard.get("metadata") or {}).get("applied_guardrails"), } ) @@ -13540,6 +13541,38 @@ class TestUnroutableModelFailureLogHidesUnscannedContent: assert _shows_no_raw_content(sent) is content_hidden, sent.failure_dumps +class TestUnroutableModelFailureLogNamesOnlyGuardrailsThatRan: + @pytest.mark.asyncio + @pytest.mark.parametrize("run_in_parallel", [False, True], ids=["sequential", "parallel"]) + async def test_failure_log_leaves_out_a_skipped_default_on_guardrail(self, monkeypatch, run_in_parallel): + sent: Final = await _send(monkeypatch, [_VendorGuardrail(run_in_parallel=run_in_parallel)]) + + assert sent.status == 400 + assert sent.failure_logs[0]["applied_guardrails"] == [] + + @pytest.mark.asyncio + async def test_failure_log_keeps_an_operator_guardrail_that_ran(self, monkeypatch): + class OperatorGuardrail(_VendorGuardrail): + __module__ = "operator_guardrails.checks" + + operator: Final = OperatorGuardrail(name="operator") + skipped: Final = _VendorGuardrail(name="skipped") + + sent: Final = await _send(monkeypatch, [operator, skipped]) + + assert (operator.calls, skipped.calls) == (1, 0) + assert sent.failure_logs[0]["applied_guardrails"] == ["operator"] + + @pytest.mark.asyncio + async def test_failure_log_names_a_guardrail_that_blocked_a_routable_model(self, monkeypatch): + guardrail: Final = _VendorGuardrail(behavior="block") + + sent: Final = await _send(monkeypatch, [guardrail], model="gpt-4o-mini") + + assert guardrail.calls == 1 + assert sent.failure_logs[0]["applied_guardrails"] == ["vendor-guardrail"] + + class TestUnroutableModelLeavesEverythingElseAsBefore: """Everything except the skipped pre-call guardrails behaves as it did before LIT-8011, including the cases earlier attempts at this change broke."""