mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
fix(guardrails): key the incremental scan cache by a fingerprint of the content filter's rules
This commit is contained in:
parent
1f2b0b9a17
commit
a6e4a3a716
4 changed files with 125 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue