mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(bedrock_guardrails): skip ApplyGuardrail when there is no content to scan (#36441)
This commit is contained in:
parent
bea31871fc
commit
417c70589f
3 changed files with 139 additions and 2 deletions
|
|
@ -826,7 +826,6 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
guardrail call and is logged exactly once here.
|
||||
"""
|
||||
start_time: Final = datetime.now(timezone.utc)
|
||||
credentials, aws_region_name = self._load_credentials()
|
||||
bedrock_request_data: Final[dict] = dict(
|
||||
self.convert_to_bedrock_format(source=source, messages=messages, response=response)
|
||||
)
|
||||
|
|
@ -850,6 +849,16 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
)
|
||||
|
||||
content: Final[tuple[BedrockContentItem, ...]] = tuple(bedrock_request_data.get("content") or ())
|
||||
if not content:
|
||||
# ApplyGuardrail rejects an empty content list with a 400, so a turn this extractor
|
||||
# found no text in is skipped rather than turned into a failed request
|
||||
verbose_proxy_logger.debug(
|
||||
"Bedrock Guardrail %s: no %s content to scan, skipping ApplyGuardrail",
|
||||
self.guardrail_name,
|
||||
source,
|
||||
)
|
||||
return BedrockGuardrailResponse()
|
||||
credentials, aws_region_name = self._load_credentials()
|
||||
allow_chunking: Final = not self._content_uses_contextual_grounding(content)
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -987,6 +987,134 @@ async def test_bedrock_apply_guardrail_with_only_tool_calls_response():
|
|||
print("✅ apply_guardrail with tool_calls test passed - no API call made")
|
||||
|
||||
|
||||
def _anthropic_tool_result_conversation(
|
||||
extra_blocks: tuple[dict[str, str], ...] = (),
|
||||
) -> list[dict[str, object]]:
|
||||
"""Anthropic /v1/messages history whose latest user turn is a tool_result follow-up."""
|
||||
return [
|
||||
{"role": "user", "content": "What is the weather in Paris?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "tool_use", "id": "toolu_01A", "name": "get_weather", "input": {"city": "Paris"}}],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "toolu_01A", "content": "18C and sunny"},
|
||||
*extra_blocks,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_during_call_hook_skips_bedrock_call_for_tool_result_only_turn():
|
||||
"""A tool_result-only latest user turn must not post an empty content list to Bedrock.
|
||||
|
||||
Regression for `400: At least one GuardrailContentBlock must be provided` on
|
||||
/v1/messages: with experimental_use_latest_role_message_only the scanned turn is the
|
||||
Anthropic tool_result block, which carries no text, so ApplyGuardrail rejected the call.
|
||||
"""
|
||||
guardrail = BedrockGuardrail(
|
||||
guardrail_name="bedrock-tool-result",
|
||||
guardrailIdentifier="test-guardrail",
|
||||
guardrailVersion="DRAFT",
|
||||
event_hook=GuardrailEventHooks.during_call,
|
||||
default_on=True,
|
||||
experimental_use_latest_role_message_only=True,
|
||||
)
|
||||
data = {"model": "claude-sonnet-4-5", "messages": _anthropic_tool_result_conversation()}
|
||||
|
||||
with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post:
|
||||
await guardrail.async_moderation_hook(
|
||||
data=data,
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
call_type=CallTypes.anthropic_messages.value,
|
||||
)
|
||||
|
||||
mock_post.assert_not_called()
|
||||
assert data["messages"] == _anthropic_tool_result_conversation()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_during_call_hook_still_scans_tool_result_turn_carrying_text():
|
||||
"""The skip must be limited to turns with nothing to scan, never to tool_result turns as such."""
|
||||
guardrail = BedrockGuardrail(
|
||||
guardrail_name="bedrock-tool-result-text",
|
||||
guardrailIdentifier="test-guardrail",
|
||||
guardrailVersion="DRAFT",
|
||||
event_hook=GuardrailEventHooks.during_call,
|
||||
default_on=True,
|
||||
experimental_use_latest_role_message_only=True,
|
||||
)
|
||||
data = {
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": _anthropic_tool_result_conversation(({"type": "text", "text": "now summarize that"},)),
|
||||
}
|
||||
mock_credentials = MagicMock()
|
||||
mock_credentials.access_key = "test-access-key"
|
||||
mock_credentials.secret_key = "test-secret-key"
|
||||
mock_credentials.token = None
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"action": "NONE", "assessments": []}
|
||||
|
||||
with (
|
||||
patch.object(guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1")),
|
||||
patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post,
|
||||
):
|
||||
mock_post.return_value = mock_response
|
||||
await guardrail.async_moderation_hook(
|
||||
data=data,
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
call_type=CallTypes.anthropic_messages.value,
|
||||
)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
sent = mock_post.call_args.kwargs["data"].decode()
|
||||
assert "now summarize that" in sent
|
||||
# tool_result text is not extracted by this path (https://github.com/BerriAI/litellm/issues/33086)
|
||||
assert "18C and sunny" not in sent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_apply_guardrail_request_skips_output_scan_without_response_text():
|
||||
"""A tool-calls-only assistant response yields no OUTPUT content, so it must not be posted."""
|
||||
guardrail = BedrockGuardrail(guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT")
|
||||
response = ModelResponse(
|
||||
choices=[
|
||||
litellm.Choices(
|
||||
index=0,
|
||||
message=litellm.Message(role="assistant", content=None, tool_calls=[]),
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post:
|
||||
bedrock_response = await guardrail.make_bedrock_api_request(source="OUTPUT", response=response)
|
||||
|
||||
mock_post.assert_not_called()
|
||||
assert bedrock_response == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_apply_guardrail_request_skips_scan_without_credentials():
|
||||
"""Skipping happens before credential resolution, so an empty scan costs no AWS work."""
|
||||
guardrail = BedrockGuardrail(guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT")
|
||||
|
||||
with (
|
||||
patch.object(guardrail, "_load_credentials", side_effect=AssertionError("credentials must not be loaded")),
|
||||
patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post,
|
||||
):
|
||||
await guardrail.make_bedrock_api_request(
|
||||
source="INPUT",
|
||||
messages=[{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "out"}]}],
|
||||
)
|
||||
|
||||
mock_post.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bedrock_apply_guardrail_response_uses_OUTPUT_source():
|
||||
"""input_type='response' must call Bedrock with source=OUTPUT and assistant content.
|
||||
|
|
|
|||
|
|
@ -912,7 +912,7 @@ async def test_bedrock_guardrail_make_api_request_passes_api_key():
|
|||
):
|
||||
|
||||
mock_load_creds.return_value = (Mock(), "us-east-1")
|
||||
mock_convert.return_value = {"source": "INPUT", "content": []}
|
||||
mock_convert.return_value = {"source": "INPUT", "content": [{"text": {"text": "test"}}]}
|
||||
mock_get_params.return_value = {}
|
||||
|
||||
mock_request_instance = Mock()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue