fix(guardrails): resolve guardrail pipelines from the canonical metadata bucket

Policy-resolved pipelines are stored in litellm_metadata on routes like /v1/messages, but the pre_call reader fell back to the caller-supplied metadata field first, so a request that sends its own top-level metadata (Claude Code sends metadata.user_id) skipped every pipeline-managed guardrail.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-08-14 05:15:57 +00:00
parent 6704a105ee
commit f86318c4c9
2 changed files with 44 additions and 3 deletions

View file

@ -88,7 +88,10 @@ from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.prometheus import PrometheusLogger
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
from litellm.integrations.SlackAlerting.utils import _add_langfuse_trace_id_to_alert
from litellm.litellm_core_utils.core_helpers import coerce_token_limit
from litellm.litellm_core_utils.core_helpers import (
coerce_token_limit,
get_metadata_variable_name_from_kwargs,
)
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
@ -1248,7 +1251,7 @@ class ProxyLogging:
Returns the (possibly modified) data dict.
"""
metadata: Final = data.get("metadata", data.get("litellm_metadata", {})) or {}
metadata: Final = data.get(get_metadata_variable_name_from_kwargs(data)) or {}
pipelines: Final = metadata.get("_guardrail_pipelines")
if not pipelines:
return data
@ -1400,7 +1403,7 @@ class ProxyLogging:
)
# Get pipeline-managed guardrails to skip in normal loop
metadata: Final = data.get("metadata", data.get("litellm_metadata", {})) or {}
metadata: Final = data.get(get_metadata_variable_name_from_kwargs(data)) or {}
pipeline_managed: Final[set] = metadata.get("_pipeline_managed_guardrails", set())
caps: Final = ProxyLogging._callback_capabilities()

View file

@ -350,6 +350,44 @@ async def test_maybe_execute_pipelines_skips_pipelines_with_other_mode(proxy_log
assert out is data
@pytest.mark.asyncio
async def test_maybe_execute_pipelines_reads_litellm_metadata_when_caller_sends_own_metadata(
proxy_logging, make_user_api_key_auth, monkeypatch
):
"""On /v1/messages the proxy stores policy state in ``litellm_metadata``, while the
caller's provider-facing ``metadata`` (Claude Code sends ``metadata.user_id``) stays
untouched. The pipeline must still run."""
pipeline = MagicMock()
pipeline.mode = "pre_call"
pipeline.steps = []
fake_result = MagicMock()
fake_result.terminal_action = "allow"
fake_result.modified_data = None
fake_result.step_results = []
data = {
"metadata": {"user_id": "user_abc"},
"litellm_metadata": {"_guardrail_pipelines": [("policy-1", pipeline)]},
"messages": [],
"model": "m",
}
executed = MagicMock(return_value=fake_result)
async def fake_execute_steps(**kwargs):
return executed(**kwargs)
monkeypatch.setattr(
"litellm.proxy.policy_engine.pipeline_executor.PipelineExecutor.execute_steps",
fake_execute_steps,
)
await proxy_logging._maybe_execute_pipelines(
data=data,
user_api_key_dict=make_user_api_key_auth(),
call_type="anthropic_messages",
event_hook="pre_call",
)
executed.assert_called_once()
@pytest.mark.asyncio
async def test_maybe_execute_pipelines_blocks_on_block_terminal_action_raises(
proxy_logging, make_user_api_key_auth, monkeypatch