fix(proxy): name the blocking guardrail in x-litellm-applied-guardrails

When a guardrail hook raises, the common ProxyLogging dispatch (sequential and parallel pre_call, pipeline block, during_call and post_call metrics wrapper, streaming iterator wrapper) now records that guardrail in applied_guardrails before re-raising, and pre_call_hook folds request-declared guardrails in on its raising path. Buffered streams rebuild their response headers after the first chunk so a post_call block reached while buffering carries the blocker too

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-17 08:39:01 +00:00
parent 4b368bf066
commit 9fa85c5da5
8 changed files with 234 additions and 20 deletions

View file

@ -2576,11 +2576,14 @@ class ProxyBaseLLMRequestProcessing:
)
async def refresh_stream_headers() -> Mapping[str, str]:
"""`custom_headers` rebuilt for whichever deployment served the stream."""
if not getattr(response, "fallback_headers_adopted", False):
return custom_headers
"""`custom_headers` rebuilt once the first chunk is buffered, from `self.data` as the
guardrails left it and for whichever deployment served the stream."""
return self._stream_response_headers(
hidden_params=get_hidden_params_dict(response),
hidden_params=(
get_hidden_params_dict(response)
if getattr(response, "fallback_headers_adopted", False)
else hidden_params
),
user_api_key_dict=user_api_key_dict,
logging_obj=logging_obj,
version=version,

View file

@ -123,6 +123,7 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
)
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.common_utils.callback_utils import add_guardrail_to_applied_guardrails_header
from litellm.proxy.common_utils.config_sync_pubsub import publish_config_param_change
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.db.create_views import (
@ -437,6 +438,12 @@ def _enrich_http_exception_with_guardrail_context(exc: BaseException, callback:
detail.setdefault("guardrail_mode", event_hook)
def _record_raising_guardrail(request_data: Mapping[str, object], callback: object) -> None:
guardrail_name: Final[object] = getattr(callback, "guardrail_name", None)
if isinstance(request_data, dict) and isinstance(guardrail_name, str):
add_guardrail_to_applied_guardrails_header(request_data=request_data, guardrail_name=guardrail_name)
def _is_client_error_exception(exc: Exception) -> bool:
if isinstance(exc, HTTPException):
return exc.status_code < 500
@ -1795,13 +1802,19 @@ class ProxyLogging:
)
if expected_if_unmutated is not None:
callback.mark_pre_call_hook_ran(expected_if_unmutated)
result: Final = await self._process_guardrail_callback(
callback=callback,
data=input_data,
user_api_key_dict=user_api_key_dict,
call_type=call_type,
event_type=GuardrailEventHooks.pre_call,
)
try:
result: Final = await self._process_guardrail_callback(
callback=callback,
data=input_data,
user_api_key_dict=user_api_key_dict,
call_type=call_type,
event_type=GuardrailEventHooks.pre_call,
)
except SensitiveDataRouteException:
raise
except Exception:
_record_raising_guardrail(data, callback)
raise
if (
scans_raw_request
and expected_if_unmutated is not None
@ -2031,6 +2044,7 @@ class ProxyLogging:
callback: Final = PipelineExecutor.find_guardrail_callback(blocking_step.guardrail_name)
if callback is not None:
_enrich_http_exception_with_guardrail_context(original_exception, callback)
_record_raising_guardrail(data, callback)
raise original_exception
step_results_serializable: Final = [
@ -2296,8 +2310,10 @@ class ProxyLogging:
if data is not None:
self._process_guardrail_metadata(data)
return data
except Exception as e:
raise e
except Exception:
if data is not None:
self._process_guardrail_metadata(data)
raise
async def _run_parallel_pre_call_guardrails(
self,
@ -2355,6 +2371,8 @@ class ProxyLogging:
# live kwargs.
if callback.scan_raw_request and not isinstance(result, BaseException) and result is not None:
callback.mark_pre_call_hook_ran(data)
if isinstance(result, BaseException) and not isinstance(result, SensitiveDataRouteException):
_record_raising_guardrail(data, callback)
raised: Final = tuple(result for result in results if isinstance(result, BaseException))
blocking: Final = next((exc for exc in raised if not _exception_changes_request_flow(exc)), None)
if blocking is not None:
@ -2433,7 +2451,12 @@ class ProxyLogging:
break
@staticmethod
async def _run_guardrail_with_metrics(callback: object, coro: Awaitable[_T], hook_type: str) -> _T:
async def _run_guardrail_with_metrics(
callback: object,
coro: Awaitable[_T],
hook_type: str,
request_data: Mapping[str, object],
) -> _T:
"""
Await `coro`, recording its latency and status to the
`litellm_guardrail_latency_seconds` metric under `hook_type`, and
@ -2453,6 +2476,7 @@ class ProxyLogging:
status = "error"
error_type = type(e).__name__
_enrich_http_exception_with_guardrail_context(e, callback)
_record_raising_guardrail(request_data, callback)
raise
finally:
ProxyLogging._emit_guardrail_metrics(
@ -2465,7 +2489,9 @@ class ProxyLogging:
@staticmethod
async def _wrap_streaming_iterator_with_enrichment(
callback: object, gen: AsyncGenerator[_T, None]
callback: object,
gen: AsyncGenerator[_T, None],
request_data: Mapping[str, object],
) -> AsyncGenerator[_T, None]:
"""
Yield from `gen`; if iteration raises an HTTPException with dict detail,
@ -2480,6 +2506,7 @@ class ProxyLogging:
yield chunk
except Exception as e:
_enrich_http_exception_with_guardrail_context(e, callback)
_record_raising_guardrail(request_data, callback)
raise
# Cache for callback-capability detection. Keyed on a signature of
@ -2714,6 +2741,7 @@ class ProxyLogging:
call_type=call_type,
),
"during_call",
request_data=data,
)
return
await self._run_guardrail_with_metrics(
@ -2724,6 +2752,7 @@ class ProxyLogging:
call_type=call_type,
),
"during_call",
request_data=data,
)
async def failed_tracking_alert(
@ -3242,6 +3271,7 @@ class ProxyLogging:
response=response,
),
"post_call",
request_data=data,
)
else:
guardrail_response = await self._run_guardrail_with_metrics(
@ -3252,6 +3282,7 @@ class ProxyLogging:
response=response,
),
"post_call",
request_data=data,
)
if guardrail_response is not None:
@ -3315,6 +3346,7 @@ class ProxyLogging:
response=response,
),
"post_call",
request_data=data,
)
else:
await self._run_guardrail_with_metrics(
@ -3325,6 +3357,7 @@ class ProxyLogging:
response=response,
),
"post_call",
request_data=data,
)
results: Final = await asyncio.gather(
@ -3388,6 +3421,7 @@ class ProxyLogging:
request_data=request_data,
),
"post_mcp_call",
request_data=request_data,
)
return response
@ -3637,6 +3671,7 @@ class ProxyLogging:
response=current_response,
request_data=request_data,
),
request_data=request_data,
)
else:
# kind == "apply_guardrail": route through unified_guardrail
@ -3649,6 +3684,7 @@ class ProxyLogging:
guardrail_to_apply=resolved_callback,
buffer_until_moderated_default=(kind == "override"),
),
request_data=request_data,
)
pipeline_translation: Final = (

View file

@ -40,9 +40,12 @@ from litellm.proxy.common_request_processing import (
_parse_event_data_for_error,
_resolve_per_request_model_group_alias,
_should_return_raw_model_name,
_sse_error_frames,
_UpstreamClosingStreamingResponse,
create_response,
sse_error_payload,
)
from litellm.proxy.common_utils.callback_utils import add_guardrail_to_applied_guardrails_header
from litellm.proxy.dd_span_tagger import DDSpanTagger
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.proxy._types import ProxyErrorTypes, ProxyException
@ -8952,6 +8955,60 @@ class TestStreamingResponseHeadersFollowFallback:
assert "llm_provider-stale-marker" not in result.headers
assert result.headers["x-callback-header"] == "kept"
@pytest.mark.asyncio
async def test_streaming_block_headers_name_the_blocking_guardrail(self, monkeypatch):
processor_data: dict[str, object] = {"model": "oa", "stream": True, "metadata": {}}
def select_data_generator(**kwargs):
async def generator():
add_guardrail_to_applied_guardrails_header(processor_data, "stream-blocker")
_, error_obj = sse_error_payload(HTTPException(status_code=400, detail="blocked"))
for frame in _sse_error_frames(error_obj):
yield frame
return generator()
logging_obj = MagicMock()
logging_obj.litellm_call_id = "lit-7144-call"
logging_obj._defer_async_logging = False
logging_obj._on_deferred_stream_complete = None
logging_obj.cost_breakdown = None
processor_data["litellm_logging_obj"] = logging_obj
processor = ProxyBaseLLMRequestProcessing(data=processor_data)
proxy_logging_obj = MagicMock(spec=ProxyLogging)
proxy_logging_obj.during_call_hook = AsyncMock(return_value=None)
proxy_logging_obj.update_request_status = AsyncMock(return_value=None)
proxy_logging_obj.post_call_success_hook = AsyncMock(
side_effect=lambda data, user_api_key_dict, response: response
)
proxy_logging_obj.post_call_response_headers_hook = AsyncMock(return_value={})
async def fake_route_request(**kwargs):
async def call():
return SimpleNamespace(_hidden_params={}, fallback_headers_adopted=False)
return call()
monkeypatch.setattr(litellm.proxy.common_request_processing, "route_request", fake_route_request)
result = await processor.base_process_llm_request(
request=Request(scope={"type": "http", "headers": []}),
fastapi_response=Response(),
user_api_key_dict=ProxyUserAPIKeyAuth(api_key="sk-test"),
route_type="acompletion",
proxy_logging_obj=proxy_logging_obj,
general_settings={},
proxy_config=MagicMock(spec=ProxyConfig),
select_data_generator=select_data_generator,
is_streaming_request=True,
skip_pre_call_logic=True,
)
assert isinstance(result, JSONResponse)
assert result.status_code == 400
assert result.headers["x-litellm-applied-guardrails"] == "stream-blocker"
class _MessagesFallbackStream:
def __init__(self) -> None:

View file

@ -6,6 +6,7 @@ from typing import Any, Dict
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
import litellm
from litellm.integrations.custom_guardrail import CustomGuardrail
@ -84,3 +85,20 @@ async def test_during_call_hook_guardrail_error_raises(proxy_logging, make_user_
user_api_key_dict=make_user_api_key_auth(),
call_type="completion",
)
@pytest.mark.asyncio
async def test_during_call_block_names_the_blocking_guardrail_in_applied_guardrails(
proxy_logging, make_user_api_key_auth, monkeypatch
):
g = _make_guardrail("blocker")
g.async_moderation_hook = AsyncMock(side_effect=HTTPException(status_code=400, detail="blocked"))
monkeypatch.setattr(litellm, "callbacks", [_make_guardrail("passer"), g])
data = {"model": "m", "metadata": {}}
with pytest.raises(HTTPException):
await proxy_logging.during_call_hook(
data=data,
user_api_key_dict=make_user_api_key_auth(),
call_type="completion",
)
assert "blocker" in data["metadata"]["applied_guardrails"]

View file

@ -527,17 +527,19 @@ def test_handle_pipeline_result_block_enriches_with_guardrail_name_and_mode():
result.step_results = [MagicMock(guardrail_name="g")]
result.original_exception = original
data: dict[str, object] = {"model": "m"}
saved = litellm.callbacks
litellm.callbacks = [cb]
try:
with pytest.raises(HTTPException) as info:
ProxyLogging._handle_pipeline_result(result=result, data={"model": "m"}, policy_name="p")
ProxyLogging._handle_pipeline_result(result=result, data=data, policy_name="p")
finally:
litellm.callbacks = saved
assert info.value is original
assert info.value.detail["guardrail_name"] == "g"
assert info.value.detail["guardrail_mode"] == GuardrailEventHooks.pre_call
assert data["metadata"] == {"applied_guardrails": ["g"]}
def test_handle_pipeline_result_block_does_not_reraise_sensitive_data_route():
@ -617,7 +619,7 @@ async def test_run_guardrail_with_metrics_passes_result_and_records_success(monk
monkeypatch.setattr(litellm, "callbacks", [prom])
out = await ProxyLogging._run_guardrail_with_metrics(
callback=MagicMock(guardrail_name="g"), coro=task(), hook_type="during_call"
callback=MagicMock(guardrail_name="g"), coro=task(), hook_type="during_call", request_data={}
)
assert out == {"a": 1, "b": 2, "c": 3}
@ -643,7 +645,7 @@ async def test_run_guardrail_with_metrics_records_error_and_enriches(monkeypatch
monkeypatch.setattr(litellm, "callbacks", [prom])
with pytest.raises(HTTPException):
await ProxyLogging._run_guardrail_with_metrics(callback=cb, coro=task(), hook_type="post_call")
await ProxyLogging._run_guardrail_with_metrics(callback=cb, coro=task(), hook_type="post_call", request_data={})
assert detail["guardrail_name"] == "presidio"
recorded = prom._record_guardrail_metrics.call_args.kwargs

View file

@ -6,6 +6,7 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
import litellm
from litellm.integrations.custom_guardrail import CustomGuardrail
@ -96,3 +97,26 @@ async def test_post_call_success_hook_guardrail_returns_modified_response(
data={}, response={"orig": True}, user_api_key_dict=make_user_api_key_auth()
)
assert out == modified
@pytest.mark.asyncio
@pytest.mark.parametrize("run_in_parallel", [False, True], ids=["sequential", "parallel"])
async def test_post_call_block_names_the_blocking_guardrail_in_applied_guardrails(
proxy_logging, make_user_api_key_auth, monkeypatch, run_in_parallel
):
def _passer_that_records(data, user_api_key_dict, response):
data["metadata"]["applied_guardrails"] = ["passer"]
passer = _make_guardrail("passer")
passer.async_post_call_success_hook = AsyncMock(side_effect=_passer_that_records)
passer.run_in_parallel = run_in_parallel
blocker = _make_guardrail("blocker")
blocker.async_post_call_success_hook = AsyncMock(side_effect=HTTPException(status_code=400, detail="blocked"))
blocker.run_in_parallel = run_in_parallel
monkeypatch.setattr(litellm, "callbacks", [passer, blocker])
data = {"model": "m", "metadata": {}}
with pytest.raises(HTTPException):
await proxy_logging.post_call_success_hook(
data=data, response=MagicMock(), user_api_key_dict=make_user_api_key_auth()
)
assert data["metadata"]["applied_guardrails"] == ["passer", "blocker"]

View file

@ -905,3 +905,43 @@ async def test_scan_raw_request_warns_on_in_place_mutation_returning_none(
)
mock_logger.warning.assert_called_once()
assert "scan_raw_request" in str(mock_logger.warning.call_args)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"blocker_kwargs",
[
pytest.param({}, id="sequential"),
pytest.param({"scan_raw_request": True}, id="scan_raw_request"),
pytest.param({"run_in_parallel": True}, id="parallel"),
],
)
async def test_pre_call_block_names_the_blocking_guardrail_in_applied_guardrails(
proxy_logging, make_user_api_key_auth, monkeypatch, blocker_kwargs
):
monkeypatch.setattr(litellm, "callbacks", [_BlockOnSecretGuardrail(**blocker_kwargs)])
proxy_logging.slack_alerting_instance = MagicMock(alerting=None)
data = _secret_request()
with pytest.raises(HTTPException, match="blocked"):
await proxy_logging.pre_call_hook(
user_api_key_dict=make_user_api_key_auth(),
data=data,
call_type="completion",
)
assert data["metadata"]["applied_guardrails"] == ["blocker"]
@pytest.mark.asyncio
async def test_pre_call_block_keeps_request_declared_guardrail_in_applied_guardrails(
proxy_logging, make_user_api_key_auth, monkeypatch
):
monkeypatch.setattr(litellm, "callbacks", [_BlockOnSecretGuardrail(default_on=False)])
proxy_logging.slack_alerting_instance = MagicMock(alerting=None)
data = {**_secret_request(), "metadata": {"guardrails": ["blocker", "declared-post-call"]}}
with pytest.raises(HTTPException, match="blocked"):
await proxy_logging.pre_call_hook(
user_api_key_dict=make_user_api_key_auth(),
data=data,
call_type="completion",
)
assert data["metadata"]["applied_guardrails"] == ["blocker", "declared-post-call"]

View file

@ -20,6 +20,7 @@ from fastapi import HTTPException
import litellm
from litellm.exceptions import GuardrailRaisedException
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import (
@ -27,6 +28,7 @@ from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterato
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.utils import ProxyLogging
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import Usage
@ -175,7 +177,7 @@ async def test_wrap_streaming_iterator_with_enrichment_passes_through_chunks(pro
yield ch
cb = MagicMock(guardrail_name="g", event_hook="pre_call")
wrapped = proxy_logging._wrap_streaming_iterator_with_enrichment(callback=cb, gen=gen())
wrapped = proxy_logging._wrap_streaming_iterator_with_enrichment(callback=cb, gen=gen(), request_data={})
out = [ch async for ch in wrapped]
snapshot = {
"chunks": out,
@ -201,7 +203,7 @@ async def test_wrap_streaming_iterator_with_enrichment_enriches_http_exception_r
raise HTTPException(status_code=400, detail=detail)
cb = MagicMock(guardrail_name="presidio", event_hook="post_call")
wrapped = proxy_logging._wrap_streaming_iterator_with_enrichment(callback=cb, gen=boom_gen())
wrapped = proxy_logging._wrap_streaming_iterator_with_enrichment(callback=cb, gen=boom_gen(), request_data={})
with pytest.raises(HTTPException):
async for _ in wrapped:
pass
@ -696,3 +698,35 @@ async def test_post_call_response_headers_hook_swallows_callback_error(proxy_log
data={}, user_api_key_dict=make_user_api_key_auth(), response=response
)
assert out == {}
@pytest.mark.asyncio
async def test_stream_guardrail_block_names_the_blocking_guardrail_in_applied_guardrails(
proxy_logging, make_user_api_key_auth, monkeypatch
):
class _StreamBlocker(CustomGuardrail):
def __init__(self) -> None:
super().__init__(guardrail_name="stream-blocker", event_hook=GuardrailEventHooks.post_call, default_on=True)
async def async_post_call_streaming_iterator_hook(
self, user_api_key_dict: UserAPIKeyAuth, response: AsyncIterator[object], request_data: dict[str, object]
) -> AsyncGenerator[object, None]:
async for _ in response:
raise HTTPException(status_code=400, detail={"error": "blocked"})
yield # pragma: no cover
monkeypatch.setattr(litellm, "callbacks", [_StreamBlocker()])
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None, raising=False)
async def upstream():
yield "chunk"
request_data: dict[str, object] = {"metadata": {}}
with pytest.raises(HTTPException):
async for _ in proxy_logging.async_post_call_streaming_iterator_hook(
response=upstream(),
user_api_key_dict=make_user_api_key_auth(),
request_data=request_data,
):
pass
assert request_data["metadata"]["applied_guardrails"] == ["stream-blocker"]