fix(responses): run streaming-scoped guardrails on socket turns, keep guardrail fields on blocks, merge guardrail lists by name

This commit is contained in:
mateo-berri 2026-10-28 23:05:58 -07:00
parent ad55e6eaaa
commit a17ad50dd3
9 changed files with 282 additions and 88 deletions

View file

@ -3,12 +3,14 @@ import copy
import hashlib
import os
import secrets
from collections.abc import Mapping, Sequence
from collections.abc import Iterable, Mapping, Sequence
from datetime import datetime
from itertools import chain
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Optional, cast, get_args
import httpx
from pydantic import TypeAdapter
from litellm._internal_context import with_service_target
from litellm._logging import verbose_logger
@ -207,6 +209,29 @@ def guardrail_request_data_with_streaming(
}
_GUARDRAIL_ENTRY_WITH_SETTINGS: Final = TypeAdapter(Mapping[str, object])
def merged_guardrail_entries(*entry_lists: Iterable[object]) -> list[object]:
"""Merges guardrail lists in order, dropping an entry whose guardrails an earlier one already names, so the
first entry for a guardrail, plain or ``{name: settings}``, is the one that stays"""
entries: Final = tuple(chain.from_iterable(entry_lists))
names: Final = tuple(_guardrail_entry_names(entry) for entry in entries)
return [entry for index, entry in enumerate(entries) if not _named_earlier(names, index)]
def _guardrail_entry_names(entry: object) -> frozenset[str]:
if isinstance(entry, str):
return frozenset((entry,))
if isinstance(entry, dict):
return frozenset(_GUARDRAIL_ENTRY_WITH_SETTINGS.validate_python(entry))
return frozenset()
def _named_earlier(names: tuple[frozenset[str], ...], index: int) -> bool:
return bool(names[index]) and names[index] <= frozenset[str]().union(*names[:index])
def _request_is_streaming(data: object, event_type: GuardrailEventHooks | None = None) -> bool:
if event_type in _REALTIME_STREAMING_HOOKS:
return True

View file

