fix(guardrails): forward on_unscannable_image through initialize_bedrock

initialize_bedrock enumerates its kwargs explicitly, so the new setting parsed
and rendered but never reached the guardrail. An operator who opted into "allow"
kept getting 400s on unscannable images with nothing to explain why. Same shape
as the chunk_budget_chars regression, so the test follows that one and asserts
through initialize_guardrail rather than the constructor.

Also trims the docstrings added by this branch down to the rationale that is not
already obvious from the code, and drops a stale line describing a return
convention these helpers no longer use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
samtsai15 2026-08-25 15:27:51 +08:00
parent 4c98cf95b5
commit a19dd2d92e
3 changed files with 47 additions and 30 deletions

View file

@ -338,13 +338,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
return cleaned or None
async def _create_bedrock_input_content_request(self, messages: list[AllMessageValues] | None) -> BedrockRequest:
"""
Create a bedrock request for the input content - the LLM request.
Text and image parts are both sent, so a guardrail with the IMAGE modality
enabled inspects the image the caller actually sent instead of only the text
that happened to sit next to it.
"""
"""Create a bedrock request for the input content - the LLM request."""
bedrock_request: Final[BedrockRequest] = BedrockRequest(source="INPUT")
if messages is None:
return bedrock_request
@ -357,10 +351,8 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
async def _build_input_content_items(self, message: AllMessageValues) -> tuple[BedrockContentItem, ...]:
"""Flatten one request message into ApplyGuardrail INPUT content items.
INPUT scans send text and image parts. Grounding qualifiers are attached
exclusively when assembling the OUTPUT request, so a caller cannot use a
grounding_source/query tag to change how input-safety policies treat their
content (which would be an input-guardrail bypass).
Grounding qualifiers are attached only when assembling the OUTPUT request, so a
grounding_source/query tag cannot change how input-safety policies treat content
"""
content = message.get("content")
if content is None:
@ -381,11 +373,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
if not isinstance(item, dict):
return None
part: Final = cast(Mapping[str, object], item) # cast-ok: narrowed to dict on the line above
# Classify by the declared type before reading any field. Provider
# transformations branch on `type`, so a part tagged image_url reaches the
# model as an image even when it also carries a `text` key. Reading `text`
# first would scan that decoy and forward the image unscanned, which is the
# bypass this whole extractor exists to close.
# Provider transformations branch on `type`, so an image_url part reaches the
# model as an image even when it also carries `text`. Reading `text` first would
# scan that decoy and forward the image unscanned
if part.get("type") == "image_url":
image_url: Final = self._get_image_url(item=part)
if image_url is None:
@ -409,15 +399,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
return None
def _handle_unscannable_image(self, reason: str) -> None:
"""Decide what to do with an image part ApplyGuardrail cannot scan.
"""Block or warn for an image part ApplyGuardrail cannot scan.
The image still reaches the model either way, so skipping it silently would
let a caller defeat an IMAGE-modality guardrail just by picking a format the
API does not accept. `on_unscannable_image` defaults to "block" for that
reason; "allow" restores the permissive behavior for deployments that would
rather serve the request than fail it.
Returns None so callers can `return self._handle_unscannable_image(...)`.
The image reaches the model either way, so skipping it silently would let a
caller defeat an IMAGE-modality guardrail by picking a format the API rejects
"""
if self.on_unscannable_image == "block":
raise HTTPException(
@ -440,13 +425,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
)
async def _build_image_content_item(self, image_url: str) -> BedrockContentItem | None:
"""Decode/fetch an image part into an ApplyGuardrail image block.
"""Decode or fetch an image part into an ApplyGuardrail image block.
Remote URLs are only fetched while LiteLLM's URL validation is on. With
validation disabled `async_safe_get` degrades to an unrestricted, redirect
following GET, and the URL comes straight from the caller, so fetching here
would turn the guardrail into an SSRF primitive. Such an image is treated as
unscannable instead.
With `user_url_validation` off, `async_safe_get` degrades to an unrestricted,
redirect-following GET on a caller-supplied URL, so it is not fetched at all
"""
if not image_url.startswith("data:") and not getattr(litellm, "user_url_validation", True):
self._handle_unscannable_image(

View file

@ -21,6 +21,7 @@ def initialize_bedrock(litellm_params: LitellmParams, guardrail: Guardrail):
prompt_attack_threshold=litellm_params.prompt_attack_threshold,
pii_confidence_threshold=litellm_params.pii_confidence_threshold,
chunk_budget_chars=litellm_params.chunk_budget_chars,
on_unscannable_image=litellm_params.on_unscannable_image,
default_on=litellm_params.default_on,
disable_exception_on_block=litellm_params.disable_exception_on_block,
mask_request_content=litellm_params.mask_request_content,

View file

@ -118,3 +118,37 @@ def test_initialize_guardrail_sets_run_in_parallel(config_value, expected):
custom_guardrail = guardrail_handler.guardrail_id_to_custom_guardrail[result["guardrail_id"]]
assert custom_guardrail.run_in_parallel is expected
def test_initialize_bedrock_forwards_on_unscannable_image():
"""Regression: `on_unscannable_image` set in config.yaml must reach the guardrail.
Same shape as chunk_budget_chars above: the field lives on
BedrockGuardrailConfigModel so LitellmParams parses it, but initialize_bedrock
enumerates its kwargs explicitly. Dropped here, an operator who opted into
`allow` would keep getting 400s on unscannable images with no indication why.
"""
import litellm
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail
test_guardrail = {
"guardrail_name": "test_bedrock_unscannable_image",
"litellm_params": {
"guardrail": SupportedGuardrailIntegrations.BEDROCK.value,
"mode": "pre_call",
"guardrailIdentifier": "test-guardrail",
"guardrailVersion": "DRAFT",
"on_unscannable_image": "allow",
},
}
guardrail_handler = InMemoryGuardrailHandler()
guardrail_handler.initialize_guardrail(guardrail=test_guardrail)
initialized = [
callback
for callback in litellm.callbacks
if isinstance(callback, BedrockGuardrail) and callback.guardrail_name == "test_bedrock_unscannable_image"
]
assert initialized, "bedrock guardrail was not registered as a callback"
assert initialized[-1].on_unscannable_image == "allow"