From c1e3d07e766ad01805edc673ea3e858ef38d5558 Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Sat, 10 Oct 2026 16:48:37 -0700 Subject: [PATCH] fix(guardrails): scan or refuse attachments in the bedrock guardrail (#43383) * fix(guardrails): scan or refuse attachments in the bedrock guardrail * fix(guardrails): cover native pre_call and computer screenshots in the bedrock attachment scan * fix(guardrails): classify every attachment shape the bedrock guardrail can receive * test(guardrails): type the attachment-scanning test guardrail * fix(guardrails): keep base behavior for Bedrock subclasses and malformed messages - skip the attachment scan when a subclass overrides apply_guardrail - ignore non-string message role/type instead of raising - treat a null Converse document/video/audio block as absent - keep the base request-body debug log for text-only calls * fix(guardrails): let text documents, unpadded base64 images and request-override subclasses through as before * fix(guardrails): let Converse text documents through and refuse documents that carry images * fix(guardrails): scan images inside content documents instead of refusing them * fix(guardrails): scan text document content with the Bedrock guardrail * fix(guardrails): scan document title, context and nested documents * Expand nested content documents without recursion * Keep attachment scan within the type-discipline budget * Take the image format and pixel size from the image bytes * chore(guardrails): satisfy type-discipline gate after merging main * chore(guardrails): drop docstrings added by the attachment scan * fix(guardrails): scan Converse text documents and guardContent images A Converse document whose source is text (or a list of text parts) now goes through the Bedrock text scan instead of being refused as unscannable, so the pass-through request that worked before this PR keeps working. A Converse guardContent image is now scanned like any other image. Byte and S3 document sources stay unscannable. Adds the integration test that drives every scanned endpoint through a real proxy against a scripted Bedrock ApplyGuardrail stub, and retypes the touched hook signatures for the type gates. * test(guardrails): cover Bedrock checks mode in the attachment integration test Adds a checks-mode proxy to the rig: images are refused without calling Bedrock on every route, and a Converse text document is sent to InvokeGuardrailChecks. * fix(guardrails): scan a Converse document's name and context whatever its source A Converse PDF or S3 document let through by skip_unscannable_attachments now still has its name and context scanned as text, the same as the Anthropic document path. --------- Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com> --- litellm/llms/bedrock/guardrail_attachments.py | 406 +++++++++++ litellm/proxy/_lazy_openapi_snapshot.json | 4 +- .../guardrail_hooks/bedrock_guardrails.py | 180 ++++- .../unified_guardrail/unified_guardrail.py | 20 +- .../guardrails/guardrail_initializers.py | 1 + litellm/types/guardrails.py | 8 +- .../guardrail_hooks/bedrock_guardrails.py | 12 +- .../test_bedrock_guardrail_attachments.py | 525 ++++++++++++++ .../bedrock/test_guardrail_attachments.py | 651 ++++++++++++++++++ .../test_bedrock_guardrails.py | 443 +++++++++++- .../test_unified_guardrail.py | 42 ++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 +- 12 files changed, 2279 insertions(+), 17 deletions(-) create mode 100644 litellm/llms/bedrock/guardrail_attachments.py create mode 100644 tests/integration/observability/test_bedrock_guardrail_attachments.py create mode 100644 tests/unit/llms/bedrock/test_guardrail_attachments.py diff --git a/litellm/llms/bedrock/guardrail_attachments.py b/litellm/llms/bedrock/guardrail_attachments.py new file mode 100644 index 00000000000..cad26206fa3 --- /dev/null +++ b/litellm/llms/bedrock/guardrail_attachments.py @@ -0,0 +1,406 @@ +import base64 +import binascii +from collections.abc import Callable, Iterable, Mapping, Sequence +from functools import reduce +from itertools import chain +from types import MappingProxyType +from typing import Final, Literal, NamedTuple, TypeAlias, TypeGuard # noqa: TID251 # narrows request JSON to str keys + +from litellm.types.proxy.guardrails.guardrail_hooks.bedrock_guardrails import ( + BedrockContentItem, + BedrockImageContent, + BedrockImageSource, +) +from litellm.types.utils import CallTypes + +BedrockImageFormat: TypeAlias = Literal["png", "jpeg"] + + +class RequestAttachments(NamedTuple): + images: tuple[BedrockContentItem, ...] + unscannable: tuple[str, ...] + document_texts: tuple[str, ...] = () + + +class _Image(NamedTuple): + item: BedrockContentItem + + +class _Unscannable(NamedTuple): + label: str + + +class _DocumentText(NamedTuple): + text: str + + +class _Block(NamedTuple): + block: Mapping[str, object] + from_tool: bool + document_depth: int = 0 + + +_Classified: TypeAlias = _Image | _Unscannable | _DocumentText | None +_BlockClassifier: TypeAlias = Callable[[Mapping[str, object]], _Classified] +_NestedToolBlocks: TypeAlias = Callable[ + [Mapping[str, object]], + tuple[Mapping[str, object], ...], +] + +NO_ATTACHMENTS: Final = RequestAttachments(images=(), unscannable=()) + +_IMAGE_FORMAT_BY_MIME: Final[Mapping[str, BedrockImageFormat]] = MappingProxyType( + {"image/png": "png", "image/jpeg": "jpeg", "image/jpg": "jpeg"} +) +_CHAT_CALL_TYPES: Final = frozenset({CallTypes.completion.value, CallTypes.acompletion.value}) +_RESPONSES_CALL_TYPES: Final = frozenset({CallTypes.responses.value, CallTypes.aresponses.value}) +_OPENAI_IMAGE_TYPES: Final = frozenset({"image_url", "input_image", "computer_screenshot"}) +_OPENAI_UNSCANNABLE_TYPES: Final = frozenset( + {"file", "input_file", "input_audio", "video_url", "audio_url", "document", "container_upload"} +) +_ANTHROPIC_UNSCANNABLE_TYPES: Final = frozenset({"document", "container_upload"}) +_TEXT_DOCUMENT_SOURCE_TYPES: Final = frozenset({"text", "content"}) +_MAX_DOCUMENT_DEPTH: Final = 3 +_CONVERSE_UNSCANNABLE_KEYS: Final = ("document", "video", "audio") +_CONVERSE_BINARY_SOURCE_KEYS: Final = ("bytes", "s3Location") +_MAX_IMAGE_BYTES: Final = 4 * 1024 * 1024 +_MAX_IMAGE_BASE64_CHARS: Final = -(-_MAX_IMAGE_BYTES // 3) * 4 +_URL_SAFE_TO_STANDARD_BASE64: Final = str.maketrans("-_", "+/") +_CONVERSE_ACTIONS: Final = frozenset({"converse", "converse-stream"}) +_TOOL_ROLES: Final = frozenset({"tool", "function"}) +_TOOL_OUTPUT_ITEM_TYPES: Final = frozenset({"function_call_output", "custom_tool_call_output", "computer_call_output"}) +_MAX_LABEL_MIME_CHARS: Final = 40 +_MAX_IMAGE_SIDE_PIXELS: Final = 8000 +_PNG_SIGNATURE: Final = b"\x89PNG\r\n\x1a\n" +_JPEG_SIGNATURE: Final = b"\xff\xd8\xff" +_JPEG_STANDALONE_MARKERS: Final = frozenset({0x01, *range(0xD0, 0xD9)}) +_JPEG_FRAME_MARKERS: Final = frozenset(range(0xC0, 0xD0)) - {0xC4, 0xC8, 0xCC} + + +def find_request_attachments( + data: Mapping[str, object], + call_type: str, + skip_tool_messages: bool, + latest_user_message_only: bool, + scan_only_tool_results: bool = False, +) -> RequestAttachments: + messages, classify, nested_tool_blocks = _messages_and_classifier(data, call_type) + selected: Final = messages if not latest_user_message_only else _latest_user_message(messages) + entries: Final = chain.from_iterable(_message_blocks(message, nested_tool_blocks) for message in selected) + classified: Final = tuple( + chain.from_iterable( + _classify_entry(entry, classify) + for entry in entries + if _in_scope(entry, skip_tool_messages, scan_only_tool_results) + ) + ) + return RequestAttachments( + images=tuple(result.item for result in classified if isinstance(result, _Image)), + unscannable=tuple(result.label for result in classified if isinstance(result, _Unscannable)), + document_texts=tuple(result.text for result in classified if isinstance(result, _DocumentText)), + ) + + +def _classify_entry(entry: _Block, classify: _BlockClassifier) -> tuple[_Image | _Unscannable | _DocumentText, ...]: + if entry.document_depth >= _MAX_DOCUMENT_DEPTH and _content_document_source(entry.block) is not None: + return (_Unscannable("document (nested too deep)"),) + text: Final = _document_text(entry) + result: Final = classify(entry.block) + return tuple(item for item in (_DocumentText(text) if text else None, result) if item is not None) + + +def _document_text(entry: _Block) -> str: + block: Final = entry.block + if entry.document_depth > 0 and block.get("type") == "text": + text: Final = block.get("text") + return text if isinstance(text, str) else "" + converse_document: Final = block.get("document") if block.get("type") is None else None + if _is_mapping(converse_document): + return _converse_document_text(converse_document) + if block.get("type") != "document": + return "" + source: Final = block.get("source") + source_type: Final = source.get("type") if _is_mapping(source) else None + source_text: Final = ( + (source.get("data") if source_type == "text" else source.get("content")) + if _is_mapping(source) and isinstance(source_type, str) and source_type in _TEXT_DOCUMENT_SOURCE_TYPES + else None + ) + return "\n".join( + part for part in (block.get("title"), block.get("context"), source_text) if isinstance(part, str) and part + ) + + +def _messages_and_classifier( + data: Mapping[str, object], call_type: str +) -> tuple[Sequence[Mapping[str, object]], _BlockClassifier, _NestedToolBlocks]: + if call_type in _CHAT_CALL_TYPES: + return _mappings(data.get("messages")), _classify_openai_block, _no_nested_blocks + if call_type == CallTypes.anthropic_messages.value: + return _mappings(data.get("messages")), _classify_anthropic_block, _anthropic_tool_result_blocks + if call_type in _RESPONSES_CALL_TYPES: + return _mappings(data.get("input")), _classify_openai_block, _no_nested_blocks + if call_type == CallTypes.allm_passthrough_route.value and _is_bedrock_converse(data): + body: Final = data.get("data") + messages: Final = _mappings(body.get("messages") if _is_mapping(body) else None) + return messages, _classify_converse_block, _converse_tool_result_blocks + return (), _classify_nothing, _no_nested_blocks + + +def _is_bedrock_converse(data: Mapping[str, object]) -> bool: + endpoint: Final = data.get("endpoint") + return ( + data.get("custom_llm_provider") == "bedrock" + and isinstance(endpoint, str) + and endpoint.rstrip("/").rsplit("/", 1)[-1] in _CONVERSE_ACTIONS + ) + + +def _is_mapping(value: object) -> TypeGuard[Mapping[str, object]]: # guard-ok: isinstance narrows correctly; predicate is trivially correct # fmt: skip + return isinstance(value, Mapping) + + +def _is_list(value: object) -> TypeGuard[Sequence[object]]: # guard-ok: isinstance narrows correctly; predicate is trivially correct # fmt: skip + return isinstance(value, list) + + +def _mappings(value: object) -> tuple[Mapping[str, object], ...]: + if not _is_list(value): + return () + return tuple(item for item in value if _is_mapping(item)) + + +def _latest_user_message(messages: Sequence[Mapping[str, object]]) -> tuple[Mapping[str, object], ...]: + return tuple(message for message in messages if message.get("role") == "user")[-1:] + + +def _message_blocks(message: Mapping[str, object], nested_tool_blocks: _NestedToolBlocks) -> tuple[_Block, ...]: + message_type: Final = message.get("type") + if isinstance(message_type, str) and message_type in _TOOL_OUTPUT_ITEM_TYPES: + output: Final = message.get("output") + blocks: Final = (output,) if _is_mapping(output) else _mappings(output) + return _with_document_contents(_Block(block, from_tool=True) for block in blocks) + role: Final = message.get("role") + from_tool_message: Final = isinstance(role, str) and role in _TOOL_ROLES + return _with_document_contents( + chain.from_iterable( + ( + _Block(block, from_tool=from_tool_message), + *(_Block(inner, from_tool=True) for inner in nested_tool_blocks(block)), + ) + for block in _mappings(message.get("content")) + ) + ) + + +def _with_document_contents(entries: Iterable[_Block]) -> tuple[_Block, ...]: + return reduce(_with_document_level_expanded, range(_MAX_DOCUMENT_DEPTH), tuple(entries)) + + +def _with_document_level_expanded(entries: tuple[_Block, ...], depth: int) -> tuple[_Block, ...]: + return tuple( + chain.from_iterable( + _with_document_children(entry) if entry.document_depth == depth else (entry,) for entry in entries + ) + ) + + +def _with_document_children(entry: _Block) -> tuple[_Block, ...]: + source: Final = _content_document_source(entry.block) + if source is None: + return (entry,) + return ( + entry, + *( + _Block(inner, from_tool=entry.from_tool, document_depth=entry.document_depth + 1) + for inner in _mappings(source.get("content")) + ), + ) + + +def _content_document_source(block: Mapping[str, object]) -> Mapping[str, object] | None: + source: Final = block.get("source") + if block.get("type") != "document" or not _is_mapping(source) or source.get("type") != "content": + return None + return source + + +def _in_scope(entry: _Block, skip_tool_messages: bool, scan_only_tool_results: bool) -> bool: + return not skip_tool_messages if entry.from_tool else not scan_only_tool_results + + +def _no_nested_blocks(block: Mapping[str, object]) -> tuple[Mapping[str, object], ...]: + return () + + +def _anthropic_tool_result_blocks(block: Mapping[str, object]) -> tuple[Mapping[str, object], ...]: + return _mappings(block.get("content")) if block.get("type") == "tool_result" else () + + +def _converse_tool_result_blocks(block: Mapping[str, object]) -> tuple[Mapping[str, object], ...]: + tool_result: Final = block.get("toolResult") + return _mappings(tool_result.get("content")) if _is_mapping(tool_result) else () + + +def _classify_nothing(block: Mapping[str, object]) -> _Classified: + return None + + +def _classify_openai_block(block: Mapping[str, object]) -> _Classified: + block_type: Final = block.get("type") + if block_type in ("image", "document"): + return _classify_anthropic_block(block) + if isinstance(block_type, str) and block_type in _OPENAI_IMAGE_TYPES: + image_url: Final = block.get("image_url") + url: Final = image_url.get("url") if _is_mapping(image_url) else image_url + return _classify_data_uri(url, block_type) + if isinstance(block_type, str) and block_type in _OPENAI_UNSCANNABLE_TYPES: + return _Unscannable(block_type) + return None + + +def _classify_anthropic_block(block: Mapping[str, object]) -> _Classified: + block_type: Final = block.get("type") + if block_type == "image": + source: Final = block.get("source") + if _is_mapping(source) and source.get("type") == "base64": + return _classify_base64(source.get("media_type"), source.get("data"), "image") + return _Unscannable("image (url or file source)") + if block_type == "document" and _is_text_document(block): + return None + if isinstance(block_type, str) and block_type in _ANTHROPIC_UNSCANNABLE_TYPES: + return _Unscannable(block_type) + return None + + +def _is_text_document(block: Mapping[str, object]) -> bool: + source: Final = block.get("source") + source_type: Final = source.get("type") if _is_mapping(source) else None + return isinstance(source_type, str) and source_type in _TEXT_DOCUMENT_SOURCE_TYPES + + +def _classify_converse_block(block: Mapping[str, object]) -> _Classified: + guard_content: Final = block.get("guardContent") + image: Final = guard_content.get("image") if _is_mapping(guard_content) else block.get("image") + if _is_mapping(image): + image_format: Final = image.get("format") + source: Final = image.get("source") + encoded: Final = source.get("bytes") if _is_mapping(source) else None + if encoded is None: + return _Unscannable("image (no inline bytes)") + mime: Final = f"image/{image_format}" if isinstance(image_format, str) else None + return _classify_base64(mime, encoded, "image") + document: Final = block.get("document") + if _is_mapping(document) and _converse_text_parts(document) is not None: + return None + for key in _CONVERSE_UNSCANNABLE_KEYS: + if block.get(key) is not None: + return _Unscannable(key) + return None + + +def _converse_document_text(document: Mapping[str, object]) -> str: + parts: Final = _converse_text_parts(document) or () + return "\n".join( + part for part in (document.get("name"), document.get("context"), *parts) if isinstance(part, str) and part + ) + + +def _converse_text_parts(document: Mapping[str, object]) -> tuple[str, ...] | None: + source: Final = document.get("source") + if not _is_mapping(source) or any(source.get(key) is not None for key in _CONVERSE_BINARY_SOURCE_KEYS): + return None + return _converse_document_parts(source) + + +def _converse_document_parts(source: Mapping[str, object]) -> tuple[str, ...] | None: + text: Final = source.get("text") + if isinstance(text, str): + return (text,) + content: Final = source.get("content") + if not _is_list(content): + return None + parts: Final = tuple(item.get("text") if _is_mapping(item) else None for item in content) + return ( + tuple(part for part in parts if isinstance(part, str)) if all(isinstance(part, str) for part in parts) else None + ) + + +def _classify_data_uri(url: object, label: str) -> _Classified: + if not isinstance(url, str) or not url.startswith("data:"): + return _Unscannable(f"{label} (remote URL or file id)") + header, _, payload = url.partition(",") + params: Final = header.removeprefix("data:").lower().split(";") + if "base64" not in params[1:]: + return _Unscannable(f"{label} (not base64)") + return _classify_base64(params[0], payload, label) + + +def _classify_base64(mime: object, encoded: object, label: str) -> _Classified: + image_format: Final = _IMAGE_FORMAT_BY_MIME.get(mime.lower()) if isinstance(mime, str) else None + if image_format is None: + return _Unscannable(f"{label} ({mime[:_MAX_LABEL_MIME_CHARS]})" if isinstance(mime, str) and mime else label) + standard: Final = _standard_base64(encoded) + if len(standard) > _MAX_IMAGE_BASE64_CHARS: + return _Unscannable(f"{label} (over 4 MB)") + decoded: Final = _decoded(standard) + if decoded is None: + return _Unscannable(f"{label} (invalid base64)") + if len(decoded) > _MAX_IMAGE_BYTES: + return _Unscannable(f"{label} (over 4 MB)") + actual_format: Final = _sniffed_format(decoded) + if actual_format is None: + return _Unscannable(f"{label} (not PNG or JPEG data)") + if max(_pixel_size(decoded, actual_format), default=0) > _MAX_IMAGE_SIDE_PIXELS: + return _Unscannable(f"{label} (over {_MAX_IMAGE_SIDE_PIXELS} pixels)") + return _Image( + BedrockContentItem(image=BedrockImageContent(format=actual_format, source=BedrockImageSource(bytes=standard))) + ) + + +def _sniffed_format(data: bytes) -> BedrockImageFormat | None: + if data.startswith(_PNG_SIGNATURE): + return "png" + if data.startswith(_JPEG_SIGNATURE): + return "jpeg" + return None + + +def _pixel_size(data: bytes, image_format: BedrockImageFormat) -> tuple[int, ...]: + if image_format == "png": + return ( + (int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")) if data[12:16] == b"IHDR" else () + ) + offset = 2 # rebind-ok: cursor that walks the JPEG segments + while offset + 4 <= len(data) and data[offset] == 0xFF: + marker = data[offset + 1] + if marker == 0xFF or marker in _JPEG_STANDALONE_MARKERS: + offset += 1 if marker == 0xFF else 2 + continue + if marker in _JPEG_FRAME_MARKERS: + return ( + ( + int.from_bytes(data[offset + 7 : offset + 9], "big"), + int.from_bytes(data[offset + 5 : offset + 7], "big"), + ) + if offset + 9 <= len(data) + else () + ) + offset += 2 + int.from_bytes(data[offset + 2 : offset + 4], "big") + return () + + +def _standard_base64(encoded: object) -> str: + compact: Final = ( + "".join(encoded.split()).translate(_URL_SAFE_TO_STANDARD_BASE64) if isinstance(encoded, str) else "" + ) + return compact + "=" * (-len(compact) % 4) + + +def _decoded(standard: str) -> bytes | None: + if not standard: + return None + try: + return base64.b64decode(standard, validate=True) + except (binascii.Error, ValueError): + return None diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 8bf0881c129..b04a958cd03 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -13714,7 +13714,7 @@ } ], "default": false, - "description": "Implemented by guardrail='model_armor'. When True, attachment references that carry no inline bytes (file_id, gs://, or http(s) URLs) pass through unscanned instead of blocking, while fail_on_error still governs real Model Armor API errors. Default False blocks them.", + "description": "Implemented by guardrail='model_armor' and guardrail='bedrock'. When True, attachments the guardrail cannot scan pass through unscanned instead of blocking. For Model Armor these are references with no inline bytes (file_id, gs://, or http(s) URLs), and fail_on_error still governs real Model Armor API errors. For Bedrock these are documents, files, audio, video, and images that are not inline PNG or JPEG up to 4 MB. Default False blocks them.", "title": "Skip Unscannable Attachments" }, "sticky_session_routing": { @@ -16767,7 +16767,7 @@ } ], "default": false, - "description": "Implemented by guardrail='model_armor'. When True, attachment references that carry no inline bytes (file_id, gs://, or http(s) URLs) pass through unscanned instead of blocking, while fail_on_error still governs real Model Armor API errors. Default False blocks them.", + "description": "Implemented by guardrail='model_armor' and guardrail='bedrock'. When True, attachments the guardrail cannot scan pass through unscanned instead of blocking. For Model Armor these are references with no inline bytes (file_id, gs://, or http(s) URLs), and fail_on_error still governs real Model Armor API errors. For Bedrock these are documents, files, audio, video, and images that are not inline PNG or JPEG up to 4 MB. Default False blocks them.", "title": "Skip Unscannable Attachments" }, "sticky_session_routing": { diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 37318feba45..34f5fdc7c5f 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -44,8 +44,10 @@ from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import ( from litellm.llms.anthropic.chat.guardrail_translation.handler import AnthropicMessagesHandler from litellm.llms.base_llm.guardrail_translation.utils import ( effective_scan_only_tool_results_for_guardrail, + effective_skip_tool_message_for_guardrail, ) from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, bedrock_bearer_token, run_aws_signing +from litellm.llms.bedrock.guardrail_attachments import find_request_attachments from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, @@ -76,6 +78,8 @@ from litellm.types.proxy.guardrails.guardrail_hooks.bedrock_guardrails import ( BedrockGuardrailQualifier, BedrockGuardrailResponse, BedrockGuardrailUsage, + BedrockImageContent, + BedrockImageSource, BedrockRequest, BedrockTextContent, ) @@ -120,6 +124,7 @@ _BEDROCK_INVOKE_GUARDRAIL_CHECKS_PATH: Final = "/guardrail-checks/invoke" # more text blocks is split across multiple messages so ALL content is scanned -- # never truncated (truncation would let a user hide content past the limit). _BEDROCK_CHECKS_MAX_CONTENT_BLOCKS: Final = 10 +_BEDROCK_APPLY_GUARDRAIL_MAX_IMAGES: Final = 20 _BEDROCK_CHECKS_KNOWN_KEYS: Final = frozenset({"contentFilter", "promptAttack", "sensitiveInformation"}) # Keys in a sensitiveInformation result that pinpoint the PII location. They are # stripped before the response is handed to standard logging / telemetry so the @@ -223,6 +228,28 @@ def _redact_assessment_match_fields(assessments: list[dict]) -> list[dict]: _RESPONSES_API_CALL_TYPES: Final = frozenset({CallTypes.responses, CallTypes.aresponses}) +def _without_image_bytes(content: Sequence[BedrockContentItem]) -> tuple[BedrockContentItem, ...]: + return tuple( + BedrockContentItem( + image=BedrockImageContent( + format=item["image"]["format"], + source=BedrockImageSource(bytes=f"<{len(item['image']['source']['bytes'])} base64 chars>"), + ) + ) + if "image" in item + else item + for item in content + ) + + +def _allowed_dynamic_body_params(params: Mapping[str, object]) -> Mapping[str, object]: + return {key: value for key, value in params.items() if key not in _BEDROCK_DYNAMIC_BODY_DENYLIST} + + +def _overridden(guardrail: object, name: str) -> bool: + return getattr(getattr(guardrail, name, None), "__func__", None) is not getattr(BedrockGuardrail, name) + + def _is_responses_api_route(request_route: str | None) -> bool: if request_route is None: return False @@ -271,6 +298,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): self.chunk_budget_chars = chunk_budget_chars self.contextual_grounding_from_messages = contextual_grounding_from_messages self.experimental_use_latest_role_message_only = bool(kwargs.get("experimental_use_latest_role_message_only")) + self.skip_unscannable_attachments: Final = kwargs.get("skip_unscannable_attachments") is True # Resource-less, detect-only InvokeGuardrailChecks mode. Present `checks` # routes the guardrail to InvokeGuardrailChecks; absent => ApplyGuardrail. @@ -957,10 +985,29 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): source, ) return BedrockGuardrailResponse() + return await self._apply_guardrail_to_content( + content=content, + bedrock_request_data=bedrock_request_data, + api_key=api_key, + request_data=request_data, + event_type=event_type, + start_time=start_time, + allow_chunking=not self._content_uses_contextual_grounding(content), + ) + + async def _apply_guardrail_to_content( + self, + content: Sequence[BedrockContentItem], + bedrock_request_data: Mapping[str, object], + api_key: str | None, + request_data: dict[str, object] | None, # mutable-ok: proxy request body dict, mutated by the logging helper + event_type: GuardrailEventHooks, + start_time: "datetime", + allow_chunking: bool, + ) -> BedrockGuardrailResponse: credentials, aws_region_name = await run_aws_signing( self._load_credentials, bearer_token=bedrock_bearer_token(api_key) ) - allow_chunking: Final = not self._content_uses_contextual_grounding(content) completed_chunk_usages: Final[list[BedrockGuardrailUsage]] = [] # mutable-ok: billed-chunk usage accumulator try: @@ -1004,7 +1051,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): credentials: "Credentials | None", aws_region_name: str, api_key: str | None, - request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper + request_data: dict[str, object] | None, # mutable-ok: proxy request body dict, mutated by the logging helper event_type: GuardrailEventHooks, start_time: "datetime", allow_chunking: bool, @@ -1231,7 +1278,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): headers_dict: Final = dict(prepared_request.headers) verbose_proxy_logger.debug( "Bedrock AI request body: %s, url %s, headers: %s", - bedrock_request_data, + {**bedrock_request_data, "content": _without_image_bytes(content)} + if any("image" in item for item in content) + else bedrock_request_data, prepared_request.url, get_masked_values(headers_dict), ) @@ -1322,7 +1371,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): def _log_apply_guardrail_success( self, merged_response: BedrockGuardrailResponse, - request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper + request_data: dict[str, object] | None, # mutable-ok: proxy request body dict, mutated by the logging helper event_type: GuardrailEventHooks, start_time: "datetime", aws_region_name: str | None, @@ -1356,7 +1405,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): def _log_apply_guardrail_failure( self, detail: object, - request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper + request_data: dict[str, object] | None, # mutable-ok: proxy request body dict, mutated by the logging helper event_type: GuardrailEventHooks, start_time: "datetime", aws_region_name: str | None, @@ -2486,11 +2535,125 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): # This means all actions were ANONYMIZED or NONE, so don't raise exception return False + async def async_scan_request_attachments( + self, + data: dict[str, object], # mutable-ok: proxy request body dict, mutated by the logging helper + call_type: CallTypesLiteral, + event_type: GuardrailEventHooks = GuardrailEventHooks.pre_call, + ) -> None: + if _overridden(self, "apply_guardrail") or _overridden(self, "make_bedrock_api_request"): + return + attachments: Final = find_request_attachments( + data, + call_type, + skip_tool_messages=effective_skip_tool_message_for_guardrail(self), + latest_user_message_only=self.experimental_use_latest_role_message_only, + scan_only_tool_results=effective_scan_only_tool_results_for_guardrail(self), + ) + images: Final = attachments.images if self.checks is None else () + unscannable: Final = attachments.unscannable + tuple("image" for _ in attachments.images if self.checks) + if unscannable: + if not self.skip_unscannable_attachments: + raise self._unscannable_attachments_exception(unscannable, request_data=data, event_type=event_type) + verbose_proxy_logger.warning( + "Bedrock Guardrail %s: letting %d unscannable attachment(s) through unscanned because " + "skip_unscannable_attachments is enabled", + self.guardrail_name, + len(unscannable), + ) + if attachments.document_texts: + await self._scan_document_texts(attachments.document_texts, request_data=data, event_type=event_type) + if not images: + return + bedrock_request_data: Final = { + "source": "INPUT", + **_allowed_dynamic_body_params( + self.get_guardrail_dynamic_request_body_params(request_data=data) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] # the base helper returns an untyped dict + ), + } + api_key: Final = data.get("api_key") + try: + for start in range(0, len(images), _BEDROCK_APPLY_GUARDRAIL_MAX_IMAGES): + await self._apply_guardrail_to_content( + content=images[start : start + _BEDROCK_APPLY_GUARDRAIL_MAX_IMAGES], + bedrock_request_data=bedrock_request_data, + api_key=api_key if isinstance(api_key, str) else None, + request_data=data, + event_type=event_type, + start_time=datetime.now(timezone.utc), + allow_chunking=False, + ) + except (HTTPException, ModifyResponseException): + raise + except Exception as e: + if event_type != GuardrailEventHooks.pre_call: + raise + verbose_proxy_logger.error("Bedrock Guardrail: Failed to scan attachments: %s", str(e)) + raise Exception(f"Bedrock guardrail failed: {e}") from e # noqa: TRY002 # same error the text scan raises + + async def _scan_document_texts( + self, + document_texts: Sequence[str], + request_data: dict[str, object], # mutable-ok: proxy request body dict, mutated by the logging helper + event_type: GuardrailEventHooks, + ) -> None: + response: Final = await self.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": text} for text in document_texts], + request_data=request_data, + logging_event_type=event_type, + ) + if response.get("action") == "GUARDRAIL_INTERVENED": + raise self._unscannable_attachments_exception( + tuple("document (text the guardrail would mask)" for _ in document_texts), + request_data=request_data, + event_type=event_type, + ) + + def _unscannable_attachments_exception( + self, + unscannable: Sequence[str], + request_data: dict[str, object], # mutable-ok: proxy request body dict, mutated by the logging helper + event_type: GuardrailEventHooks, + ) -> HTTPException | ModifyResponseException: + reason: Final = ( + f"Bedrock guardrail cannot scan {len(unscannable)} attachment(s) " + f"({', '.join(sorted(frozenset(unscannable)))}), so the request was blocked" + ) + now: Final = datetime.now(timezone.utc).timestamp() + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_provider=self.guardrail_provider, + guardrail_json_response={"unscannable_attachments": list(unscannable)}, + request_data=request_data, + guardrail_status="guardrail_intervened", + start_time=now, + end_time=now, + duration=0.0, + event_type=event_type, + ) + if self.disable_exception_on_block is True: + model: Final = request_data.get("model", "bedrock-guardrail") + return ModifyResponseException( + message=reason, + model=model if isinstance(model, str) else "bedrock-guardrail", + request_data=request_data, + guardrail_name=self.guardrail_name, + ) + detail: Final[dict[str, object]] = { # mutable-ok: HTTPException detail + "error": "Violated guardrail policy", + "reason": reason, + } + if self.guardrailIdentifier: + detail["guardrailIdentifier"] = self.guardrailIdentifier + if self.guardrailVersion: + detail["guardrailVersion"] = self.guardrailVersion + return HTTPException(status_code=400, detail=detail) + async def async_pre_call_hook( self, user_api_key_dict: UserAPIKeyAuth, cache: DualCache, - data: dict, + data: dict[str, object], # mutable-ok: the hook writes the guarded messages back into the request call_type: CallTypesLiteral, ) -> Exception | str | dict | None: verbose_proxy_logger.debug("Inside Bedrock Pre-Call Hook for call_type: %s", call_type) @@ -2502,6 +2665,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): event_type: Final[GuardrailEventHooks] = GuardrailEventHooks.pre_call if self.should_run_guardrail(data=data, event_type=event_type) is not True: return data + await self.async_scan_request_attachments(data=data, call_type=call_type, event_type=event_type) new_messages: Final = self.get_guardrails_messages_for_call_type( call_type=cast(CallTypes, call_type), @@ -2555,7 +2719,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): async def async_moderation_hook( self, - data: dict, + data: dict[str, object], # mutable-ok: the hook writes the guarded messages back into the request user_api_key_dict: UserAPIKeyAuth, call_type: CallTypesLiteral, ): @@ -2569,6 +2733,8 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): if self.should_run_guardrail(data=data, event_type=event_type) is not True: return + await self.async_scan_request_attachments(data=data, call_type=call_type, event_type=event_type) + new_messages: Final = self.get_guardrails_messages_for_call_type( call_type=cast(CallTypes, call_type), data=data, diff --git a/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py b/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py index 03796569043..a7f813d953e 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py +++ b/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py @@ -11,7 +11,7 @@ import contextlib import copy import json from collections.abc import AsyncGenerator, AsyncIterable, Awaitable, Callable, Mapping, Sequence -from typing import TYPE_CHECKING, Any, Final, Protocol, TypeAlias, cast +from typing import TYPE_CHECKING, Any, Final, Protocol, TypeAlias, cast, runtime_checkable import anyio from fastapi import HTTPException @@ -53,6 +53,22 @@ GUARDRAIL_NAME: Final = "unified_llm_guardrails" _RequestData: TypeAlias = dict[str, object] +@runtime_checkable +class RequestAttachmentScanner(Protocol): + async def async_scan_request_attachments( + self, + data: _RequestData, + call_type: CallTypesLiteral, + ) -> None: ... + + +async def _scan_request_attachments( + guardrail: CustomGuardrail, data: _RequestData, call_type: CallTypesLiteral +) -> None: + if isinstance(guardrail, RequestAttachmentScanner) and hasattr(type(guardrail), "async_scan_request_attachments"): + await guardrail.async_scan_request_attachments(data=data, call_type=call_type) + + class _EndpointTranslation(Protocol): @property def process_input_messages(self) -> "Callable[..., Awaitable[dict[str, object]]]": ... @@ -251,6 +267,8 @@ class UnifiedLLMGuardrails(CustomLogger): _ensure_litellm_metadata(data, user_api_key_dict) + await _scan_request_attachments(guardrail_to_apply, data, call_type) # pyright: ignore[reportUnknownArgumentType] # hook data is an untyped dict + data = await endpoint_translation.process_input_messages( data=data, guardrail_to_apply=guardrail_to_apply, diff --git a/litellm/proxy/guardrails/guardrail_initializers.py b/litellm/proxy/guardrails/guardrail_initializers.py index 3151e316fdf..0b38f2d9488 100644 --- a/litellm/proxy/guardrails/guardrail_initializers.py +++ b/litellm/proxy/guardrails/guardrail_initializers.py @@ -41,6 +41,7 @@ def initialize_bedrock(litellm_params: LitellmParams, guardrail: Guardrail): aws_bedrock_runtime_endpoint=litellm_params.aws_bedrock_runtime_endpoint, experimental_use_latest_role_message_only=litellm_params.experimental_use_latest_role_message_only, only_scan_new_messages=litellm_params.only_scan_new_messages or False, + skip_unscannable_attachments=litellm_params.skip_unscannable_attachments, streaming_buffer_until_moderated=streaming_params.streaming_buffer_until_moderated, streaming_sampling_rate=streaming_params.streaming_sampling_rate, streaming_end_of_stream_only=streaming_params.streaming_end_of_stream_only, diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index e19f73e556a..b3b57d53bdf 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -1137,9 +1137,11 @@ class BaseLitellmParams(ContentFilterConfigModel): # works for new and patch up skip_unscannable_attachments: bool | None = Field( default=False, description=( - "Implemented by guardrail='model_armor'. When True, attachment references that carry no " - "inline bytes (file_id, gs://, or http(s) URLs) pass through unscanned instead of blocking, " - "while fail_on_error still governs real Model Armor API errors. Default False blocks them." + "Implemented by guardrail='model_armor' and guardrail='bedrock'. When True, attachments the " + "guardrail cannot scan pass through unscanned instead of blocking. For Model Armor these are " + "references with no inline bytes (file_id, gs://, or http(s) URLs), and fail_on_error still " + "governs real Model Armor API errors. For Bedrock these are documents, files, audio, video, " + "and images that are not inline PNG or JPEG up to 4 MB. Default False blocks them." ), ) sanitize_error_detail: bool | None = Field( diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/types/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 8d66b624341..c2c737fcc03 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -1,6 +1,6 @@ from typing import Literal -from typing_extensions import TypedDict +from typing_extensions import ReadOnly, TypedDict # Bedrock contextual grounding tags each content block so the guardrail knows # which text is the reference source, the user question, and the content to grade. @@ -12,8 +12,18 @@ class BedrockTextContent(TypedDict, total=False): qualifiers: list[BedrockGuardrailQualifier] +class BedrockImageSource(TypedDict): + bytes: ReadOnly[str] + + +class BedrockImageContent(TypedDict): + format: ReadOnly[Literal["png", "jpeg"]] + source: ReadOnly[BedrockImageSource] + + class BedrockContentItem(TypedDict, total=False): text: BedrockTextContent + image: ReadOnly[BedrockImageContent] class BedrockRequest(TypedDict, total=False): diff --git a/tests/integration/observability/test_bedrock_guardrail_attachments.py b/tests/integration/observability/test_bedrock_guardrail_attachments.py new file mode 100644 index 00000000000..0d53e335cd1 --- /dev/null +++ b/tests/integration/observability/test_bedrock_guardrail_attachments.py @@ -0,0 +1,525 @@ +import base64 +import json +import threading +import uuid +from collections.abc import Callable, Iterator, Mapping +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +import httpx +import pytest +import yaml +from integration._support.client import Gateway, gateway_from_environment +from integration._support.process import owned_proxy +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue + +_GUARDRAIL_ID: Final = "synthetic-attachment-guard" +_APPLY_PATH: Final = f"/guardrail/{_GUARDRAIL_ID}/version/DRAFT/apply" +_CHECKS_PATH: Final = "/guardrail-checks/invoke" +_BEDROCK_MODEL: Final = "anthropic.claude-3-haiku-20240307-v1:0" +_SSN: Final = "123-45-6789" +_ENDPOINTS: Final = ("chat", "messages", "responses", "converse") + + +def _png(marker: bytes) -> bytes: + return b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + (64).to_bytes(4, "big") * 2 + b"\x08\x02" + marker + + +_THREAT_PNG: Final = _png(b"synthetic-threat") +_PLAIN_PNG: Final = _png(b"synthetic-plain") +_PDF: Final = b"%PDF-1.4 synthetic employee record" + + +def _b64(raw: bytes) -> str: + return base64.b64encode(raw).decode() + + +@dataclass(frozen=True, slots=True) +class _Rig: + strict: Gateway + lenient: Gateway + checks: Gateway + policy: Wire + upstream: Wire + outage: threading.Event + + +def _blocked(assessment: Mapping[str, JsonValue]) -> Reply: + return Reply( + body=json.dumps( + { + "action": "GUARDRAIL_INTERVENED", + "outputs": [{"text": "blocked by synthetic policy"}], + "assessments": [assessment], + "usage": {"contentPolicyUnits": 1, "contentPolicyImageUnits": 1}, + } + ).encode() + ) + + +def _guardrail(outage: threading.Event) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + if request.target == _CHECKS_PATH: + return Reply(body=json.dumps({"results": {}}).encode()) + assert request.target == _APPLY_PATH, request.target + if outage.is_set(): + return Reply(status=500, body=b'{"message":"synthetic guardrail outage"}') + content: Final = json.loads(request.body)["content"] + images: Final = [base64.b64decode(item["image"]["source"]["bytes"]) for item in content if "image" in item] + texts: Final = [item["text"]["text"] for item in content if "text" in item] + if _THREAT_PNG in images: + return _blocked( + {"contentPolicy": {"filters": [{"type": "VIOLENCE", "confidence": "HIGH", "action": "BLOCKED"}]}} + ) + if any(_SSN in text for text in texts): + return _blocked( + { + "sensitiveInformationPolicy": { + "piiEntities": [{"type": "US_SOCIAL_SECURITY_NUMBER", "match": _SSN, "action": "BLOCKED"}] + } + } + ) + return Reply(body=json.dumps({"action": "NONE", "outputs": [], "assessments": []}).encode()) + + return respond + + +def _upstream(request: Request) -> Reply: + if request.target == "/v1/chat/completions": + return Reply( + body=json.dumps( + { + "id": f"chatcmpl-{uuid.uuid4().hex}", + "object": "chat.completion", + "created": 1, + "model": "gpt-4o-mini", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "served"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4}, + } + ).encode() + ) + if request.target == "/v1/messages": + return Reply( + body=json.dumps( + { + "id": f"msg_{uuid.uuid4().hex}", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5-20250929", + "content": [{"type": "text", "text": "served"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 3, "output_tokens": 1}, + } + ).encode() + ) + if request.target == "/v1/responses": + return Reply( + body=json.dumps( + { + "id": f"resp_{uuid.uuid4().hex}", + "object": "response", + "created_at": 1700000000, + "status": "completed", + "model": "gpt-4.1-mini", + "output": [ + { + "type": "message", + "id": "msg_synthetic", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "served", "annotations": []}], + } + ], + "usage": {"input_tokens": 3, "output_tokens": 1, "total_tokens": 4}, + } + ).encode() + ) + assert request.target == f"/model/{_BEDROCK_MODEL}/converse", request.target + return Reply( + body=json.dumps( + { + "output": {"message": {"role": "assistant", "content": [{"text": "served"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 3, "outputTokens": 1, "totalTokens": 4}, + "metrics": {"latencyMs": 1}, + } + ).encode() + ) + + +def _config( + directory: Path, name: str, policy: Wire, upstream: Wire, skip_unscannable: bool, checks: bool = False +) -> Path: + configuration: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + configuration["litellm_settings"] = {} + configuration["model_list"] = [ + { + "model_name": "attach-chat", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_base": f"{upstream.url}/v1", "api_key": "sk-chat"}, + }, + { + "model_name": "attach-messages", + "litellm_params": { + "model": "anthropic/claude-sonnet-4-5-20250929", + "api_base": upstream.url, + "api_key": "sk-ant-synthetic", + }, + }, + { + "model_name": "attach-responses", + "litellm_params": {"model": "openai/gpt-4.1-mini", "api_base": f"{upstream.url}/v1", "api_key": "sk-resp"}, + }, + { + "model_name": "attach-converse", + "litellm_params": { + "model": f"bedrock/{_BEDROCK_MODEL}", + "api_base": upstream.url, + "aws_access_key_id": "AKIASYNTHETICRUNTIME", + "aws_secret_access_key": "synthetic-runtime-secret", + "aws_region_name": "us-east-1", + }, + }, + ] + target: Final[dict[str, JsonValue]] = ( + {"checks": {"contentFilter": {"categories": [{"category": "VIOLENCE"}]}}} + if checks + else {"guardrailIdentifier": _GUARDRAIL_ID, "guardrailVersion": "DRAFT"} + ) + configuration["guardrails"] = [ + { + "guardrail_name": f"bedrock-attachments-{name}", + "litellm_params": { + "guardrail": "bedrock", + "mode": "pre_call", + "default_on": True, + "skip_unscannable_attachments": skip_unscannable, + **target, + "aws_region_name": "us-east-1", + "aws_access_key_id": "AKIASYNTHETICGUARDRAIL", + "aws_secret_access_key": "synthetic-guardrail-secret", + "aws_bedrock_runtime_endpoint": policy.url, + }, + } + ] + path: Final = directory / f"bedrock-attachments-{name}.yaml" + path.write_text(yaml.safe_dump(configuration)) + return path + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]: + directory: Final = tmp_path_factory.mktemp("bedrock-guardrail-attachments") + outage: Final = threading.Event() + with ( + wire_server(_guardrail(outage)) as policy, + wire_server(_upstream) as upstream, + gateway_from_environment() as shared, + owned_proxy( + shared, directory, {}, config=_config(directory, "strict", policy, upstream, False), workers=2 + ) as strict, + owned_proxy( + shared, directory, {}, config=_config(directory, "lenient", policy, upstream, True), workers=2 + ) as lenient, + owned_proxy( + shared, directory, {}, config=_config(directory, "checks", policy, upstream, False, checks=True), workers=2 + ) as checks, + ): + yield _Rig(strict, lenient, checks, policy, upstream, outage) + + +@pytest.fixture(autouse=True) +def _drained(rig: _Rig) -> Iterator[None]: + rig.policy.drain() + rig.upstream.drain() + rig.outage.clear() + yield + rig.outage.clear() + + +def _image_block(endpoint: str, encoded: str, mime: str = "image/png") -> dict[str, JsonValue]: + if endpoint == "chat": + return {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{encoded}"}} + if endpoint == "messages": + return {"type": "image", "source": {"type": "base64", "media_type": mime, "data": encoded}} + if endpoint == "responses": + return {"type": "input_image", "image_url": f"data:{mime};base64,{encoded}"} + return {"image": {"format": mime.removeprefix("image/"), "source": {"bytes": encoded}}} + + +def _pdf_block(endpoint: str) -> dict[str, JsonValue]: + if endpoint == "chat": + return {"type": "file", "file": {"file_data": f"data:application/pdf;base64,{_b64(_PDF)}", "filename": "r.pdf"}} + if endpoint == "messages": + return {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": _b64(_PDF)}} + if endpoint == "responses": + return {"type": "input_file", "file_data": f"data:application/pdf;base64,{_b64(_PDF)}", "filename": "r.pdf"} + return {"document": {"format": "pdf", "name": "record", "source": {"bytes": _b64(_PDF)}}} + + +def _text_block(endpoint: str, text: str) -> dict[str, JsonValue]: + if endpoint == "converse": + return {"text": text} + return {"type": "input_text" if endpoint == "responses" else "text", "text": text} + + +def _send(gateway: Gateway, endpoint: str, *blocks: JsonValue, stream: bool = False) -> httpx.Response: + content: Final = list(blocks) + if endpoint == "chat": + body: dict[str, JsonValue] = {"model": "attach-chat", "messages": [{"role": "user", "content": content}]} + return gateway.request("POST", "/v1/chat/completions", {**body, "stream": True} if stream else body) + if endpoint == "messages": + return gateway.request( + "POST", + "/v1/messages", + {"model": "attach-messages", "max_tokens": 32, "messages": [{"role": "user", "content": content}]}, + ) + if endpoint == "responses": + return gateway.request( + "POST", "/v1/responses", {"model": "attach-responses", "input": [{"role": "user", "content": content}]} + ) + return gateway.request( + "POST", "/bedrock/model/attach-converse/converse", {"messages": [{"role": "user", "content": content}]} + ) + + +def _scans(policy: Wire) -> list[list[dict[str, JsonValue]]]: + return [json.loads(request.body)["content"] for request in policy.drain()] + + +def _image_scans(scans: list[list[dict[str, JsonValue]]]) -> list[list[JsonValue]]: + return [[item["image"] for item in content if "image" in item] for content in scans if content[0].get("image")] + + +def _text_scans(scans: list[list[dict[str, JsonValue]]]) -> list[str]: + return [item["text"]["text"] for content in scans for item in content if "text" in item] + + +def _forwarded(upstream: Wire) -> list[dict[str, JsonValue]]: + return [json.loads(request.body) for request in upstream.drain() if request.method == "POST"] + + +@pytest.mark.parametrize("endpoint", _ENDPOINTS) +def test_an_image_only_turn_is_scanned_and_a_threat_image_is_blocked(rig: _Rig, endpoint: str) -> None: + response: Final = _send(rig.strict, endpoint, _image_block(endpoint, _b64(_THREAT_PNG))) + + assert response.status_code == 400, response.text + assert "VIOLENCE" in response.text, response.text + assert _image_scans(_scans(rig.policy)) == [[{"format": "png", "source": {"bytes": _b64(_THREAT_PNG)}}]] + assert _forwarded(rig.upstream) == [] + + +@pytest.mark.parametrize("endpoint", _ENDPOINTS) +def test_a_harmless_image_is_scanned_and_reaches_the_model(rig: _Rig, endpoint: str) -> None: + response: Final = _send(rig.strict, endpoint, _image_block(endpoint, _b64(_PLAIN_PNG))) + + assert response.status_code == 200, response.text + assert _image_scans(_scans(rig.policy)) == [[{"format": "png", "source": {"bytes": _b64(_PLAIN_PNG)}}]] + assert len(_forwarded(rig.upstream)) == 1 + + +@pytest.mark.parametrize("endpoint", _ENDPOINTS) +def test_a_pdf_is_refused_without_calling_bedrock_or_the_model(rig: _Rig, endpoint: str) -> None: + question: Final = f"What is in the attachment {uuid.uuid4().hex}?" + response: Final = _send(rig.strict, endpoint, _text_block(endpoint, question), _pdf_block(endpoint)) + + assert response.status_code == 400, response.text + assert "Bedrock guardrail cannot scan 1 attachment(s)" in response.text, response.text + assert _scans(rig.policy) == [] + assert _forwarded(rig.upstream) == [] + + +@pytest.mark.parametrize("endpoint", _ENDPOINTS) +def test_skip_unscannable_attachments_lets_a_pdf_through_and_still_scans_the_text(rig: _Rig, endpoint: str) -> None: + question: Final = f"What is in the attachment {uuid.uuid4().hex}?" + response: Final = _send(rig.lenient, endpoint, _text_block(endpoint, question), _pdf_block(endpoint)) + + assert response.status_code == 200, response.text + assert question in _text_scans(_scans(rig.policy)) + (forwarded,) = _forwarded(rig.upstream) + assert _b64(_PDF) in json.dumps(forwarded) + + +@pytest.mark.parametrize("endpoint", _ENDPOINTS) +def test_skip_unscannable_attachments_still_blocks_a_threat_image(rig: _Rig, endpoint: str) -> None: + response: Final = _send(rig.lenient, endpoint, _image_block(endpoint, _b64(_THREAT_PNG))) + + assert response.status_code == 400, response.text + assert "VIOLENCE" in response.text, response.text + assert _forwarded(rig.upstream) == [] + + +def test_skip_unscannable_attachments_still_scans_a_converse_pdf_name_and_context(rig: _Rig) -> None: + context: Final = f"Employee SSN {_SSN} {uuid.uuid4().hex}" + document: Final = { + "document": {"format": "pdf", "name": "record", "context": context, "source": {"bytes": _b64(_PDF)}} + } + response: Final = _send(rig.lenient, "converse", _text_block("converse", "summarize"), document) + + assert response.status_code == 400, response.text + assert f"record\n{context}" in _text_scans(_scans(rig.policy)) + assert _forwarded(rig.upstream) == [] + + +def test_a_streaming_chat_request_with_a_pdf_is_refused_before_streaming(rig: _Rig) -> None: + response: Final = _send(rig.strict, "chat", _text_block("chat", "summarize"), _pdf_block("chat"), stream=True) + + assert response.status_code == 400, response.text + assert "Bedrock guardrail cannot scan 1 attachment(s)" in response.text, response.text + assert _forwarded(rig.upstream) == [] + + +def _anthropic_text_document(text: str) -> dict[str, JsonValue]: + return {"type": "document", "title": "record", "source": {"type": "text", "media_type": "text/plain", "data": text}} + + +def _converse_text_document(source: Mapping[str, JsonValue]) -> dict[str, JsonValue]: + return {"document": {"format": "txt", "name": "record", "source": dict(source)}} + + +_TEXT_DOCUMENTS: Final = ( + pytest.param("messages", _anthropic_text_document, id="messages-text-source"), + pytest.param("converse", lambda text: _converse_text_document({"text": text}), id="converse-text-source"), + pytest.param( + "converse", + lambda text: _converse_text_document({"content": [{"text": "cover page"}, {"text": text}]}), + id="converse-content-source", + ), +) + + +@pytest.mark.parametrize("endpoint, document", _TEXT_DOCUMENTS) +def test_a_text_document_with_an_ssn_is_scanned_as_text_and_blocked( + rig: _Rig, endpoint: str, document: Callable[[str], dict[str, JsonValue]] +) -> None: + secret: Final = f"Employee SSN {_SSN} {uuid.uuid4().hex}" + response: Final = _send(rig.strict, endpoint, _text_block(endpoint, "summarize"), document(secret)) + + assert response.status_code == 400, response.text + assert any(secret in text for text in _text_scans(_scans(rig.policy))) + assert _forwarded(rig.upstream) == [] + + +@pytest.mark.parametrize("endpoint, document", _TEXT_DOCUMENTS) +def test_a_harmless_text_document_is_scanned_as_text_and_reaches_the_model( + rig: _Rig, endpoint: str, document: Callable[[str], dict[str, JsonValue]] +) -> None: + note: Final = f"The meeting is at noon {uuid.uuid4().hex}" + response: Final = _send(rig.strict, endpoint, _text_block(endpoint, "summarize"), document(note)) + + assert response.status_code == 200, response.text + assert any(note in text for text in _text_scans(_scans(rig.policy))) + (forwarded,) = _forwarded(rig.upstream) + assert note in json.dumps(forwarded) + + +def test_a_converse_txt_document_sent_as_bytes_stays_unscannable(rig: _Rig) -> None: + document: Final = {"document": {"format": "txt", "name": "record", "source": {"bytes": _b64(b"notes")}}} + response: Final = _send(rig.strict, "converse", _text_block("converse", "summarize"), document) + + assert response.status_code == 400, response.text + assert "cannot scan 1 attachment(s) (document)" in response.text, response.text + assert _forwarded(rig.upstream) == [] + + +def test_a_converse_guard_content_threat_image_is_scanned_and_blocked(rig: _Rig) -> None: + guarded: Final = {"guardContent": {"image": {"format": "png", "source": {"bytes": _b64(_THREAT_PNG)}}}} + response: Final = _send(rig.strict, "converse", _text_block("converse", "describe"), guarded) + + assert response.status_code == 400, response.text + assert "VIOLENCE" in response.text, response.text + assert _forwarded(rig.upstream) == [] + + +@pytest.mark.parametrize("endpoint", _ENDPOINTS) +def test_checks_mode_refuses_an_image_without_calling_bedrock(rig: _Rig, endpoint: str) -> None: + response: Final = _send(rig.checks, endpoint, _image_block(endpoint, _b64(_PLAIN_PNG))) + + assert response.status_code == 400, response.text + assert "cannot scan 1 attachment(s) (image)" in response.text, response.text + assert list(rig.policy.drain()) == [] + assert _forwarded(rig.upstream) == [] + + +def test_checks_mode_sends_a_converse_text_document_to_invoke_guardrail_checks(rig: _Rig) -> None: + note: Final = f"The meeting is at noon {uuid.uuid4().hex}" + response: Final = _send(rig.checks, "converse", _converse_text_document({"text": note})) + + assert response.status_code == 200, response.text + calls: Final = list(rig.policy.drain()) + assert [call.target for call in calls] == [_CHECKS_PATH] + assert note in calls[0].body.decode() + (forwarded,) = _forwarded(rig.upstream) + assert note in json.dumps(forwarded) + + +@pytest.mark.parametrize( + "encoded", + [ + pytest.param(_b64(_THREAT_PNG).rstrip("="), id="unpadded"), + pytest.param(base64.urlsafe_b64encode(_THREAT_PNG).decode(), id="url-safe"), + ], +) +def test_non_standard_base64_is_normalized_before_the_scan(rig: _Rig, encoded: str) -> None: + response: Final = _send(rig.strict, "chat", _image_block("chat", encoded)) + + assert response.status_code == 400, response.text + assert _image_scans(_scans(rig.policy)) == [[{"format": "png", "source": {"bytes": _b64(_THREAT_PNG)}}]] + + +def test_the_image_format_sent_to_bedrock_comes_from_the_bytes(rig: _Rig) -> None: + response: Final = _send(rig.strict, "chat", _image_block("chat", _b64(_PLAIN_PNG), mime="image/jpeg")) + + assert response.status_code == 200, response.text + assert _image_scans(_scans(rig.policy)) == [[{"format": "png", "source": {"bytes": _b64(_PLAIN_PNG)}}]] + + +@pytest.mark.parametrize("endpoint", ("chat", "converse")) +def test_a_guardrail_outage_fails_closed_on_an_image_turn(rig: _Rig, endpoint: str) -> None: + rig.outage.set() + response: Final = _send(rig.strict, endpoint, _image_block(endpoint, _b64(_PLAIN_PNG))) + + assert response.status_code == 500, response.text + assert "synthetic guardrail outage" in response.text, response.text + assert len(_image_scans(_scans(rig.policy))) == 1 + assert _forwarded(rig.upstream) == [] + + +def test_a_concurrent_mixed_burst_across_two_workers_keeps_each_verdict(rig: _Rig) -> None: + cases: Final = tuple( + (kind, uuid.uuid4().hex) + for kind in ("threat", "plain", "pdf", "converse-note", "converse-ssn") + for _ in range(5) + ) + + def send(case: tuple[str, str]) -> tuple[str, str, int]: + kind, tag = case + if kind == "threat": + response = _send(rig.strict, "chat", _text_block("chat", tag), _image_block("chat", _b64(_THREAT_PNG))) + elif kind == "plain": + response = _send( + rig.strict, "messages", _text_block("messages", tag), _image_block("messages", _b64(_PLAIN_PNG)) + ) + elif kind == "pdf": + response = _send(rig.strict, "responses", _text_block("responses", tag), _pdf_block("responses")) + elif kind == "converse-note": + response = _send(rig.strict, "converse", _converse_text_document({"text": f"note {tag}"})) + else: + response = _send(rig.strict, "converse", _converse_text_document({"text": f"SSN {_SSN} {tag}"})) + return kind, tag, response.status_code + + with ThreadPoolExecutor(max_workers=12) as pool: + results: Final = tuple(pool.map(send, cases)) + + expected: Final = {"threat": 400, "plain": 200, "pdf": 400, "converse-note": 200, "converse-ssn": 400} + assert [(kind, status) for kind, _, status in results] == [(kind, expected[kind]) for kind, _, _ in results] + served: Final = json.dumps(_forwarded(rig.upstream)) + assert sorted(tag for kind, tag, _ in results if kind in ("plain", "converse-note") and tag in served) == sorted( + tag for kind, tag, _ in results if kind in ("plain", "converse-note") + ) + assert not any(tag in served for kind, tag, _ in results if expected[kind] == 400) diff --git a/tests/unit/llms/bedrock/test_guardrail_attachments.py b/tests/unit/llms/bedrock/test_guardrail_attachments.py new file mode 100644 index 00000000000..211747069e2 --- /dev/null +++ b/tests/unit/llms/bedrock/test_guardrail_attachments.py @@ -0,0 +1,651 @@ +import base64 + +import pytest + +from litellm.llms.bedrock.guardrail_attachments import find_request_attachments +from litellm.types.utils import CallTypes + +PNG_B64 = base64.b64encode(b"\x89PNG\r\n\x1a\nfake-png").decode() +JPEG_B64 = base64.b64encode(b"\xff\xd8\xfffake-jpeg").decode() +OVERSIZE_PNG_B64 = base64.b64encode(b"\x89PNG" + b"\0" * (4 * 1024 * 1024)).decode() +PDF_B64 = base64.b64encode(b"%PDF-1.4 fake").decode() + + +def _png_item(encoded: str = PNG_B64) -> dict: + return {"image": {"format": "png", "source": {"bytes": encoded}}} + + +def _jpeg_item() -> dict: + return {"image": {"format": "jpeg", "source": {"bytes": JPEG_B64}}} + + +def _chat(*blocks: object, role: str = "user") -> dict: + return {"messages": [{"role": role, "content": list(blocks)}]} + + +def _converse(*blocks: object, endpoint: str = "model/haiku/converse", provider: str = "bedrock") -> dict: + return { + "endpoint": endpoint, + "custom_llm_provider": provider, + "data": {"messages": [{"role": "user", "content": list(blocks)}]}, + } + + +TEXT = {"type": "text", "text": "hello"} + + +@pytest.mark.parametrize( + "data, call_type, images, unscannable", + [ + pytest.param(_chat(TEXT), CallTypes.acompletion.value, [], [], id="chat-text-only"), + pytest.param( + {"messages": [{"role": "user", "content": "hi"}]}, + CallTypes.acompletion.value, + [], + [], + id="chat-string-content", + ), + pytest.param( + _chat(TEXT, {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{PNG_B64}"}}), + CallTypes.acompletion.value, + [_png_item()], + [], + id="chat-png-data-uri-after-text", + ), + pytest.param( + _chat({"type": "image_url", "image_url": f"data:image/jpeg;base64,{JPEG_B64}"}), + CallTypes.completion.value, + [_jpeg_item()], + [], + id="chat-jpeg-string-url", + ), + pytest.param( + _chat( + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{PNG_B64}"}}, + TEXT, + {"type": "image_url", "image_url": {"url": f"data:image/jpg;base64,{JPEG_B64}"}}, + ), + CallTypes.acompletion.value, + [_png_item(), _jpeg_item()], + [], + id="chat-two-images", + ), + pytest.param( + _chat({"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}), + CallTypes.acompletion.value, + [], + ["image_url (remote URL or file id)"], + id="chat-remote-image", + ), + pytest.param( + _chat({"type": "image_url", "image_url": {"url": f"data:image/gif;base64,{PNG_B64}"}}), + CallTypes.acompletion.value, + [], + ["image_url (image/gif)"], + id="chat-gif", + ), + pytest.param( + _chat({"type": "image_url", "image_url": {"url": "data:image/png;base64,***not-base64***"}}), + CallTypes.acompletion.value, + [], + ["image_url (invalid base64)"], + id="chat-malformed-base64", + ), + pytest.param( + _chat({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{OVERSIZE_PNG_B64}"}}), + CallTypes.acompletion.value, + [], + ["image_url (over 4 MB)"], + id="chat-image-over-4mb", + ), + pytest.param( + _chat({"type": "image_url", "image_url": {"url": "data:image/png;base64,"}}), + CallTypes.acompletion.value, + [], + ["image_url (invalid base64)"], + id="chat-empty-base64", + ), + pytest.param( + _chat( + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": PNG_B64}}, + {"type": "input_image", "image_url": f"data:image/jpeg;base64,{JPEG_B64}"}, + {"type": "input_file", "file_id": "file-1"}, + ), + CallTypes.acompletion.value, + [_png_item(), _jpeg_item()], + ["input_file"], + id="chat-anthropic-and-responses-shaped-parts", + ), + pytest.param( + {"input": [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": "https://x/y.png"}}]}]}, + CallTypes.aresponses.value, + [], + ["image_url (remote URL or file id)"], + id="responses-chat-shaped-image-url", + ), + pytest.param( + _converse({"audio": {"format": "mp3", "source": {"bytes": PDF_B64}}}), + CallTypes.allm_passthrough_route.value, + [], + ["audio"], + id="converse-audio", + ), + pytest.param( + _chat(TEXT, {"type": "file", "file": {"file_data": f"data:application/pdf;base64,{PDF_B64}"}}), + CallTypes.acompletion.value, + [], + ["file"], + id="chat-pdf-file", + ), + pytest.param( + _chat( + {"type": "input_audio", "input_audio": {"data": "AAAA", "format": "wav"}}, + {"type": "video_url", "video_url": {"url": "https://example.com/v.mp4"}}, + ), + CallTypes.acompletion.value, + [], + ["input_audio", "video_url"], + id="chat-audio-and-video", + ), + pytest.param( + _chat( + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": PNG_B64}}, + {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": PDF_B64}}, + ), + CallTypes.anthropic_messages.value, + [_png_item()], + ["document"], + id="anthropic-image-and-document", + ), + pytest.param( + _chat({"type": "image", "source": {"type": "url", "url": "https://example.com/cat.png"}}), + CallTypes.anthropic_messages.value, + [], + ["image (url or file source)"], + id="anthropic-url-image", + ), + pytest.param( + { + "input": [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "hi"}, + {"type": "input_image", "image_url": f"data:image/png;base64,{PNG_B64}"}, + {"type": "input_file", "file_data": f"data:application/pdf;base64,{PDF_B64}"}, + ], + } + ] + }, + CallTypes.aresponses.value, + [_png_item()], + ["input_file"], + id="responses-image-and-file", + ), + pytest.param({"input": "just text"}, CallTypes.responses.value, [], [], id="responses-string-input"), + pytest.param( + _converse({"text": "hi"}, _png_item(), {"document": {"format": "pdf", "source": {"bytes": PDF_B64}}}), + CallTypes.allm_passthrough_route.value, + [_png_item()], + ["document"], + id="converse-image-and-document", + ), + pytest.param( + _converse({"image": {"format": "png", "source": {"s3Location": {"uri": "s3://b/k.png"}}}}), + CallTypes.allm_passthrough_route.value, + [], + ["image (no inline bytes)"], + id="converse-s3-image", + ), + pytest.param( + _converse( + {"video": {"format": "mp4", "source": {"bytes": PDF_B64}}}, endpoint="model/haiku/converse-stream" + ), + CallTypes.allm_passthrough_route.value, + [], + ["video"], + id="converse-stream-video", + ), + pytest.param( + _converse(_png_item(), endpoint="model/haiku/invoke"), + CallTypes.allm_passthrough_route.value, + [], + [], + id="bedrock-invoke-not-converse", + ), + pytest.param( + _converse(_png_item(), provider="vertex_ai"), + CallTypes.allm_passthrough_route.value, + [], + [], + id="non-bedrock-passthrough", + ), + pytest.param( + _chat({"type": "image_url", "image_url": {"url": f"data:image/PNG;BASE64,{PNG_B64}"}}), + CallTypes.acompletion.value, + [_png_item()], + [], + id="chat-uppercase-data-uri", + ), + pytest.param( + _chat( + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": [ + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": PNG_B64}}, + {"type": "document", "source": {"type": "url", "url": "https://example.com/r.pdf"}}, + ], + } + ), + CallTypes.anthropic_messages.value, + [_png_item()], + ["document"], + id="anthropic-tool-result-image-and-document", + ), + pytest.param( + { + "input": [ + { + "type": "function_call_output", + "call_id": "call_1", + "output": [ + {"type": "input_image", "image_url": f"data:image/png;base64,{PNG_B64}"}, + {"type": "input_file", "file_id": "file-1"}, + ], + }, + {"type": "function_call_output", "call_id": "call_2", "output": "plain text"}, + ] + }, + CallTypes.aresponses.value, + [_png_item()], + ["input_file"], + id="responses-function-call-output-image-and-file", + ), + pytest.param( + { + "input": [ + { + "type": "computer_call_output", + "call_id": "call_1", + "output": {"type": "computer_screenshot", "image_url": f"data:image/png;base64,{PNG_B64}"}, + }, + { + "type": "computer_call_output", + "call_id": "call_2", + "output": {"type": "computer_screenshot", "file_id": "file-1"}, + }, + ] + }, + CallTypes.responses.value, + [_png_item()], + ["computer_screenshot (remote URL or file id)"], + id="responses-computer-screenshot", + ), + pytest.param( + _converse( + { + "toolResult": { + "toolUseId": "t1", + "content": [{"document": {"format": "pdf", "source": {"bytes": PDF_B64}}}, _png_item()], + } + } + ), + CallTypes.allm_passthrough_route.value, + [_png_item()], + ["document"], + id="converse-tool-result-document-and-image", + ), + pytest.param( + _chat({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{PNG_B64}"}}), + CallTypes.call_mcp_tool.value, + [], + [], + id="mcp-call-type-ignored", + ), + ], +) +def test_find_request_attachments(data, call_type, images, unscannable): + found = find_request_attachments(data, call_type, skip_tool_messages=False, latest_user_message_only=False) + + assert list(found.images) == images + assert list(found.unscannable) == unscannable + + +def test_whitespace_in_base64_is_stripped_before_sending(): + wrapped = PNG_B64[:8] + "\n" + PNG_B64[8:] + data = _chat({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{wrapped}"}}) + + found = find_request_attachments(data, CallTypes.acompletion.value, False, False) + + assert list(found.images) == [_png_item()] + + +def test_tool_messages_skipped_only_when_asked(): + data = { + "messages": [ + {"role": "user", "content": [TEXT]}, + {"role": "tool", "content": [{"type": "file", "file": {"file_id": "file-1"}}]}, + ] + } + + scanned = find_request_attachments(data, CallTypes.acompletion.value, False, False) + skipped = find_request_attachments(data, CallTypes.acompletion.value, True, False) + + assert scanned.unscannable == ("file",) + assert skipped.unscannable == () + + +def test_scan_only_tool_results_keeps_only_tool_output(): + data = _chat( + {"type": "document", "source": {"type": "url", "url": "https://example.com/user.pdf"}}, + {"type": "tool_result", "tool_use_id": "toolu_1", "content": [{"type": "document", "source": {}}]}, + ) + + everything = find_request_attachments(data, CallTypes.anthropic_messages.value, False, False) + tool_only = find_request_attachments(data, CallTypes.anthropic_messages.value, False, False, True) + skip_tool = find_request_attachments(data, CallTypes.anthropic_messages.value, True, False) + + assert everything.unscannable == ("document", "document") + assert tool_only.unscannable == ("document",) + assert skip_tool.unscannable == ("document",) + assert find_request_attachments(data, CallTypes.anthropic_messages.value, True, False, True).unscannable == () + + +def test_long_mime_is_truncated_in_label(): + data = _chat({"type": "image_url", "image_url": {"url": f"data:image/{'x' * 500};base64,{PNG_B64}"}}) + + found = find_request_attachments(data, CallTypes.acompletion.value, False, False) + + assert found.unscannable == (f"image_url (image/{'x' * 34})",) + + +def test_latest_user_message_only(): + data = { + "messages": [ + {"role": "user", "content": [{"type": "file", "file": {"file_id": "old"}}]}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": [{"type": "image_url", "image_url": f"data:image/png;base64,{PNG_B64}"}]}, + ] + } + + found = find_request_attachments(data, CallTypes.acompletion.value, False, True) + + assert list(found.images) == [_png_item()] + assert found.unscannable == () + + +def test_non_string_role_and_type_do_not_raise(): + data = { + "messages": [ + {"role": "user", "content": [{"type": "image_url", "image_url": f"data:image/png;base64,{PNG_B64}"}]}, + {"role": ["tool"], "type": {"x": 1}, "content": [{"type": "file", "file": {"file_id": "f"}}]}, + {"role": {"r": "tool"}, "type": ["function_call_output"], "content": [{"type": "file", "file": {}}]}, + ] + } + + found = find_request_attachments(data, CallTypes.acompletion.value, True, False) + + assert list(found.images) == [_png_item()] + assert found.unscannable == ("file", "file") + + +def test_converse_null_document_is_not_an_attachment(): + data = _converse(_png_item(), {"document": None}, {"video": None, "audio": None}, {"audio": {"format": "mp3"}}) + + found = find_request_attachments(data, CallTypes.allm_passthrough_route.value, False, False) + + assert list(found.images) == [_png_item()] + assert found.unscannable == ("audio",) + + +@pytest.mark.parametrize( + "block", + [ + pytest.param( + {"type": "document", "source": {"type": "text", "media_type": "text/plain", "data": "hi"}}, id="text" + ), + pytest.param( + {"type": "document", "source": {"type": "content", "content": [{"type": "text", "text": "hi"}]}}, + id="content", + ), + pytest.param({"type": "document", "source": {"type": "content", "content": "hi"}}, id="content-string"), + ], +) +@pytest.mark.parametrize( + "call_type", [CallTypes.anthropic_messages.value, CallTypes.acompletion.value], ids=["messages", "chat"] +) +def test_text_source_document_is_not_an_attachment(block, call_type): + pdf = {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": PDF_B64}} + + found = find_request_attachments(_chat(TEXT, block, pdf), call_type, False, False) + + assert found.images == () + assert found.unscannable == ("document",) + assert found.document_texts == ("hi",) + + +def test_unpadded_and_url_safe_base64_are_sent_as_standard_base64(): + raw = b"\x89PNG\r\n\x1a\n\xfb\xff\xfe-fake" + standard = base64.b64encode(raw).decode() + unpadded = standard.rstrip("=") + url_safe = base64.urlsafe_b64encode(raw).decode().rstrip("=") + data = _chat( + *( + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{encoded}"}} + for encoded in (unpadded, url_safe) + ) + ) + + found = find_request_attachments(data, CallTypes.acompletion.value, False, False) + + assert unpadded != standard + assert set("-_") & set(url_safe) + assert list(found.images) == [_png_item(standard), _png_item(standard)] + assert found.unscannable == () + + +def test_oversize_base64_is_refused_by_length_before_decoding(): + encoded = "!" * (len(OVERSIZE_PNG_B64) + 4) + data = _chat({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{encoded}"}}) + + found = find_request_attachments(data, CallTypes.acompletion.value, False, False) + + assert found.unscannable == ("image_url (over 4 MB)",) + + +@pytest.mark.parametrize( + "call_type", [CallTypes.anthropic_messages.value, CallTypes.acompletion.value], ids=["messages", "chat"] +) +def test_images_inside_a_content_source_document_are_scanned(call_type): + image = {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": PNG_B64}} + pdf = {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": PDF_B64}} + block = {"type": "document", "source": {"type": "content", "content": [TEXT, image, pdf]}} + + found = find_request_attachments(_chat(block), call_type, False, False) + + assert list(found.images) == [_png_item()] + assert found.unscannable == ("document",) + assert found.document_texts == ("hello",) + + +def test_document_images_inside_a_tool_result_follow_the_tool_scope(): + image = {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": PNG_B64}} + document = {"type": "document", "source": {"type": "content", "content": [image]}} + data = _chat({"type": "tool_result", "tool_use_id": "t1", "content": [document]}) + + scanned = find_request_attachments(data, CallTypes.anthropic_messages.value, False, False) + skipped = find_request_attachments(data, CallTypes.anthropic_messages.value, True, False) + + assert list(scanned.images) == [_png_item()] + assert skipped.images == () + + +def test_document_title_and_context_are_scanned_as_text(): + text_doc = { + "type": "document", + "title": "record", + "context": "SSN 123-45-6789", + "source": {"type": "text", "media_type": "text/plain", "data": "body"}, + } + pdf = { + "type": "document", + "context": "note", + "source": {"type": "base64", "media_type": "application/pdf", "data": PDF_B64}, + } + + found = find_request_attachments(_chat(text_doc, pdf), CallTypes.anthropic_messages.value, False, False) + + assert found.document_texts == ("record\nSSN 123-45-6789\nbody", "note") + assert found.unscannable == ("document",) + + +def _content_document(*inner: object) -> dict: + return {"type": "document", "source": {"type": "content", "content": list(inner)}} + + +def test_nested_documents_are_scanned_and_deep_nesting_is_refused(): + inner_text = {"type": "document", "source": {"type": "text", "media_type": "text/plain", "data": "inner"}} + nested = _content_document(inner_text, _content_document({"type": "text", "text": "deeper"})) + too_deep = _content_document(_content_document(_content_document(_content_document(TEXT)))) + + found = find_request_attachments(_chat(nested, too_deep), CallTypes.anthropic_messages.value, False, False) + + assert found.document_texts == ("inner", "deeper") + assert found.unscannable == ("document (nested too deep)",) + + +def _png_bytes(width: int, height: int) -> bytes: + return b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + width.to_bytes(4, "big") + height.to_bytes(4, "big") + b"\x08\x02" + + +def _jpeg_bytes(width: int, height: int) -> bytes: + app0 = b"\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + sof0 = b"\xff\xc0\x00\x11\x08" + height.to_bytes(2, "big") + width.to_bytes(2, "big") + b"\x03" + return b"\xff\xd8" + app0 + b"\xff" + sof0 + + +def _image_url(mime: str, raw: bytes) -> dict: + return {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{base64.b64encode(raw).decode()}"}} + + +@pytest.mark.parametrize( + "mime, raw, sent_format", + [ + pytest.param("image/png", _jpeg_bytes(256, 256), "jpeg", id="jpeg-labeled-png"), + pytest.param("image/jpeg", _png_bytes(256, 256), "png", id="png-labeled-jpeg"), + pytest.param("image/png", _png_bytes(8000, 8000), "png", id="png-at-pixel-limit"), + pytest.param("image/jpeg", _jpeg_bytes(8000, 10), "jpeg", id="jpeg-at-pixel-limit"), + ], +) +def test_image_format_is_taken_from_the_bytes(mime, raw, sent_format): + found = find_request_attachments(_chat(_image_url(mime, raw)), CallTypes.acompletion.value, False, False) + + assert [item["image"]["format"] for item in found.images] == [sent_format] + assert found.unscannable == () + + +@pytest.mark.parametrize( + "mime, raw, label", + [ + pytest.param("image/png", _png_bytes(8001, 10), "image_url (over 8000 pixels)", id="png-too-wide"), + pytest.param("image/png", _png_bytes(10, 9000), "image_url (over 8000 pixels)", id="png-too-tall"), + pytest.param("image/jpeg", _jpeg_bytes(9000, 10), "image_url (over 8000 pixels)", id="jpeg-too-wide"), + pytest.param("image/jpeg", _jpeg_bytes(10, 8001), "image_url (over 8000 pixels)", id="jpeg-too-tall"), + pytest.param("image/png", b"GIF89a\x01\x00\x01\x00", "image_url (not PNG or JPEG data)", id="gif-labeled-png"), + ], +) +def test_images_bedrock_cannot_scan_are_unscannable(mime, raw, label): + data = _chat(_image_url("image/png", _png_bytes(16, 16)), _image_url(mime, raw)) + + found = find_request_attachments(data, CallTypes.acompletion.value, False, False) + + assert len(found.images) == 1 + assert found.unscannable == (label,) + + +def _converse_document(source: dict, **fields: str) -> dict: + return {"document": {"format": "txt", "name": "notes", **fields, "source": source}} + + +@pytest.mark.parametrize( + "block, document_texts", + [ + pytest.param(_converse_document({"text": "SSN 123-45-6789"}), ("notes\nSSN 123-45-6789",), id="text"), + pytest.param( + _converse_document({"text": "body"}, context="SSN 123-45-6789"), + ("notes\nSSN 123-45-6789\nbody",), + id="text-with-context", + ), + pytest.param( + _converse_document({"content": [{"text": "first"}, {"text": "SSN 123-45-6789"}, {"text": "last"}]}), + ("notes\nfirst\nSSN 123-45-6789\nlast",), + id="content", + ), + pytest.param(_converse_document({"text": ""}, name=""), (), id="empty-text"), + ], +) +def test_converse_text_source_document_is_scanned_as_text(block, document_texts): + found = find_request_attachments( + _converse({"text": "hi"}, block), CallTypes.allm_passthrough_route.value, False, False + ) + + assert found.document_texts == document_texts + assert found.images == () + assert found.unscannable == () + + +@pytest.mark.parametrize( + "source", + [ + pytest.param({"bytes": PDF_B64}, id="bytes"), + pytest.param({"s3Location": {"uri": "s3://b/k.txt"}}, id="s3"), + pytest.param({"text": "hi", "bytes": PDF_B64}, id="text-and-bytes"), + pytest.param({"content": [{"text": "hi"}, {"image": {"format": "png"}}]}, id="content-with-non-text"), + pytest.param({"content": [{"text": "hi"}, "raw"]}, id="content-with-string"), + pytest.param({"content": "hi"}, id="content-not-a-list"), + pytest.param({}, id="no-source-fields"), + ], +) +def test_converse_document_without_text_source_stays_unscannable(source): + found = find_request_attachments( + _converse(_converse_document(source)), CallTypes.allm_passthrough_route.value, False, False + ) + + assert found.document_texts == ("notes",) + assert found.unscannable == ("document",) + + +def test_converse_binary_document_name_and_context_are_still_scanned_as_text(): + data = _converse( + _converse_document({"bytes": PDF_B64}, context="SSN 123-45-6789"), + {"toolResult": {"toolUseId": "t1", "content": [_converse_document({"s3Location": {"uri": "s3://b/k.pdf"}})]}}, + ) + + found = find_request_attachments(data, CallTypes.allm_passthrough_route.value, False, False) + + assert found.document_texts == ("notes\nSSN 123-45-6789", "notes") + assert found.unscannable == ("document", "document") + + +def test_converse_text_document_in_tool_result_follows_the_tool_scope(): + data = _converse({"toolResult": {"toolUseId": "t1", "content": [_converse_document({"text": "SSN 123-45-6789"})]}}) + + scanned = find_request_attachments(data, CallTypes.allm_passthrough_route.value, False, False) + skipped = find_request_attachments(data, CallTypes.allm_passthrough_route.value, True, False) + + assert scanned.document_texts == ("notes\nSSN 123-45-6789",) + assert skipped.document_texts == () + + +def test_converse_guard_content_image_is_scanned(): + data = _converse( + {"guardContent": {"image": _png_item()["image"]}}, + {"guardContent": {"image": {"format": "png", "source": {"s3Location": {"uri": "s3://b/k.png"}}}}}, + {"guardContent": {"text": {"text": "hi"}}}, + ) + + found = find_request_attachments(data, CallTypes.allm_passthrough_route.value, False, False) + + assert list(found.images) == [_png_item()] + assert found.unscannable == ("image (no inline bytes)",) diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index ccf3852c2c5..3b33ba75eb3 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -4,6 +4,7 @@ Unit tests for Bedrock Guardrails import json import asyncio +import logging from typing import Final from datetime import datetime, timezone import sys @@ -16,6 +17,7 @@ from fastapi import HTTPException import litellm +from litellm._logging import verbose_proxy_logger from litellm.caching.caching import DualCache from litellm.exceptions import ModifyResponseException from litellm.proxy._types import UserAPIKeyAuth @@ -6104,7 +6106,9 @@ async def test_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch): mock_response.json.return_value = {"action": "NONE", "assessments": []} with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock, return_value=mock_response) as mock_post: - response = await guardrail.make_bedrock_api_request(source="INPUT", messages=[{"role": "user", "content": "hello"}]) + response = await guardrail.make_bedrock_api_request( + source="INPUT", messages=[{"role": "user", "content": "hello"}] + ) assert response["action"] == "NONE" assert mock_post.call_args.kwargs["headers"]["Authorization"] == "Bearer env-bearer-token-12345" @@ -6143,6 +6147,443 @@ async def test_apply_guardrail_signs_off_the_event_loop(monkeypatch): assert probe.served_during_refresh is True +_ATTACHMENT_PNG_B64 = "iVBORw0KGgpmYWtlLXBuZw==" +_ATTACHMENT_PDF_URI = "data:application/pdf;base64,JVBERi0xLjQgZmFrZQ==" + + +def _image_only_chat_request() -> dict: + return { + "model": "claude", + "messages": [ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{_ATTACHMENT_PNG_B64}"}} + ], + } + ], + } + + +def _pdf_chat_request() -> dict: + return { + "model": "claude", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "summarize"}, + {"type": "file", "file": {"file_data": _ATTACHMENT_PDF_URI}}, + ], + } + ], + } + + +def _attachment_guardrail(**kwargs) -> BedrockGuardrail: + return BedrockGuardrail( + guardrail_name="bedrock-attachments", + guardrailIdentifier="gid", + guardrailVersion="DRAFT", + **kwargs, + ) + + +def _patched_bedrock_post(guardrail: BedrockGuardrail, response: MagicMock): + credentials = MagicMock(access_key="k", secret_key="s", token=None) + return ( + patch.object(guardrail.async_handler, "post", new_callable=AsyncMock, return_value=response), + patch.object(guardrail, "_load_credentials", return_value=(credentials, "us-east-1")), + patch.object(guardrail, "_prepare_request", return_value=MagicMock()), + ) + + +@pytest.mark.asyncio +async def test_attachment_scan_sends_image_only_turn_to_apply_guardrail_and_blocks(): + guardrail = _attachment_guardrail() + post_patch, credentials_patch, prepare_patch = _patched_bedrock_post( + guardrail, _blocking_bedrock_httpx_response("violence") + ) + + with post_patch as mock_post, credentials_patch, prepare_patch as mock_prepare: + with pytest.raises(HTTPException) as exc_info: + await guardrail.async_scan_request_attachments( + data=_image_only_chat_request(), call_type=CallTypes.acompletion.value + ) + + assert exc_info.value.status_code == 400 + assert mock_post.await_count == 1 + sent_body = mock_prepare.call_args.kwargs["data"] + assert sent_body["source"] == "INPUT" + assert sent_body["content"] == ({"image": {"format": "png", "source": {"bytes": _ATTACHMENT_PNG_B64}}},) + + +@pytest.mark.asyncio +async def test_attachment_scan_lets_a_passing_image_through(): + guardrail = _attachment_guardrail() + data = _image_only_chat_request() + post_patch, credentials_patch, prepare_patch = _patched_bedrock_post( + guardrail, _passing_bedrock_httpx_response("ok") + ) + + with post_patch as mock_post, credentials_patch, prepare_patch: + await guardrail.async_scan_request_attachments(data=data, call_type=CallTypes.acompletion.value) + + assert mock_post.await_count == 1 + logged = data["metadata"]["standard_logging_guardrail_information"] + assert [entry["guardrail_status"] for entry in logged] == ["success"] + + +@pytest.mark.asyncio +async def test_attachment_scan_blocks_unscannable_attachment_without_calling_bedrock(): + guardrail = _attachment_guardrail() + data = _pdf_chat_request() + + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post: + with pytest.raises(HTTPException) as exc_info: + await guardrail.async_scan_request_attachments(data=data, call_type=CallTypes.acompletion.value) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["error"] == "Violated guardrail policy" + assert "cannot scan 1 attachment(s) (file)" in exc_info.value.detail["reason"] + assert exc_info.value.detail["guardrailIdentifier"] == "gid" + mock_post.assert_not_awaited() + logged = data["metadata"]["standard_logging_guardrail_information"] + assert logged[0]["guardrail_status"] == "guardrail_intervened" + assert logged[0]["guardrail_response"] == {"unscannable_attachments": ["file"]} + + +@pytest.mark.asyncio +async def test_attachment_scan_skip_unscannable_attachments_lets_documents_through(): + guardrail = _attachment_guardrail(skip_unscannable_attachments=True) + data = _pdf_chat_request() + + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post: + result = await guardrail.async_scan_request_attachments(data=data, call_type=CallTypes.acompletion.value) + + assert result is None + assert data == _pdf_chat_request() + mock_post.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_attachment_scan_block_returns_a_response_when_exceptions_are_disabled(): + guardrail = _attachment_guardrail(disable_exception_on_block=True) + + with pytest.raises(ModifyResponseException) as exc_info: + await guardrail.async_scan_request_attachments(data=_pdf_chat_request(), call_type=CallTypes.acompletion.value) + + assert "cannot scan 1 attachment(s) (file)" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_attachment_scan_treats_images_as_unscannable_in_checks_mode(): + guardrail = BedrockGuardrail(guardrail_name="bedrock-checks", checks={"contentFilter": {}}) + + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post: + with pytest.raises(HTTPException) as exc_info: + await guardrail.async_scan_request_attachments( + data=_image_only_chat_request(), call_type=CallTypes.acompletion.value + ) + + assert "cannot scan 1 attachment(s) (image)" in exc_info.value.detail["reason"] + mock_post.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_attachment_scan_does_nothing_for_text_only_requests(): + guardrail = _attachment_guardrail() + data = {"model": "claude", "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]} + + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post: + await guardrail.async_scan_request_attachments(data=data, call_type=CallTypes.acompletion.value) + + mock_post.assert_not_awaited() + assert "metadata" not in data + + +@pytest.mark.asyncio +async def test_during_call_hook_scans_image_only_turn(): + guardrail = _attachment_guardrail(event_hook=GuardrailEventHooks.during_call, default_on=True) + post_patch, credentials_patch, prepare_patch = _patched_bedrock_post( + guardrail, _blocking_bedrock_httpx_response("violence") + ) + + with post_patch as mock_post, credentials_patch, prepare_patch: + with pytest.raises(HTTPException): + await guardrail.async_moderation_hook( + data=_image_only_chat_request(), + user_api_key_dict=UserAPIKeyAuth(), + call_type=CallTypes.acompletion.value, + ) + + assert mock_post.await_count == 1 + + +@pytest.mark.asyncio +async def test_attachment_scan_in_checks_mode_never_posts_images_even_when_skipping(): + guardrail = BedrockGuardrail( + guardrail_name="bedrock-checks", checks={"contentFilter": {}}, skip_unscannable_attachments=True + ) + + data = _image_only_chat_request() + + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post: + result = await guardrail.async_scan_request_attachments(data=data, call_type=CallTypes.acompletion.value) + + assert result is None + assert data == _image_only_chat_request() + mock_post.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_attachment_scan_sends_at_most_twenty_images_per_call(): + guardrail = _attachment_guardrail() + data = _image_only_chat_request() + data["messages"][0]["content"] = data["messages"][0]["content"] * 25 + post_patch, credentials_patch, prepare_patch = _patched_bedrock_post( + guardrail, _passing_bedrock_httpx_response("ok") + ) + + with post_patch as mock_post, credentials_patch, prepare_patch as mock_prepare: + await guardrail.async_scan_request_attachments(data=data, call_type=CallTypes.acompletion.value) + + assert mock_post.await_count == 2 + assert [len(call.kwargs["data"]["content"]) for call in mock_prepare.call_args_list] == [20, 5] + + +@pytest.mark.asyncio +async def test_during_call_hook_skips_attachment_scan_when_guardrail_is_not_requested(): + guardrail = _attachment_guardrail(event_hook=GuardrailEventHooks.during_call, default_on=False) + + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post: + result = await guardrail.async_moderation_hook( + data=_pdf_chat_request(), user_api_key_dict=UserAPIKeyAuth(), call_type="acompletion" + ) + + assert result is None + assert mock_post.await_count == 0 + + +@pytest.mark.asyncio +async def test_attachment_scan_honors_scan_only_tool_results(): + guardrail = _attachment_guardrail() + guardrail.scan_only_tool_results = True + data = _pdf_chat_request() + + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post: + result = await guardrail.async_scan_request_attachments(data=data, call_type=CallTypes.acompletion.value) + + assert result is None + assert mock_post.await_count == 0 + assert data == _pdf_chat_request() + + +@pytest.mark.asyncio +async def test_native_pre_call_hook_blocks_unscannable_attachment(): + guardrail = _attachment_guardrail(event_hook=GuardrailEventHooks.pre_call, default_on=True) + + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post: + with pytest.raises(HTTPException) as exc_info: + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), + cache=MagicMock(), + data=_pdf_chat_request(), + call_type="acompletion", + ) + + assert exc_info.value.status_code == 400 + assert "cannot scan 1 attachment(s) (file)" in exc_info.value.detail["reason"] + assert mock_post.await_count == 0 + + +@pytest.mark.asyncio +async def test_attachment_scan_debug_log_omits_image_bytes(): + guardrail = _attachment_guardrail() + post_patch, credentials_patch, prepare_patch = _patched_bedrock_post( + guardrail, _passing_bedrock_httpx_response("ok") + ) + + with ( + post_patch, + credentials_patch, + prepare_patch, + patch("litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails.verbose_proxy_logger.debug") as mock_debug, + ): + await guardrail.async_scan_request_attachments( + data=_image_only_chat_request(), call_type=CallTypes.acompletion.value + ) + + logged = " ".join(str(call.args) for call in mock_debug.call_args_list) + assert _ATTACHMENT_PNG_B64 not in logged + assert f"<{len(_ATTACHMENT_PNG_B64)} base64 chars>" in logged + + +@pytest.mark.asyncio +async def test_text_only_debug_log_prints_the_signed_request_body(): + guardrail = _attachment_guardrail() + post_patch, credentials_patch, prepare_patch = _patched_bedrock_post( + guardrail, _passing_bedrock_httpx_response("ok") + ) + captured_records: list[logging.LogRecord] = [] + + class _RecordingHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + captured_records.append(record) + + handler = _RecordingHandler(level=logging.DEBUG) + previous_level = verbose_proxy_logger.level + verbose_proxy_logger.addHandler(handler) + verbose_proxy_logger.setLevel(logging.DEBUG) + try: + with post_patch, credentials_patch, prepare_patch: + await guardrail.make_bedrock_api_request( + source="INPUT", messages=[{"role": "user", "content": "hello"}], request_data={} + ) + finally: + verbose_proxy_logger.removeHandler(handler) + verbose_proxy_logger.setLevel(previous_level) + + body_lines = [ + record.getMessage() for record in captured_records if record.getMessage().startswith("Bedrock AI request body") + ] + assert len(body_lines) == 1 + assert "'content': ({'text': {'text': 'hello'}},)" in body_lines[0] + + +def _text_document_messages_request(text: str) -> dict: + return { + "model": "claude", + "messages": [ + { + "role": "user", + "content": [ + {"type": "document", "source": {"type": "text", "media_type": "text/plain", "data": text}}, + {"type": "text", "text": "summarize"}, + ], + } + ], + } + + +def _anonymizing_bedrock_httpx_response() -> MagicMock: + response = MagicMock() + response.status_code = 200 + response.json.return_value = { + "action": "GUARDRAIL_INTERVENED", + "outputs": [{"text": "SSN {US_SOCIAL_SECURITY_NUMBER}"}], + "assessments": [ + { + "sensitiveInformationPolicy": { + "piiEntities": [{"type": "US_SOCIAL_SECURITY_NUMBER", "action": "ANONYMIZED"}] + } + } + ], + "usage": {"contentPolicyUnits": 1}, + } + return response + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response, blocked", + [ + pytest.param(_passing_bedrock_httpx_response("ok"), False, id="pass"), + pytest.param(_blocking_bedrock_httpx_response("deny"), True, id="block"), + pytest.param(_anonymizing_bedrock_httpx_response(), True, id="anonymize"), + ], +) +async def test_attachment_scan_sends_text_document_as_text(response, blocked): + guardrail = _attachment_guardrail() + post_patch, credentials_patch, prepare_patch = _patched_bedrock_post(guardrail, response) + + with post_patch as mock_post, credentials_patch, prepare_patch as mock_prepare: + if blocked: + with pytest.raises(HTTPException) as exc_info: + await guardrail.async_scan_request_attachments( + data=_text_document_messages_request("SSN 123-45-6789"), + call_type=CallTypes.anthropic_messages.value, + ) + assert exc_info.value.status_code == 400 + else: + result = await guardrail.async_scan_request_attachments( + data=_text_document_messages_request("SSN 123-45-6789"), + call_type=CallTypes.anthropic_messages.value, + ) + assert result is None + + assert mock_post.await_count == 1 + sent_body = mock_prepare.call_args.kwargs["data"] + assert sent_body["source"] == "INPUT" + assert sent_body["content"] == ({"text": {"text": "SSN 123-45-6789"}},) + + +class _CustomApplyGuardrail(BedrockGuardrail): + async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None): + return inputs + + +@pytest.mark.asyncio +async def test_attachment_scan_skipped_when_subclass_overrides_apply_guardrail(): + guardrail = _CustomApplyGuardrail( + guardrail_name="bedrock-attachments", guardrailIdentifier="gid", guardrailVersion="DRAFT" + ) + + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post: + pdf_result = await guardrail.async_scan_request_attachments( + data=_pdf_chat_request(), call_type=CallTypes.acompletion.value + ) + image_result = await guardrail.async_scan_request_attachments( + data=_image_only_chat_request(), call_type=CallTypes.acompletion.value + ) + + assert pdf_result is None + assert image_result is None + assert mock_post.await_count == 0 + + +@pytest.mark.asyncio +async def test_attachment_scan_skipped_when_instance_replaces_make_bedrock_api_request(): + guardrail = BedrockGuardrail( + guardrail_name="bedrock-attachments", guardrailIdentifier="gid", guardrailVersion="DRAFT" + ) + guardrail.make_bedrock_api_request = AsyncMock(return_value={"action": "NONE"}) + + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post: + pdf_result = await guardrail.async_scan_request_attachments( + data=_pdf_chat_request(), call_type=CallTypes.acompletion.value + ) + + assert pdf_result is None + assert mock_post.await_count == 0 + + +class _CustomRequestGuardrail(BedrockGuardrail): + async def make_bedrock_api_request(self, source, messages=None, response=None, request_data=None, **kwargs): + return {"action": "NONE"} + + +@pytest.mark.asyncio +async def test_attachment_scan_skipped_when_subclass_overrides_make_bedrock_api_request(): + guardrail = _CustomRequestGuardrail( + guardrail_name="bedrock-attachments", guardrailIdentifier="gid", guardrailVersion="DRAFT" + ) + + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock) as mock_post: + pdf_result = await guardrail.async_scan_request_attachments( + data=_pdf_chat_request(), call_type=CallTypes.acompletion.value + ) + image_result = await guardrail.async_scan_request_attachments( + data=_image_only_chat_request(), call_type=CallTypes.acompletion.value + ) + + assert pdf_result is None + assert image_result is None + assert mock_post.await_count == 0 + + + + @pytest.mark.asyncio async def test_bedrock_guardrails_streaming_request_body_mock(aws_test_credentials): """Test that the exact request body sent to Bedrock matches expected format when using streaming""" diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py b/tests/unit/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py index e797304a1c6..3efbe6475b2 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py @@ -49,6 +49,7 @@ from litellm.types.guardrails import GuardrailEventHooks from litellm.types.llms.openai import ResponsesAPIResponse from litellm.types.utils import ( CallTypes, + CallTypesLiteral, Delta, GenericGuardrailAPIInputs, ModelResponse, @@ -3011,3 +3012,44 @@ class TestTranslationMappingsAreReadLive: assert not [ name for name, value in vars(unified_module).items() if isinstance(value, dict) and CallTypes.aocr in value ] + + +class AttachmentScanningGuardrail(RecordingGuardrail): + """Records the raw request it is handed for an attachment scan.""" + + def __init__(self) -> None: + super().__init__() + self.attachment_scans: list[dict[str, object]] = [] + + async def async_scan_request_attachments(self, data: dict, call_type: CallTypesLiteral) -> None: + self.attachment_scans.append({"call_type": call_type, "apply_calls_so_far": len(self.apply_calls)}) + + +@pytest.mark.asyncio +async def test_pre_call_hook_scans_attachments_before_text_extraction(): + guardrail = AttachmentScanningGuardrail() + + await UnifiedLLMGuardrails().async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(api_key="test-key"), + cache=DualCache(), + data={"guardrail_to_apply": guardrail, "model": "gpt-4o", "input": "hello"}, + call_type=CallTypes.aresponses.value, + ) + + assert guardrail.attachment_scans == [{"call_type": CallTypes.aresponses.value, "apply_calls_so_far": 0}] + assert len(guardrail.apply_calls) == 1 + + +@pytest.mark.asyncio +async def test_pre_call_hook_skips_attachment_scan_when_guardrail_has_none(): + guardrail = RecordingGuardrail() + guardrail.async_scan_request_attachments = None # instance attribute, not a method on the class + + await UnifiedLLMGuardrails().async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(api_key="test-key"), + cache=DualCache(), + data={"guardrail_to_apply": guardrail, "model": "gpt-4o", "input": "hello"}, + call_type=CallTypes.aresponses.value, + ) + + assert len(guardrail.apply_calls) == 1 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 3f786a38765..f78cd23bc96 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26874,7 +26874,7 @@ export interface components { skip_tool_message_in_guardrail?: boolean | null; /** * Skip Unscannable Attachments - * @description Implemented by guardrail='model_armor'. When True, attachment references that carry no inline bytes (file_id, gs://, or http(s) URLs) pass through unscanned instead of blocking, while fail_on_error still governs real Model Armor API errors. Default False blocks them. + * @description Implemented by guardrail='model_armor' and guardrail='bedrock'. When True, attachments the guardrail cannot scan pass through unscanned instead of blocking. For Model Armor these are references with no inline bytes (file_id, gs://, or http(s) URLs), and fail_on_error still governs real Model Armor API errors. For Bedrock these are documents, files, audio, video, and images that are not inline PNG or JPEG up to 4 MB. Default False blocks them. * @default false */ skip_unscannable_attachments: boolean | null; @@ -36518,7 +36518,7 @@ export interface components { skip_tool_message_in_guardrail?: boolean | null; /** * Skip Unscannable Attachments - * @description Implemented by guardrail='model_armor'. When True, attachment references that carry no inline bytes (file_id, gs://, or http(s) URLs) pass through unscanned instead of blocking, while fail_on_error still governs real Model Armor API errors. Default False blocks them. + * @description Implemented by guardrail='model_armor' and guardrail='bedrock'. When True, attachments the guardrail cannot scan pass through unscanned instead of blocking. For Model Armor these are references with no inline bytes (file_id, gs://, or http(s) URLs), and fail_on_error still governs real Model Armor API errors. For Bedrock these are documents, files, audio, video, and images that are not inline PNG or JPEG up to 4 MB. Default False blocks them. * @default false */ skip_unscannable_attachments: boolean | null;