fix(guardrails): key the incremental scan cache by a fingerprint of the content filter's rules

This commit is contained in:
michelligabriele 2026-09-11 21:18:43 +02:00
parent 1f2b0b9a17
commit a6e4a3a716
No known key found for this signature in database
4 changed files with 125 additions and 0 deletions

View file

@ -438,7 +438,18 @@ class CustomGuardrail(CustomLogger):
"""
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _incremental_scan_policy_fingerprint(self) -> str:
"""Identity of the rules the scan enforces; empty leaves the cache key as before.
A guardrail that returns a hash of its effective rules starts a fresh per-session
cache whenever those rules change under the same guardrail name.
"""
return ""
def _scanned_texts_cache_key(self, session_id: str) -> str:
fingerprint: Final = self._incremental_scan_policy_fingerprint()
if fingerprint:
return f"guardrail_scanned_texts:{self.guardrail_name}:{fingerprint}:{session_id}"
return f"guardrail_scanned_texts:{self.guardrail_name}:{session_id}"
@staticmethod

View file

@ -6,6 +6,7 @@ to detect and block/mask sensitive content.
"""
import asyncio
import hashlib
import json
import os
import re
@ -239,6 +240,11 @@ class ContentFilterGuardrail(CustomGuardrail):
# Competitor intent checker (optional; airline uses major_airlines.json, generic requires competitors)
self._competitor_intent_checker: BaseCompetitorIntentChecker | None = None
self._competitor_intent_config: Final = (
competitor_intent_config
if competitor_intent_config and isinstance(competitor_intent_config, dict)
else None
)
if competitor_intent_config and isinstance(competitor_intent_config, dict):
self._init_competitor_intent_checker(competitor_intent_config)
@ -289,6 +295,9 @@ class ContentFilterGuardrail(CustomGuardrail):
)
self.only_scan_new_messages = False
# Rule stores are written only here and a DB update rebuilds the instance, so hash the policy once
self._policy_fingerprint: Final = self._compute_policy_fingerprint()
verbose_proxy_logger.debug(
"ContentFilterGuardrail initialized with %s patterns and %s blocked words",
len(self.compiled_patterns),
@ -355,6 +364,57 @@ class ContentFilterGuardrail(CustomGuardrail):
)
)
def _incremental_scan_policy_fingerprint(self) -> str:
return self._policy_fingerprint
def _compute_policy_fingerprint(self) -> str:
"""Hash of every rule the scan enforces, so a changed rule set never reuses a session's scanned-text state."""
policy: Final = (
tuple(
(
entry["pattern_name"],
entry["action"].value,
entry["regex"].pattern,
entry["regex"].flags,
entry["keyword_regex"].pattern if entry["keyword_regex"] else None,
entry["allow_word_numbers"],
)
for entry in self.compiled_patterns
),
tuple(
sorted((word, action.value, description) for word, (action, description) in self.blocked_words.items())
),
tuple(
sorted((word, cat, sev, action.value) for word, (cat, sev, action) in self.category_keywords.items())
),
tuple(
sorted(
(word, cat, sev, action.value)
for word, (cat, sev, action) in self.always_block_category_keywords.items()
)
),
tuple(
(name, tuple(cfg["identifier_words"]), tuple(cfg["block_words"]), cfg["action"].value, cfg["severity"])
for name, cfg in sorted(self.conditional_categories.items())
),
tuple(
(
name,
category.default_action.value,
tuple(category.keywords),
tuple(category.exceptions),
tuple(category.identifier_words),
tuple(category.always_block_keywords),
category.inherit_from,
tuple(category.additional_block_words),
tuple(source for source, _ in category.phrase_patterns),
)
for name, category in sorted(self.loaded_categories.items())
),
self._competitor_intent_config,
)
return hashlib.sha256(json.dumps(policy, sort_keys=True, default=str).encode("utf-8")).hexdigest()[:16]
@staticmethod
def _category_config_view(cat_config: ContentFilterCategoryConfig) -> _CategoryConfigView:
return {

View file

@ -2829,3 +2829,19 @@ class TestCustomGuardrailPostCallSuccessDeploymentHook:
assert response.choices[0].message.content == "filtered response"
assert "guardrail_to_apply" not in request_data
assert len(_guardrail_entries(request_data)) == 1
class TestScannedTextsCacheKey:
def test_key_is_unchanged_without_a_policy_fingerprint(self):
guardrail = CustomGuardrail(guardrail_name="bedrock-shape")
assert guardrail._scanned_texts_cache_key("sess-1") == "guardrail_scanned_texts:bedrock-shape:sess-1"
def test_policy_fingerprint_namespaces_the_key(self):
class _FingerprintedGuardrail(CustomGuardrail):
def _incremental_scan_policy_fingerprint(self) -> str:
return "rules-v2"
guardrail = _FingerprintedGuardrail(guardrail_name="cf")
assert guardrail._scanned_texts_cache_key("sess-1") == "guardrail_scanned_texts:cf:rules-v2:sess-1"

View file

@ -3289,6 +3289,44 @@ class TestContentFilterOnlyScanNewMessages:
assert self._scan_counts(caplog) == [(4, 4), (4, 4)]
@pytest.mark.asyncio
async def test_same_rules_on_a_new_instance_share_session_state(self, caplog):
"""Two instances with the same rules (a restarted pod, a sibling pod) share the session's scanned state."""
session = {"litellm_session_id": "cf-incremental-same-rules"}
texts = ["be helpful", "first question"]
with caplog.at_level(logging.DEBUG, logger="LiteLLM Proxy"):
for guardrail in (self._guardrail(), self._guardrail()):
await guardrail.apply_guardrail(
inputs={"texts": list(texts)}, request_data=session, input_type="request"
)
assert self._scan_counts(caplog) == [(2, 2), (0, 2)]
@pytest.mark.asyncio
async def test_rule_change_under_same_name_rescans_allowed_text(self):
"""The cache key carries a hash of the effective rules, so a stricter policy never trusts earlier scans."""
session = {"litellm_session_id": "cf-incremental-rule-change"}
texts = ["be helpful", "tell me about swordfish"]
await self._guardrail().apply_guardrail(
inputs={"texts": list(texts)}, request_data=session, input_type="request"
)
stricter = ContentFilterGuardrail(
guardrail_name="content-filter-incremental",
blocked_words=[
BlockedWord(keyword=self.BLOCKED_KEYWORD, action=ContentFilterAction.BLOCK),
BlockedWord(keyword="swordfish", action=ContentFilterAction.BLOCK),
],
default_on=True,
only_scan_new_messages=True,
)
with pytest.raises(HTTPException):
await stricter.apply_guardrail(
inputs={"texts": list(texts)}, request_data=session, input_type="request"
)
class TestContentFilterInitializerForwardsOnlyScanNewMessages:
"""initialize_guardrail forwards an explicit kwarg list, so a field left out of it never reaches the object."""