fix(guardrails): return HTTP 400 for litellm content filter blocks (#28418)
* fix(guardrails): return HTTP 400 for litellm content filter blocks Align litellm_content_filter hard rejects with the standard guardrail block status code so clients receive 400 instead of 403. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(guardrails): return HTTP 400 for custom code guardrail blocks Pre-call custom code guardrail blocks now raise HTTPException(400) instead of using the passthrough ModifyResponseException path that returned a synthetic 200 response. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(guardrails): preserve custom code passthrough blocks Keep standalone custom code guardrail blocks on the passthrough contract while covering policy pipeline block handling for passthrough-style guardrail interventions. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
152b1177e5
commit
4c3efe9c7c
@ -41,6 +41,7 @@ from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Type, cast
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.exceptions import ModifyResponseException
|
||||
from litellm.integrations.custom_guardrail import (
|
||||
CustomGuardrail,
|
||||
log_guardrail_information,
|
||||
@ -253,6 +254,9 @@ class CustomCodeGuardrail(CustomGuardrail):
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions (from block action)
|
||||
raise
|
||||
except ModifyResponseException:
|
||||
# Pre-call block uses passthrough; must not wrap as execution error (500)
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(
|
||||
f"Custom code guardrail '{self.guardrail_name}' execution error: {e}"
|
||||
|
||||
@ -1202,7 +1202,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
||||
)
|
||||
verbose_proxy_logger.warning(error_msg)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": error_msg,
|
||||
"category": category_name,
|
||||
@ -1242,7 +1242,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
||||
)
|
||||
verbose_proxy_logger.warning(error_msg)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": error_msg,
|
||||
"category": category_name,
|
||||
@ -1285,7 +1285,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
||||
error_msg = f"Content blocked: {pattern_name} pattern detected"
|
||||
verbose_proxy_logger.warning(error_msg)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
status_code=400,
|
||||
detail={"error": error_msg, "pattern": pattern_name},
|
||||
)
|
||||
elif action == ContentFilterAction.MASK:
|
||||
@ -1325,7 +1325,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
||||
error_msg += f" ({description})"
|
||||
verbose_proxy_logger.warning(error_msg)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": error_msg,
|
||||
"keyword": keyword,
|
||||
@ -1677,7 +1677,7 @@ class ContentFilterGuardrail(CustomGuardrail):
|
||||
"ContentFilterGuardrail: competitor intent refuse - %s", intent_val
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": msg,
|
||||
"intent": intent_val,
|
||||
|
||||
@ -59,7 +59,7 @@ def _run(checker, text: str) -> dict:
|
||||
checker.check(text)
|
||||
return {"decision": "ALLOW", "score": 0.0, "matched_topic": None}
|
||||
except HTTPException as e:
|
||||
if e.status_code == 403:
|
||||
if e.status_code == 400:
|
||||
detail: Dict[str, Any] = e.detail if isinstance(e.detail, dict) else {}
|
||||
return {
|
||||
"decision": "BLOCK",
|
||||
@ -542,7 +542,7 @@ class _LlmJudgeChecker:
|
||||
|
||||
if "BLOCK" in decision:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "Content blocked by LLM judge",
|
||||
"topic": "financial_advice",
|
||||
|
||||
@ -226,7 +226,7 @@ class TestContentFilterWithCompetitorIntent:
|
||||
await guardrail.apply_guardrail(
|
||||
inputs, request_data={}, input_type="request"
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
# Exact config from litellm/proxy/_new_secret_config.yaml (lines 27-53).
|
||||
|
||||
@ -198,7 +198,7 @@ class TestContentFilterGuardrail:
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "us_ssn" in str(exc_info.value.detail)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -563,7 +563,7 @@ class TestContentFilterGuardrail:
|
||||
):
|
||||
pass
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "us_ssn" in str(exc_info.value.detail)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -1010,7 +1010,7 @@ class TestContentFilterGuardrail:
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "danger_word" in str(exc_info.value.detail)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -1298,7 +1298,7 @@ class TestContentFilterGuardrail:
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
detail = exc_info.value.detail
|
||||
if isinstance(detail, dict):
|
||||
assert detail.get("category") == "harm_toxic_abuse"
|
||||
@ -1327,7 +1327,7 @@ class TestContentFilterGuardrail:
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
detail = exc_info.value.detail
|
||||
if isinstance(detail, dict):
|
||||
assert detail.get("category") == "harm_toxic_abuse"
|
||||
@ -1375,7 +1375,7 @@ class TestContentFilterGuardrail:
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403, f"Failed to block: '{test_input}'"
|
||||
assert exc_info.value.status_code == 400, f"Failed to block: '{test_input}'"
|
||||
detail = exc_info.value.detail
|
||||
if isinstance(detail, dict):
|
||||
assert detail.get("category") == "harm_toxic_abuse"
|
||||
@ -1443,7 +1443,7 @@ class TestContentFilterGuardrail:
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "te*st" in str(exc_info.value.detail)
|
||||
|
||||
def test_check_category_keywords_asterisk_pattern_matching(self):
|
||||
@ -1510,7 +1510,7 @@ class TestContentFilterGuardrail:
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403, f"Failed to block: '{test_input}'"
|
||||
assert exc_info.value.status_code == 400, f"Failed to block: '{test_input}'"
|
||||
detail = exc_info.value.detail
|
||||
if isinstance(detail, dict):
|
||||
assert detail.get("category") == "harm_toxic_abuse"
|
||||
@ -1560,7 +1560,7 @@ class TestContentFilterGuardrail:
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403, f"Failed to block: '{test_input}'"
|
||||
assert exc_info.value.status_code == 400, f"Failed to block: '{test_input}'"
|
||||
detail = exc_info.value.detail
|
||||
if isinstance(detail, dict):
|
||||
assert detail.get("category") == "harm_toxic_abuse"
|
||||
@ -1646,7 +1646,7 @@ class TestContentFilterGuardrail:
|
||||
)
|
||||
|
||||
assert (
|
||||
exc_info.value.status_code == 403
|
||||
exc_info.value.status_code == 400
|
||||
), f"Failed to block Spanish: '{test_input}'"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -1683,7 +1683,7 @@ class TestContentFilterGuardrail:
|
||||
)
|
||||
|
||||
assert (
|
||||
exc_info.value.status_code == 403
|
||||
exc_info.value.status_code == 400
|
||||
), f"Failed to block French: '{test_input}'"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -1720,7 +1720,7 @@ class TestContentFilterGuardrail:
|
||||
)
|
||||
|
||||
assert (
|
||||
exc_info.value.status_code == 403
|
||||
exc_info.value.status_code == 400
|
||||
), f"Failed to block German: '{test_input}'"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -1766,7 +1766,7 @@ class TestContentFilterGuardrail:
|
||||
)
|
||||
|
||||
assert (
|
||||
exc_info.value.status_code == 403
|
||||
exc_info.value.status_code == 400
|
||||
), f"Failed to block Australian: '{test_input}'"
|
||||
|
||||
async def test_html_tags_in_messages_not_blocked(self):
|
||||
@ -1942,7 +1942,7 @@ class TestContentFilterGuardrail:
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "harmful_child_safety" in str(exc_info.value.detail)
|
||||
|
||||
# Test case 2: Should BLOCK - identifier + block word combination
|
||||
@ -1956,7 +1956,7 @@ class TestContentFilterGuardrail:
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
# Test case 3: Should BLOCK - explicit content + minors
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@ -1967,7 +1967,7 @@ class TestContentFilterGuardrail:
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
# Test case 4: Should NOT block - identifier word alone (no block word)
|
||||
result = await guardrail.apply_guardrail(
|
||||
@ -2009,7 +2009,7 @@ class TestContentFilterGuardrail:
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conditional_category_sentence_boundaries(self):
|
||||
@ -2093,7 +2093,7 @@ class TestContentFilterGuardrail:
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "bias_racial" in str(exc_info.value.detail)
|
||||
|
||||
# Test case 2: Should BLOCK - identifier + dehumanizing language
|
||||
@ -2107,7 +2107,7 @@ class TestContentFilterGuardrail:
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
# Test case 3: Should BLOCK - supremacist content
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@ -2120,7 +2120,7 @@ class TestContentFilterGuardrail:
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
# Test case 4: Should BLOCK - elimination rhetoric
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@ -2133,7 +2133,7 @@ class TestContentFilterGuardrail:
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
# Test case 5: Should NOT block - identifier word alone (no block word)
|
||||
result = await guardrail.apply_guardrail(
|
||||
@ -2171,7 +2171,7 @@ class TestContentFilterGuardrail:
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
# Test case 9: Should NOT block - block word alone (no identifier)
|
||||
result = await guardrail.apply_guardrail(
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.exceptions import ModifyResponseException
|
||||
from litellm.proxy.guardrails.guardrail_hooks.custom_code.custom_code_guardrail import (
|
||||
CustomCodeCompilationError,
|
||||
CustomCodeGuardrail,
|
||||
)
|
||||
|
||||
|
||||
# str.mro() + generator gi_code + code.replace(co_names=...) + __setattr__
|
||||
# to swap a function's bytecode and read http_get's real builtins dict.
|
||||
BYTECODE_REWRITE_PAYLOAD = (
|
||||
@ -153,6 +154,49 @@ async def test_async_guardrail_compiles_and_runs():
|
||||
assert result["texts"][0] == "test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_code_pre_call_block_uses_passthrough():
|
||||
code = (
|
||||
"def apply_guardrail(inputs, request_data, input_type):\n"
|
||||
' return block("blocked by test")\n'
|
||||
)
|
||||
guardrail = _compile(code)
|
||||
|
||||
with pytest.raises(ModifyResponseException) as exc_info:
|
||||
await guardrail.apply_guardrail(
|
||||
inputs={"texts": ["test"]},
|
||||
request_data={"model": "test-model"},
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
assert exc_info.value.message == "blocked by test"
|
||||
assert exc_info.value.model == "test-model"
|
||||
assert exc_info.value.guardrail_name == "t"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_code_post_call_block_raises_http_400():
|
||||
code = (
|
||||
"def apply_guardrail(inputs, request_data, input_type):\n"
|
||||
' return block("blocked by test")\n'
|
||||
)
|
||||
guardrail = _compile(code)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await guardrail.apply_guardrail(
|
||||
inputs={"texts": ["test"]},
|
||||
request_data={"model": "test-model"},
|
||||
input_type="response",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == {
|
||||
"error": "blocked by test",
|
||||
"guardrail": "t",
|
||||
"detection_info": {},
|
||||
}
|
||||
|
||||
|
||||
def test_typical_sync_guardrail_still_works():
|
||||
code = (
|
||||
"def apply_guardrail(inputs, request_data, input_type):\n"
|
||||
|
||||
@ -10,6 +10,9 @@ import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.proxy.guardrails.guardrail_hooks.custom_code.custom_code_guardrail import (
|
||||
CustomCodeGuardrail,
|
||||
)
|
||||
from litellm.proxy.policy_engine.pipeline_executor import PipelineExecutor
|
||||
from litellm.types.proxy.policy_engine.pipeline_types import (
|
||||
GuardrailPipeline,
|
||||
@ -85,6 +88,29 @@ class AlwaysPassGuardrail(CustomGuardrail):
|
||||
return None
|
||||
|
||||
|
||||
class PassthroughBlockGuardrail(CustomGuardrail):
|
||||
"""Mock guardrail that blocks using the legacy passthrough contract."""
|
||||
|
||||
def __init__(self, guardrail_name: str):
|
||||
super().__init__(
|
||||
guardrail_name=guardrail_name,
|
||||
event_hook="pre_call",
|
||||
default_on=True,
|
||||
)
|
||||
self.calls = 0
|
||||
|
||||
def should_run_guardrail(self, data, event_type) -> bool:
|
||||
return True
|
||||
|
||||
async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
|
||||
self.calls += 1
|
||||
self.raise_passthrough_exception(
|
||||
violation_message="Content policy violation",
|
||||
request_data=data,
|
||||
detection_info={"source": "passthrough"},
|
||||
)
|
||||
|
||||
|
||||
class PiiMaskingGuardrail(CustomGuardrail):
|
||||
"""Mock guardrail that masks PII in messages and returns modified data."""
|
||||
|
||||
@ -183,6 +209,105 @@ async def test_escalation_step1_fails_step2_blocks():
|
||||
litellm.callbacks = original_callbacks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passthrough_guardrail_failure_can_pipeline_block():
|
||||
"""
|
||||
Pipeline: passthrough guardrail (on_fail: block)
|
||||
Expected: passthrough ModifyResponseException is treated as policy fail,
|
||||
and the pipeline terminal action is block.
|
||||
"""
|
||||
passthrough_guard = PassthroughBlockGuardrail(guardrail_name="passthrough-filter")
|
||||
|
||||
pipeline = GuardrailPipeline(
|
||||
mode="pre_call",
|
||||
steps=[
|
||||
PipelineStep(
|
||||
guardrail="passthrough-filter",
|
||||
on_fail="block",
|
||||
on_pass="allow",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
original_callbacks = litellm.callbacks.copy()
|
||||
litellm.callbacks = [passthrough_guard]
|
||||
|
||||
try:
|
||||
result = await PipelineExecutor.execute_steps(
|
||||
steps=pipeline.steps,
|
||||
mode=pipeline.mode,
|
||||
data={
|
||||
"model": "fake-model",
|
||||
"messages": [{"role": "user", "content": "bad content"}],
|
||||
},
|
||||
user_api_key_dict=MagicMock(),
|
||||
call_type="completion",
|
||||
policy_name="content-safety",
|
||||
)
|
||||
|
||||
assert passthrough_guard.calls == 1
|
||||
assert result.terminal_action == "block"
|
||||
assert len(result.step_results) == 1
|
||||
assert result.step_results[0].guardrail_name == "passthrough-filter"
|
||||
assert result.step_results[0].outcome == "fail"
|
||||
assert result.step_results[0].action_taken == "block"
|
||||
assert result.error_message == "Content policy violation"
|
||||
finally:
|
||||
litellm.callbacks = original_callbacks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_code_guardrail_failure_can_pipeline_block():
|
||||
"""
|
||||
Pipeline: custom code guardrail (on_fail: block)
|
||||
Expected: custom code keeps its standalone passthrough block behavior, and
|
||||
the pipeline converts that guardrail intervention into a block action.
|
||||
"""
|
||||
custom_guard = CustomCodeGuardrail(
|
||||
guardrail_name="custom-code-filter",
|
||||
custom_code=(
|
||||
"def apply_guardrail(inputs, request_data, input_type):\n"
|
||||
' return block("SSN detected")\n'
|
||||
),
|
||||
)
|
||||
|
||||
pipeline = GuardrailPipeline(
|
||||
mode="pre_call",
|
||||
steps=[
|
||||
PipelineStep(
|
||||
guardrail="custom-code-filter",
|
||||
on_fail="block",
|
||||
on_pass="allow",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
original_callbacks = litellm.callbacks.copy()
|
||||
litellm.callbacks = [custom_guard]
|
||||
|
||||
try:
|
||||
result = await PipelineExecutor.execute_steps(
|
||||
steps=pipeline.steps,
|
||||
mode=pipeline.mode,
|
||||
data={
|
||||
"model": "fake-model",
|
||||
"messages": [{"role": "user", "content": "123-45-6789"}],
|
||||
},
|
||||
user_api_key_dict=MagicMock(),
|
||||
call_type="completion",
|
||||
policy_name="content-safety",
|
||||
)
|
||||
|
||||
assert result.terminal_action == "block"
|
||||
assert len(result.step_results) == 1
|
||||
assert result.step_results[0].guardrail_name == "custom-code-filter"
|
||||
assert result.step_results[0].outcome == "fail"
|
||||
assert result.step_results[0].action_taken == "block"
|
||||
assert result.error_message == "SSN detected"
|
||||
finally:
|
||||
litellm.callbacks = original_callbacks
|
||||
|
||||
|
||||
@pytest.mark.skipif(HTTPException is None, reason="fastapi not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_early_allow_step1_passes_step2_skipped():
|
||||
|
||||
Loading…
Reference in New Issue
Block a user