diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index d8d65b27c4d..d848f655993 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -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 diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index d7a894c5799..fbb8e2fb1c8 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -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 { diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index ddc8439a83a..fa49f277615 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -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" diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py index 299f807527a..3efcec22943 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py @@ -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."""