fix(bedrock_guardrails): skip ApplyGuardrail when there is no content to scan (#36441)

This commit is contained in:
yucheng-berri 2026-08-11 17:30:54 -07:00 committed by GitHub
parent bea31871fc
commit 417c70589f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 139 additions and 2 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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()