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.
This commit is contained in:
yucheng 2026-11-02 14:41:22 -08:00
parent f61550d293
commit 78aedf990d
3 changed files with 70 additions and 5 deletions

View file

@ -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,

View file

@ -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:

View file

@ -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."""