@ -48,7 +48,7 @@ from litellm.constants import (
VECTOR_STORE_CALLER_METADATA_KWARG,
X_LITELLM_DISABLE_CALLBACKS,
)
from litellm.integrations.custom_guardrail import without_server_streaming_classification
from litellm.integrations.custom_guardrail import merged_guardrail_entries, without_server_streaming_classification
from litellm.litellm_core_utils.core_helpers import is_codex_user_agent
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
@ -3895,30 +3895,14 @@ def add_guardrails_from_key_or_team_metadata(
"""
from litellm.proxy.utils import premium_user_check
# Initialize guardrails set (avoiding duplicates)
combined_guardrails: Final = set()
# Add key-level guardrails first
if key_metadata and "guardrails" in key_metadata:
if isinstance(key_metadata["guardrails"], list) and len(key_metadata["guardrails"]) > 0:
premium_user_check()
combined_guardrails.update(key_metadata["guardrails"])
# Add team-level guardrails (set automatically handles duplicates)
if team_metadata and "guardrails" in team_metadata:
if isinstance(team_metadata["guardrails"], list) and len(team_metadata["guardrails"]) > 0:
premium_user_check()
combined_guardrails.update(team_metadata["guardrails"])
# Add project-level guardrails (set automatically handles duplicates)
if project_metadata and "guardrails" in project_metadata:
if isinstance(project_metadata["guardrails"], list) and len(project_metadata["guardrails"]) > 0:
premium_user_check()
combined_guardrails.update(project_metadata["guardrails"])
# Set combined guardrails in metadata as list
if combined_guardrails:
data[metadata_variable_name]["guardrails"] = list(combined_guardrails)
configured_guardrails: Final = tuple(
metadata["guardrails"]
for metadata in (key_metadata, team_metadata, project_metadata)
if metadata and isinstance(metadata.get("guardrails"), list) and len(metadata["guardrails"]) > 0
)
if configured_guardrails:
premium_user_check()
data[metadata_variable_name]["guardrails"] = merged_guardrail_entries(*configured_guardrails)
_add_guardrails_from_key_or_team_metadata: Final = add_guardrails_from_key_or_team_metadata
@ -4013,14 +3997,10 @@ def _add_guardrails_from_policies_in_metadata(
if metadata_variable_name not in data:
data[metadata_variable_name] = {}
existing_guardrails = data[metadata_variable_name].get("guardrails", [])
if not isinstance(existing_guardrails, list):
existing_guardrails = []
# Combine existing guardrails with policy-resolved guardrails (no duplicates)
combined: Final = set(existing_guardrails)
combined.update(resolved_guardrails)
data[metadata_variable_name]["guardrails"] = list(combined)
existing_guardrails: Final = data[metadata_variable_name].get("guardrails", [])
data[metadata_variable_name]["guardrails"] = merged_guardrail_entries(
existing_guardrails if isinstance(existing_guardrails, list) else (), resolved_guardrails
)
# Store applied policies in metadata for tracking
if "applied_policies" not in data[metadata_variable_name]:
@ -4259,16 +4239,7 @@ def _apply_resolved_guardrails_to_metadata(
_REQUEST_ENTRIES.validate_python(existing_guardrails) if isinstance(existing_guardrails, list) else ()
)
selected_names: Final = frozenset(
itertools.chain.from_iterable(
_REQUEST_FIELDS.validate_python(entry).keys() if isinstance(entry, dict) else (entry,)
for entry in existing_guardrails_list
)
)
combined: Final = [
*existing_guardrails_list,
*(name for name in dict.fromkeys(resolved_guardrails) if name not in selected_names),
]
combined: Final = merged_guardrail_entries(existing_guardrails_list, resolved_guardrails)
data[metadata_variable_name]["guardrails"] = combined
verbose_proxy_logger.debug("Policy engine: added guardrails to request metadata: %s", combined)
@ -4328,12 +4299,13 @@ async def add_guardrails_from_policy_engine(
all_tags: Final = _policy_match_tags(data, metadata_variable_name)
_team_alias: Final = user_api_key_dict.team_alias
_key_alias: Final = user_api_key_dict.key_alias
_model: Final = data.get("model")
context: Final = PolicyMatchContext(
team_alias=_team_alias if isinstance(_team_alias, str) else None,
key_alias=_key_alias if isinstance(_key_alias, str) else None,
model=_model if isinstance(_model, str) else None,
tags=all_tags,
context: Final = PolicyMatchContext.model_validate(
{
"team_alias": _team_alias if isinstance(_team_alias, str) else None,
"key_alias": _key_alias if isinstance(_key_alias, str) else None,
"model": data.get("model"),
"tags": all_tags,
}
)
verbose_proxy_logger.debug(

View file

@ -36,7 +36,7 @@ from litellm._logging import verbose_proxy_logger
from litellm.anthropic_interface.exceptions.exception_mapping_utils import AnthropicExceptionMapping
from litellm.caching.dual_cache import DualCache
from litellm.constants import EMPTY_MAPPING, MANAGED_BACKGROUND_RESPONSE_METADATA_KEY, TERMINAL_RESPONSE_STATUSES
from litellm.integrations.custom_guardrail import ModifyResponseException
from litellm.integrations.custom_guardrail import ModifyResponseException, guardrail_request_data_with_streaming
from litellm.litellm_core_utils.core_helpers import as_str_mapping, get_metadata_variable_name_from_kwargs
from litellm.litellm_core_utils.internal_call_metadata import is_background_response, requests_background
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@ -89,7 +89,12 @@ from litellm.proxy.route_llm_request import raise_if_required_body_param_missing
from litellm.proxy.utils import count_request_input_tokens, handle_exception_on_proxy
from litellm.responses.litellm_completion_transformation.compaction_items import messages_since_newest_compaction
from litellm.responses.litellm_completion_transformation.count_request import MODEL_FILE_ID_MAPPING_FIELD
from litellm.responses.streaming_iterator import GuardedResponsesWsFrame, ResponsesWsFrameGuard, spawn_logging_task
from litellm.responses.streaming_iterator import (
GuardedResponsesWsFrame,
ResponsesWsFrameGuard,
ResponsesWsFrameRejection,
spawn_logging_task,
)
from litellm.router import Router
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.llms.openai import (
@ -2057,8 +2062,8 @@ _CLIENT_METADATA_KEYS: Final = ("metadata", "litellm_metadata")
_CLIENT_GUARDRAIL_SELECTORS: Final = ("guardrails", "guardrail_config", "policies")
_METADATA_GUARDRAIL_SELECTORS: Final = ("guardrails", "guardrail_config")
_PROXY_ONLY_FRAME_FIELDS: Final = (*_CLIENT_GUARDRAIL_SELECTORS, "include_guardrail_response")
_NESTED_BODY_ONLY_FIELDS: Final = (*_GUARDED_FRAME_FIELDS, *_PROXY_ONLY_FRAME_FIELDS)
_UNFORWARDED_FRAME_FIELDS: Final = (*_NESTED_BODY_ONLY_FIELDS, "litellm_metadata")
_NESTED_BODY_ONLY_FIELDS: Final = (*_GUARDED_FRAME_FIELDS, "guardrails", "policies")
_UNFORWARDED_FRAME_FIELDS: Final = (*_GUARDED_FRAME_FIELDS, *_PROXY_ONLY_FRAME_FIELDS, "litellm_metadata")
_OBJECTS: Final = TypeAdapter(list[object])
_TURN_COPIED_CONTAINERS: Final = (list, dict)
@ -2108,26 +2113,27 @@ def _responses_ws_frame_guard(
async def guard(frame: str) -> GuardedResponsesWsFrame:
frame_obj: Final = _WS_FRAME.validate_json(frame)
nested: Final = frame_obj.get("response")
stray: Final = (
next((key for key in _NESTED_BODY_ONLY_FIELDS if key in frame_obj), None)
if isinstance(nested, dict)
else None
)
stray: Final = _field_beside_nested_response(frame_obj) if isinstance(nested, dict) else None
if stray is not None:
raise ValueError(
f"response.create sends {stray} at the top level next to response; send it inside response"
)
body: Final = _WS_FRAME.validate_python(nested) if isinstance(nested, dict) else frame_obj
frame_data: Final = {
**{
key: _without_untrusted_metadata(key, value)
for key, value in body.items()
if key != "type" and key not in _PROXY_ONLY_FRAME_FIELDS
frame_data: Final = guardrail_request_data_with_streaming(
{
**{
key: _without_untrusted_metadata(key, value)
for key, value in body.items()
if key != "type" and key not in _PROXY_ONLY_FRAME_FIELDS
},
"model": model,
"litellm_call_id": str(uuid.uuid4()),
metadata_key: await _metadata_with_frame_guardrails(
bucket, body, model, metadata_key, user_api_key_dict
),
},
"model": model,
"litellm_call_id": str(uuid.uuid4()),
metadata_key: await _metadata_with_frame_guardrails(bucket, body, model, metadata_key, user_api_key_dict),
}
is_streaming=True,
)
try:
guarded_data: Final = await proxy_logging_obj.pre_call_hook(
user_api_key_dict=user_api_key_dict, data=frame_data, call_type="aresponses", guardrails_only=True
@ -2137,7 +2143,8 @@ def _responses_ws_frame_guard(
)
except Exception as e:
await _bill_rejected_frame(rejected_frame_auth, e, frame_data)
raise ValueError(proxy_exception_from_route_error(e).message) from e
proxy_exception: Final = proxy_exception_from_route_error(e)
raise ResponsesWsFrameRejection(proxy_exception.message, proxy_exception.provider_specific_fields) from e
if guarded_data.get("model") != model:
rerouted: Final = ValueError(
f"a guardrail rerouted this turn to {guarded_data.get('model')!r}; an open Responses WebSocket"
@ -2153,7 +2160,10 @@ def _responses_ws_frame_guard(
if key not in _UNFORWARDED_FRAME_FIELDS
}
rewritten: Final = (
{**frame_obj, "response": {**kept, **guarded_fields}}
{
**{key: value for key, value in frame_obj.items() if key not in _PROXY_ONLY_FRAME_FIELDS},
"response": {**kept, **guarded_fields},
}
if isinstance(nested, dict)
else {**kept, **guarded_fields}
)
@ -2168,6 +2178,15 @@ def _responses_ws_frame_guard(
return guard
def _field_beside_nested_response(frame: Mapping[str, object]) -> str | None:
metadata_picks: Final = (
f"{key}.guardrails"
for key, value in frame.items()
if key in _CLIENT_METADATA_KEYS and isinstance(value, dict) and "guardrails" in value
)
return next(chain((key for key in _NESTED_BODY_ONLY_FIELDS if key in frame), metadata_picks), None)
class _FrameGuardrailSelection(TypedDict, total=False):
guardrails: ReadOnly[Sequence[str | Mapping[str, Mapping[str, object]]]]
guardrail_config: ReadOnly[Mapping[str, object]]

View file

@ -146,6 +146,7 @@ from litellm.exceptions import (
from litellm.integrations.custom_guardrail import (
CustomGuardrail,
ModifyResponseException,
merged_guardrail_entries,
)
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.prometheus import PrometheusLogger
@ -9086,8 +9087,7 @@ def _merge_guardrails_with_existing(data: dict, model_level_guardrails: object)
if not isinstance(model_level_guardrails, list):
model_level_guardrails = [model_level_guardrails] if model_level_guardrails else []
# Combine existing and model-level guardrails
metadata["guardrails"] = list(set(existing_guardrails + model_level_guardrails))
metadata["guardrails"] = merged_guardrail_entries(existing_guardrails, model_level_guardrails)
return modified_data

View file

@ -2172,6 +2172,29 @@ class GuardedResponsesWsFrame:
ResponsesWsFrameGuard: TypeAlias = Callable[[str], Awaitable[GuardedResponsesWsFrame]]
class ResponsesWsFrameRejection(ValueError):
def __init__(self, message: str, provider_specific_fields: Mapping[str, object] | None = None) -> None:
super().__init__(message)
self.provider_specific_fields: Final = provider_specific_fields
def _frame_rejection_event(rejection: Exception) -> str:
provider_fields: Final = (
rejection.provider_specific_fields if isinstance(rejection, ResponsesWsFrameRejection) else None
)
return json.dumps(
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": str(rejection),
**({"provider_specific_fields": dict(provider_fields)} if provider_fields else {}),
},
},
default=str,
)
async def _reject_guarded_turn(
websocket: ResponsesClientWebSocket, guarded: GuardedResponsesWsFrame, rejection: Exception
) -> None:
@ -2437,7 +2460,8 @@ class ResponsesWebSocketStreaming:
return await self.frame_guard(message)
except Exception as e: # noqa: BLE001 # any guardrail rejection maps to one client-facing error event
verbose_logger.info("Responses WebSocket: pre_call guardrail rejected a response.create frame: %s", e)
await self._send_error_event("invalid_request_error", str(e))
with contextlib.suppress(Exception):
await self.websocket.send_text(_frame_rejection_event(e))
return None
async def _forward_guarded_frame(
@ -2865,14 +2889,6 @@ class ResponsesWebSocketStreaming:
except Exception: # noqa: BLE001, S110 # client may already be gone
pass
async def _send_error_event(self, error_type: str, message: str) -> None:
try:
await self.websocket.send_text(
json.dumps({"type": "error", "error": {"type": error_type, "message": message}})
)
except Exception: # noqa: BLE001, S110 # client may already be gone
pass
async def client_to_backend(self) -> None:
"""Forward response.create events from client to backend."""
try:
@ -3286,7 +3302,7 @@ class ManagedResponsesWebSocketHandler:
return await self.frame_guard(raw_message)
except Exception as e: # noqa: BLE001 # any guardrail rejection maps to one client-facing error event
verbose_logger.info("ManagedResponsesWS: pre_call guardrail rejected a response.create frame: %s", e)
await self._send_error(str(e), error_type="invalid_request_error")
await self._send_frame(_frame_rejection_event(e))
return None
@staticmethod

View file

@ -3786,3 +3786,13 @@ def test_default_on_guardrail() -> None:
)
is True
)
def test_merged_guardrail_entries_keeps_the_first_entry_per_guardrail_and_any_entry_that_names_a_new_one():
from litellm.integrations.custom_guardrail import merged_guardrail_entries
assert merged_guardrail_entries(["a"], [{"a": {}, "b": {}}, {"a": {"x": 1}}], ["b", "c", "c"]) == [
"a",
{"a": {}, "b": {}},
"c",
]

View file

@ -2002,7 +2002,10 @@ class _FrameGuardrail(CustomGuardrail):
self.inputs.append(data["input"])
self.configs.append(cast(dict[str, object], data["litellm_metadata"]).get("guardrail_config"))
if self.blocks:
raise HTTPException(status_code=400, detail={"error": f"Content blocked by {self.guardrail_name}"})
raise HTTPException(
status_code=400,
detail={"error": f"Content blocked by {self.guardrail_name}", "guardrail_name": self.guardrail_name},
)
return {**data, "input": f"{data['input']} [{self.guardrail_name}]"} if self.rewrites else data
@ -2350,6 +2353,33 @@ class TestResponsesWSFirstFrameModelAuth:
]
assert json.loads(guarded.frame) == {"type": "response.create", "input": "hi", "metadata": {}}
@pytest.mark.asyncio
async def test_frame_guard_runs_streaming_scoped_guardrails_and_skips_non_streaming_ones_on_a_socket_turn(self):
from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard
streaming_only = _FrameGuardrail("streaming-only")
streaming_only.apply_stream_scope("streaming")
streaming_during_call = _FrameGuardrail("streaming-during-call", event_hook=GuardrailEventHooks.during_call)
streaming_during_call.apply_stream_scope("streaming")
non_streaming_only = _FrameGuardrail("non-streaming-only")
non_streaming_only.apply_stream_scope("non_streaming")
frame = {
"type": "response.create",
"input": "hi",
"guardrails": ["streaming-only", "streaming-during-call", "non-streaming-only"],
}
with _guardrails_on(streaming_only, streaming_during_call, non_streaming_only) as proxy_logging_obj:
guard = _responses_ws_frame_guard(
{"model": "gpt-4o-mini", "litellm_metadata": {}}, _key(), proxy_logging_obj, MagicMock()
)
guarded = await guard(json.dumps(frame))
assert streaming_only.inputs == ["hi"]
assert streaming_during_call.inputs == ["hi"]
assert non_streaming_only.inputs == []
assert json.loads(guarded.frame) == {"type": "response.create", "input": "hi"}
@pytest.mark.asyncio
async def test_frame_guard_blocks_a_turn_with_the_message_a_selected_during_call_guardrail_raises(self):
from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard
@ -2588,7 +2618,9 @@ class TestResponsesWSFirstFrameModelAuth:
[
({"input": "forbidden"}, "input"),
({"guardrails": ["frame-pick"]}, "guardrails"),
({"include_guardrail_response": True}, "include_guardrail_response"),
({"policies": ["frame-policy"]}, "policies"),
({"metadata": {"guardrails": ["frame-pick"]}}, "metadata.guardrails"),
({"litellm_metadata": {"guardrails": ["frame-pick"]}}, "litellm_metadata.guardrails"),
],
)
async def test_frame_guard_rejects_a_frame_that_carries_input_at_both_levels(self, top_level, field):
@ -2606,6 +2638,78 @@ class TestResponsesWSFirstFrameModelAuth:
proxy_logging_obj.pre_call_hook.assert_not_awaited()
@pytest.mark.asyncio
async def test_frame_guard_forwards_a_nested_turn_without_top_level_fields_that_pick_no_guardrail(self):
from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard
proxy_logging_obj = MagicMock(during_call_hook=AsyncMock())
proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["data"])
guard = _responses_ws_frame_guard(
{"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock(), proxy_logging_obj, MagicMock()
)
with patch.object(litellm, "callbacks", []):
guarded = await guard(
json.dumps(
{
"type": "response.create",
"include_guardrail_response": True,
"guardrail_config": {"frame-pick": {"threshold": 1}},
"metadata": {"trace": "t1"},
"response": {"input": "hi"},
}
)
)
assert json.loads(guarded.frame) == {
"type": "response.create",
"metadata": {"trace": "t1"},
"response": {"input": "hi"},
}
@pytest.mark.asyncio
async def test_a_blocked_socket_turn_answers_with_the_guardrail_fields_http_returns(self):
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard
from litellm.responses.streaming_iterator import ManagedResponsesWebSocketHandler
frame_pick = _FrameGuardrail("frame-pick", blocks=True)
websocket = MagicMock(send_text=AsyncMock())
with _guardrails_on(frame_pick) as proxy_logging_obj:
handler = ManagedResponsesWebSocketHandler(
websocket=websocket,
model="anthropic/claude-haiku-5-5",
logging_obj=Logging(
model="anthropic/claude-haiku-5-5",
messages=[],
stream=True,
call_type="aresponses",
start_time=0,
litellm_call_id="test-id",
function_id="test-func",
),
frame_guard=_responses_ws_frame_guard(
{"model": "claude-managed", "litellm_metadata": {}}, _key(), proxy_logging_obj, MagicMock()
),
)
await handler._process_response_create(
json.dumps({"type": "response.create", "input": "hi", "guardrails": ["frame-pick"]})
)
assert json.loads(websocket.send_text.await_args.args[0]) == {
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "Content blocked by frame-pick",
"provider_specific_fields": {
"error": "Content blocked by frame-pick",
"guardrail_name": "frame-pick",
"guardrail_mode": "pre_call",
},
},
}
@pytest.mark.asyncio
async def test_frame_guard_keeps_the_guardrail_error_when_billing_the_rejected_frame_fails(self):
import litellm

View file

@ -5328,6 +5328,52 @@ async def test_add_guardrails_from_policy_engine_keeps_configured_guardrails_and
assert data["metadata"]["guardrails"] == [configured, "audit", "hipaa_audit"]
def test_add_guardrails_from_auth_metadata_lists_each_key_and_team_guardrail_once_keeping_the_key_s_settings():
from litellm.proxy.litellm_pre_call_utils import add_guardrails_from_auth_metadata
configured = {"pii_blocker": {"extra_body": {"threshold": 0.9}}}
data = {"model": "gpt-4", "metadata": {}}
user_api_key_dict = UserAPIKeyAuth(
api_key="test-key",
metadata={"guardrails": [configured, "audit"]},
team_metadata={"guardrails": ["audit", "pii_blocker", "toxic"]},
)
with patch("litellm.proxy.utils.premium_user_check"):
add_guardrails_from_auth_metadata(
user_api_key_dict=user_api_key_dict, data=data, metadata_variable_name="metadata"
)
assert data["metadata"]["guardrails"] == [configured, "audit", "toxic"]
def test_add_guardrails_from_auth_metadata_keeps_a_selected_guardrail_s_settings_when_a_key_policy_adds_it():
from litellm.proxy.litellm_pre_call_utils import add_guardrails_from_auth_metadata
from litellm.proxy.policy_engine.policy_registry import get_policy_registry
from litellm.types.proxy.policy_engine import Policy, PolicyGuardrails
configured = {"pii_blocker": {"extra_body": {"threshold": 0.9}}}
data = {"model": "gpt-4", "metadata": {"guardrails": [configured, "audit"]}}
policy_registry = get_policy_registry()
policy_registry._policies = {
"baseline": Policy(guardrails=PolicyGuardrails(add=["pii_blocker", "audit", "hipaa_audit"])),
}
policy_registry._initialized = True
try:
with patch("litellm.proxy.utils.premium_user_check"):
add_guardrails_from_auth_metadata(
user_api_key_dict=UserAPIKeyAuth(api_key="test-key", metadata={"policies": ["baseline"]}),
data=data,
metadata_variable_name="metadata",
)
finally:
policy_registry._policies = {}
policy_registry._initialized = False
assert data["metadata"]["guardrails"] == [configured, "audit", "hipaa_audit"]
@pytest.mark.asyncio
async def test_add_guardrails_from_policy_engine_applies_inherited_parent_guardrail_when_child_condition_misses():
from litellm.proxy.policy_engine.attachment_registry import get_attachment_registry

View file

@ -360,7 +360,9 @@ def test_merge_guardrails_with_existing_creates_metadata_when_missing():
}
def test_merge_guardrails_with_existing_raises_on_unhashable_guardrail():
data = {"metadata": {"guardrails": [{"unhashable": True}]}}
with pytest.raises(TypeError):
_merge_guardrails_with_existing(data, ["g1"])
def test_merge_guardrails_with_existing_keeps_a_selected_guardrail_s_settings_over_the_model_s_plain_entry():
configured = {"pii-redact": {"extra_body": {"threshold": 0.9}}}
result = _merge_guardrails_with_existing({"metadata": {"guardrails": [configured]}}, ["pii-redact", "toxic-filter"])
assert result["metadata"]["guardrails"] == [configured, "toxic-filter"]