From fb837a490ae851efd080c2c1e86d5d4ca0a2fd80 Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Wed, 10 Sep 2025 13:00:44 -0400 Subject: [PATCH 1/4] chore: filter all ContentBlock fields in BedrockModel --- src/strands/models/bedrock.py | 94 ++++++++++++++------ src/strands/types/content.py | 16 ++++ tests/strands/models/test_bedrock.py | 125 +++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 26 deletions(-) diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index 8909072f6..cc43db14b 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -17,13 +17,13 @@ from ..event_loop import streaming from ..tools import convert_pydantic_to_tool_spec -from ..types.content import ContentBlock, Message, Messages +from ..types.content import ContentBlock, Message, Messages, _ContentBlockType from ..types.exceptions import ( ContextWindowOverflowException, ModelThrottledException, ) from ..types.streaming import CitationsDelta, StreamEvent -from ..types.tools import ToolResult, ToolSpec +from ..types.tools import ToolSpec from ._config_validation import validate_config_keys from .model import Model @@ -43,10 +43,57 @@ "anthropic.claude", ] +# Allowed fields for each Bedrock content block type to prevent validation exceptions +# Bedrock strictly validates content blocks and throws exceptions for unknown fields +# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ContentBlock.html +_BEDROCK_CONTENT_BLOCK_FIELDS: dict[_ContentBlockType, set[str]] = { + "image": { + "format", + "source", + }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ImageBlock.html + "toolResult": { + "content", + "toolUseId", + "status", + }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolResultBlock.html + "toolUse": { + "input", + "name", + "toolUseId", + }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolUseBlock.html + "document": { + "name", + "source", + "citations", + "context", + "format", + }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_DocumentBlock.html + "video": { + "format", + "source", + }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_VideoBlock.html + "reasoningContent": { + "reasoningText", + "redactedContent", + }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ReasoningContentBlock.html + "citationsContent": { + "citations", + "content", + }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CitationsContentBlock.html + "cachePoint": {"type"}, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CachePointBlock.html + "guardContent": { + "image", + "text", + }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_GuardrailConverseContentBlock.html + # Note: text is handled as a primitive (string) +} +_BEDROCK_CONTENT_BLOCK_TYPES: set[_ContentBlockType] = set(_BEDROCK_CONTENT_BLOCK_FIELDS.keys()) + T = TypeVar("T", bound=BaseModel) DEFAULT_READ_TIMEOUT = 120 + class BedrockModel(Model): """AWS Bedrock model provider implementation. @@ -279,9 +326,7 @@ def _format_bedrock_messages(self, messages: Messages) -> Messages: This function ensures messages conform to Bedrock's expected format by: - Filtering out SDK_UNKNOWN_MEMBER content blocks - - Cleaning tool result content blocks by removing additional fields that may be - useful for retaining information in hooks but would cause Bedrock validation - exceptions when presented with unexpected fields + - Eagerly filtering content blocks to only include Bedrock-supported fields - Ensuring all message content blocks are properly formatted for the Bedrock API Args: @@ -291,9 +336,11 @@ def _format_bedrock_messages(self, messages: Messages) -> Messages: Messages formatted for Bedrock API compatibility Note: - Bedrock will throw validation exceptions when presented with additional - unexpected fields in tool result blocks. - https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolResultBlock.html + Unlike other APIs that ignore unknown fields, Bedrock only accepts a strict + subset of fields for each content block type and throws validation exceptions + when presented with unexpected fields. Therefore, we must eagerly filter all + content blocks to remove any additional fields before sending to Bedrock. + https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ContentBlock.html """ cleaned_messages = [] @@ -315,30 +362,25 @@ def _format_bedrock_messages(self, messages: Messages) -> Messages: dropped_deepseek_reasoning_content = True continue - if "toolResult" in content_block: - # Create a new content block with only the cleaned toolResult - tool_result: ToolResult = content_block["toolResult"] + # Clean content blocks that need field filtering for Bedrock API compatibility + filterable_block_types = set(content_block.keys()) & _BEDROCK_CONTENT_BLOCK_TYPES - if self._should_include_tool_result_status(): - # Include status field - cleaned_tool_result = ToolResult( - content=tool_result["content"], - toolUseId=tool_result["toolUseId"], - status=tool_result["status"], - ) - else: - # Remove status field - cleaned_tool_result = ToolResult( # type: ignore[typeddict-item] - toolUseId=tool_result["toolUseId"], content=tool_result["content"] - ) + if filterable_block_types: + # Should only be one block type per content block since it is a discriminated union + block_type = cast(_ContentBlockType, next(iter(filterable_block_types))) + block_data = content_block[block_type] + allowed_fields = _BEDROCK_CONTENT_BLOCK_FIELDS[block_type].copy() + + if block_type == "toolResult" and not self._should_include_tool_result_status(): + allowed_fields.discard("status") - cleaned_block: ContentBlock = {"toolResult": cleaned_tool_result} - cleaned_content.append(cleaned_block) + cleaned_data = {k: v for k, v in block_data.items() if k in allowed_fields} + cleaned_content.append(cast(ContentBlock, {block_type: cleaned_data})) else: # Keep other content blocks as-is cleaned_content.append(content_block) - # Create new message with cleaned content (skip if empty for DeepSeek) + # Create new message with cleaned content (skip if empty) if cleaned_content: cleaned_message: Message = Message(content=cleaned_content, role=message["role"]) cleaned_messages.append(cleaned_message) diff --git a/src/strands/types/content.py b/src/strands/types/content.py index c3eddca4d..9d3013d96 100644 --- a/src/strands/types/content.py +++ b/src/strands/types/content.py @@ -71,6 +71,22 @@ class CachePoint(TypedDict): type: str +# Private type for type-safe access to ContentBlock keys +# Kept private to avoid backwards compatibility issues when adding new content block types +# until a public use case is identified +_ContentBlockType = Literal[ + "image", + "toolResult", + "toolUse", + "document", + "video", + "reasoningContent", + "citationsContent", + "cachePoint", + "guardContent", +] + + class ContentBlock(TypedDict, total=False): """A block of content for a message that you pass to, or receive from, a model. diff --git a/tests/strands/models/test_bedrock.py b/tests/strands/models/test_bedrock.py index 5e4c20e79..32573b200 100644 --- a/tests/strands/models/test_bedrock.py +++ b/tests/strands/models/test_bedrock.py @@ -1461,6 +1461,131 @@ async def test_stream_deepseek_skips_empty_messages(bedrock_client, alist): assert sent_messages[1]["content"] == [{"text": "Follow up"}] +def test_format_request_filters_image_content_blocks(model, model_id): + """Test that format_request filters extra fields from image content blocks.""" + messages = [ + { + "role": "user", + "content": [ + { + "image": { + "format": "png", + "source": {"bytes": b"image_data"}, + "filename": "test.png", # Extra field that should be filtered + "metadata": {"size": 1024}, # Extra field that should be filtered + } + }, + ], + } + ] + + formatted_request = model.format_request(messages) + + image_block = formatted_request["messages"][0]["content"][0]["image"] + expected = {"format": "png", "source": {"bytes": b"image_data"}} + assert image_block == expected + assert "filename" not in image_block + assert "metadata" not in image_block + + +def test_format_request_filters_document_content_blocks(model, model_id): + """Test that format_request filters extra fields from document content blocks.""" + messages = [ + { + "role": "user", + "content": [ + { + "document": { + "name": "test.pdf", + "source": {"bytes": b"pdf_data"}, + "format": "pdf", + "extraField": "should be removed", + "metadata": {"pages": 10}, + } + }, + ], + } + ] + + formatted_request = model.format_request(messages) + + document_block = formatted_request["messages"][0]["content"][0]["document"] + expected = {"name": "test.pdf", "source": {"bytes": b"pdf_data"}, "format": "pdf"} + assert document_block == expected + assert "extraField" not in document_block + assert "metadata" not in document_block + + +def test_format_request_filters_video_content_blocks(model, model_id): + """Test that format_request filters extra fields from video content blocks.""" + messages = [ + { + "role": "user", + "content": [ + { + "video": { + "format": "mp4", + "source": {"bytes": b"video_data"}, + "duration": 120, # Extra field that should be filtered + "resolution": "1080p", # Extra field that should be filtered + } + }, + ], + } + ] + + formatted_request = model.format_request(messages) + + video_block = formatted_request["messages"][0]["content"][0]["video"] + expected = {"format": "mp4", "source": {"bytes": b"video_data"}} + assert video_block == expected + assert "duration" not in video_block + assert "resolution" not in video_block + + +def test_format_request_filters_cache_point_content_blocks(model, model_id): + """Test that format_request filters extra fields from cachePoint content blocks.""" + messages = [ + { + "role": "user", + "content": [ + { + "cachePoint": { + "type": "default", + "extraField": "should be removed", + } + }, + ], + } + ] + + formatted_request = model.format_request(messages) + + cache_point_block = formatted_request["messages"][0]["content"][0]["cachePoint"] + expected = {"type": "default"} + assert cache_point_block == expected + assert "extraField" not in cache_point_block + + +def test_format_request_preserves_unknown_content_blocks(model, model_id): + """Test that format_request preserves content blocks that don't need filtering.""" + messages = [ + { + "role": "user", + "content": [ + {"text": "Hello world"}, + {"unknownBlock": {"data": "preserved", "extra": "also preserved"}}, + ], + } + ] + + formatted_request = model.format_request(messages) + + content = formatted_request["messages"][0]["content"] + assert content[0] == {"text": "Hello world"} + assert content[1] == {"unknownBlock": {"data": "preserved", "extra": "also preserved"}} + + def test_config_validation_warns_on_unknown_keys(bedrock_client, captured_warnings): """Test that unknown config keys emit a warning.""" BedrockModel(model_id="test-model", invalid_param="test") From ae1175e395915d746973f626ad39339983accad0 Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Fri, 12 Sep 2025 11:12:57 -0400 Subject: [PATCH 2/4] nested property support --- src/strands/models/bedrock.py | 68 ++++++++++++++++++++++++++-- tests/strands/models/test_bedrock.py | 43 ++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index cc43db14b..28eb1db8e 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -89,6 +89,37 @@ } _BEDROCK_CONTENT_BLOCK_TYPES: set[_ContentBlockType] = set(_BEDROCK_CONTENT_BLOCK_FIELDS.keys()) +# Nested schemas for deep filtering of Bedrock content blocks +_BEDROCK_CONTENT_BLOCK_SCHEMAS: dict[_ContentBlockType, dict[str, Any]] = { + "image": { + "format": True, + "source": {"bytes": True, "s3Location": {"bucket": True, "key": True, "region": True, "version": True}}, + }, + "toolResult": {"content": True, "toolUseId": True, "status": True}, + "toolUse": {"input": True, "name": True, "toolUseId": True}, + "document": { + "name": True, + "source": {"bytes": True, "s3Location": {"bucket": True, "key": True, "region": True, "version": True}}, + "format": True, + "citations": True, + "context": True, + }, + "video": { + "format": True, + "source": {"bytes": True, "s3Location": {"bucket": True, "key": True, "region": True, "version": True}}, + }, + "reasoningContent": {"reasoningText": {"text": True, "signature": True}, "redactedContent": True}, + "citationsContent": {"citations": True, "content": True}, + "cachePoint": {"type": True}, + "guardContent": { + "image": { + "format": True, + "source": {"bytes": True, "s3Location": {"bucket": True, "key": True, "region": True, "version": True}}, + }, + "text": {"qualifiers": True, "text": True}, + }, +} + T = TypeVar("T", bound=BaseModel) DEFAULT_READ_TIMEOUT = 120 @@ -369,12 +400,12 @@ def _format_bedrock_messages(self, messages: Messages) -> Messages: # Should only be one block type per content block since it is a discriminated union block_type = cast(_ContentBlockType, next(iter(filterable_block_types))) block_data = content_block[block_type] - allowed_fields = _BEDROCK_CONTENT_BLOCK_FIELDS[block_type].copy() + schema = _BEDROCK_CONTENT_BLOCK_SCHEMAS[block_type].copy() if block_type == "toolResult" and not self._should_include_tool_result_status(): - allowed_fields.discard("status") + schema.pop("status", None) - cleaned_data = {k: v for k, v in block_data.items() if k in allowed_fields} + cleaned_data = _deep_filter(block_data, schema) cleaned_content.append(cast(ContentBlock, {block_type: cleaned_data})) else: # Keep other content blocks as-is @@ -805,3 +836,34 @@ async def structured_output( raise ValueError("No valid tool use or tool use input was found in the Bedrock response.") yield {"output": output_model(**output_response)} + + +def _deep_filter(data: Union[dict[str, Any], Any], schema: dict[str, Any]) -> dict[str, Any]: + """Fast recursive filtering using nested dict schemas. + + Args: + data: Input data to filter (content block or nested dict) + schema: Schema defining allowed fields and nested structure + + Returns: + Filtered dictionary containing only schema-defined fields + """ + if not isinstance(data, dict): + return {} + + result = {} + for key in data.keys() & schema.keys(): + value = data[key] + schema_spec = schema[key] + + if schema_spec is True: + result[key] = value + elif isinstance(schema_spec, dict) and isinstance(value, dict): + filtered = _deep_filter(value, schema_spec) + if filtered: + result[key] = filtered + elif isinstance(schema_spec, dict) and isinstance(value, list): + result[key] = [_deep_filter(item, schema_spec) for item in value if isinstance(item, dict)] + else: + result[key] = value + return result diff --git a/tests/strands/models/test_bedrock.py b/tests/strands/models/test_bedrock.py index 32573b200..b79b59b24 100644 --- a/tests/strands/models/test_bedrock.py +++ b/tests/strands/models/test_bedrock.py @@ -1488,6 +1488,28 @@ def test_format_request_filters_image_content_blocks(model, model_id): assert "metadata" not in image_block +def test_format_request_filters_nested_image_s3_fields(model, model_id): + """Test deep filtering of nested s3Location fields in image blocks.""" + messages = [ + { + "role": "user", + "content": [ + { + "image": { + "format": "png", + "source": {"s3Location": {"bucket": "my-bucket", "key": "image.png", "extraField": "filtered"}}, + } + } + ], + } + ] + + formatted_request = model.format_request(messages) + s3_location = formatted_request["messages"][0]["content"][0]["image"]["source"]["s3Location"] + + assert s3_location == {"bucket": "my-bucket", "key": "image.png"} + + def test_format_request_filters_document_content_blocks(model, model_id): """Test that format_request filters extra fields from document content blocks.""" messages = [ @@ -1516,6 +1538,27 @@ def test_format_request_filters_document_content_blocks(model, model_id): assert "metadata" not in document_block +def test_format_request_filters_nested_reasoning_content(model, model_id): + """Test deep filtering of nested reasoningText fields.""" + messages = [ + { + "role": "assistant", + "content": [ + { + "reasoningContent": { + "reasoningText": {"text": "thinking...", "signature": "abc123", "extraField": "filtered"} + } + } + ], + } + ] + + formatted_request = model.format_request(messages) + reasoning_text = formatted_request["messages"][0]["content"][0]["reasoningContent"]["reasoningText"] + + assert reasoning_text == {"text": "thinking...", "signature": "abc123"} + + def test_format_request_filters_video_content_blocks(model, model_id): """Test that format_request filters extra fields from video content blocks.""" messages = [ From 83b61570604bfdddbaaeac25663bd218ce2ee4ee Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Mon, 15 Sep 2025 00:22:12 -0400 Subject: [PATCH 3/4] fix: move to complete mapping like other providers --- src/strands/models/bedrock.py | 328 +++++++++++++++------------ src/strands/types/content.py | 16 -- tests/strands/models/test_bedrock.py | 31 +-- 3 files changed, 194 insertions(+), 181 deletions(-) diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index 28eb1db8e..1016cbc4c 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -17,7 +17,7 @@ from ..event_loop import streaming from ..tools import convert_pydantic_to_tool_spec -from ..types.content import ContentBlock, Message, Messages, _ContentBlockType +from ..types.content import ContentBlock, Messages from ..types.exceptions import ( ContextWindowOverflowException, ModelThrottledException, @@ -43,83 +43,6 @@ "anthropic.claude", ] -# Allowed fields for each Bedrock content block type to prevent validation exceptions -# Bedrock strictly validates content blocks and throws exceptions for unknown fields -# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ContentBlock.html -_BEDROCK_CONTENT_BLOCK_FIELDS: dict[_ContentBlockType, set[str]] = { - "image": { - "format", - "source", - }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ImageBlock.html - "toolResult": { - "content", - "toolUseId", - "status", - }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolResultBlock.html - "toolUse": { - "input", - "name", - "toolUseId", - }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolUseBlock.html - "document": { - "name", - "source", - "citations", - "context", - "format", - }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_DocumentBlock.html - "video": { - "format", - "source", - }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_VideoBlock.html - "reasoningContent": { - "reasoningText", - "redactedContent", - }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ReasoningContentBlock.html - "citationsContent": { - "citations", - "content", - }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CitationsContentBlock.html - "cachePoint": {"type"}, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CachePointBlock.html - "guardContent": { - "image", - "text", - }, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_GuardrailConverseContentBlock.html - # Note: text is handled as a primitive (string) -} -_BEDROCK_CONTENT_BLOCK_TYPES: set[_ContentBlockType] = set(_BEDROCK_CONTENT_BLOCK_FIELDS.keys()) - -# Nested schemas for deep filtering of Bedrock content blocks -_BEDROCK_CONTENT_BLOCK_SCHEMAS: dict[_ContentBlockType, dict[str, Any]] = { - "image": { - "format": True, - "source": {"bytes": True, "s3Location": {"bucket": True, "key": True, "region": True, "version": True}}, - }, - "toolResult": {"content": True, "toolUseId": True, "status": True}, - "toolUse": {"input": True, "name": True, "toolUseId": True}, - "document": { - "name": True, - "source": {"bytes": True, "s3Location": {"bucket": True, "key": True, "region": True, "version": True}}, - "format": True, - "citations": True, - "context": True, - }, - "video": { - "format": True, - "source": {"bytes": True, "s3Location": {"bucket": True, "key": True, "region": True, "version": True}}, - }, - "reasoningContent": {"reasoningText": {"text": True, "signature": True}, "redactedContent": True}, - "citationsContent": {"citations": True, "content": True}, - "cachePoint": {"type": True}, - "guardContent": { - "image": { - "format": True, - "source": {"bytes": True, "s3Location": {"bucket": True, "key": True, "region": True, "version": True}}, - }, - "text": {"qualifiers": True, "text": True}, - }, -} - T = TypeVar("T", bound=BaseModel) DEFAULT_READ_TIMEOUT = 120 @@ -258,17 +181,6 @@ def get_config(self) -> BedrockConfig: """ return self.config - def _should_include_tool_result_status(self) -> bool: - """Determine whether to include tool result status based on current config.""" - include_status = self.config.get("include_tool_result_status", "auto") - - if include_status is True: - return True - elif include_status is False: - return False - else: # "auto" - return any(model in self.config["model_id"] for model in _MODELS_INCLUDE_STATUS) - def format_request( self, messages: Messages, @@ -352,7 +264,7 @@ def format_request( ), } - def _format_bedrock_messages(self, messages: Messages) -> Messages: + def _format_bedrock_messages(self, messages: Messages) -> list[dict[str, Any]]: """Format messages for Bedrock API compatibility. This function ensures messages conform to Bedrock's expected format by: @@ -373,13 +285,13 @@ def _format_bedrock_messages(self, messages: Messages) -> Messages: content blocks to remove any additional fields before sending to Bedrock. https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ContentBlock.html """ - cleaned_messages = [] + cleaned_messages: list[dict[str, Any]] = [] filtered_unknown_members = False dropped_deepseek_reasoning_content = False for message in messages: - cleaned_content: list[ContentBlock] = [] + cleaned_content: list[dict[str, Any]] = [] for content_block in message["content"]: # Filter out SDK_UNKNOWN_MEMBER content blocks @@ -393,28 +305,13 @@ def _format_bedrock_messages(self, messages: Messages) -> Messages: dropped_deepseek_reasoning_content = True continue - # Clean content blocks that need field filtering for Bedrock API compatibility - filterable_block_types = set(content_block.keys()) & _BEDROCK_CONTENT_BLOCK_TYPES - - if filterable_block_types: - # Should only be one block type per content block since it is a discriminated union - block_type = cast(_ContentBlockType, next(iter(filterable_block_types))) - block_data = content_block[block_type] - schema = _BEDROCK_CONTENT_BLOCK_SCHEMAS[block_type].copy() - - if block_type == "toolResult" and not self._should_include_tool_result_status(): - schema.pop("status", None) - - cleaned_data = _deep_filter(block_data, schema) - cleaned_content.append(cast(ContentBlock, {block_type: cleaned_data})) - else: - # Keep other content blocks as-is - cleaned_content.append(content_block) + # Format content blocks for Bedrock API compatibility + formatted_content = self._format_request_message_content(content_block) + cleaned_content.append(formatted_content) # Create new message with cleaned content (skip if empty) if cleaned_content: - cleaned_message: Message = Message(content=cleaned_content, role=message["role"]) - cleaned_messages.append(cleaned_message) + cleaned_messages.append({"content": cleaned_content, "role": message["role"]}) if filtered_unknown_members: logger.warning( @@ -427,6 +324,184 @@ def _format_bedrock_messages(self, messages: Messages) -> Messages: return cleaned_messages + def _should_include_tool_result_status(self) -> bool: + """Determine whether to include tool result status based on current config.""" + include_status = self.config.get("include_tool_result_status", "auto") + + if include_status is True: + return True + elif include_status is False: + return False + else: # "auto" + return any(model in self.config["model_id"] for model in _MODELS_INCLUDE_STATUS) + + def _format_request_message_content(self, content: ContentBlock) -> dict[str, Any]: + """Format a Bedrock content block. + + Bedrock strictly validates content blocks and throws exceptions for unknown fields. + This function extracts only the fields that Bedrock supports for each content type. + + Args: + content: Content block to format. + + Returns: + Bedrock formatted content block. + + Raises: + TypeError: If the content block type is not supported by Bedrock. + """ + # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CachePointBlock.html + if "cachePoint" in content: + return {"cachePoint": {"type": content["cachePoint"]["type"]}} + + # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_DocumentBlock.html + if "document" in content: + document = content["document"] + result: dict[str, Any] = {} + + # Handle required fields (all optional due to total=False) + if "name" in document: + result["name"] = document["name"] + if "format" in document: + result["format"] = document["format"] + + # Handle source + if "source" in document: + result["source"] = {"bytes": document["source"]["bytes"]} + + # Handle optional fields + if "citations" in document and document["citations"] is not None: + result["citations"] = {"enabled": document["citations"]["enabled"]} + if "context" in document: + result["context"] = document["context"] + + return {"document": result} + + # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_GuardrailConverseContentBlock.html + if "guardContent" in content: + guard = content["guardContent"] + guard_text = guard["text"] + result = {"text": {"text": guard_text["text"], "qualifiers": guard_text["qualifiers"]}} + return {"guardContent": result} + + # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ImageBlock.html + if "image" in content: + image = content["image"] + source = image["source"] + formatted_source = {} + if "bytes" in source: + formatted_source = {"bytes": source["bytes"]} + result = {"format": image["format"], "source": formatted_source} + return {"image": result} + + # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ReasoningContentBlock.html + if "reasoningContent" in content: + reasoning = content["reasoningContent"] + result = {} + + if "reasoningText" in reasoning: + reasoning_text = reasoning["reasoningText"] + result["reasoningText"] = {} + if "text" in reasoning_text: + result["reasoningText"]["text"] = reasoning_text["text"] + # Only include signature if truthy (avoid empty strings) + if reasoning_text.get("signature"): + result["reasoningText"]["signature"] = reasoning_text["signature"] + + if "redactedContent" in reasoning: + result["redactedContent"] = reasoning["redactedContent"] + + return {"reasoningContent": result} + + # Pass through text and other simple content types + if "text" in content: + return {"text": content["text"]} + + # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolResultBlock.html + if "toolResult" in content: + tool_result = content["toolResult"] + formatted_content: list[dict[str, Any]] = [] + for tool_result_content in tool_result["content"]: + if "json" in tool_result_content: + # Handle json field since not in ContentBlock but valid in ToolResultContent + formatted_content.append({"json": tool_result_content["json"]}) + else: + formatted_content.append( + self._format_request_message_content(cast(ContentBlock, tool_result_content)) + ) + + result = { + "content": formatted_content, + "toolUseId": tool_result["toolUseId"], + } + if "status" in tool_result and self._should_include_tool_result_status(): + result["status"] = tool_result["status"] + return {"toolResult": result} + + # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolUseBlock.html + if "toolUse" in content: + tool_use = content["toolUse"] + return { + "toolUse": { + "input": tool_use["input"], + "name": tool_use["name"], + "toolUseId": tool_use["toolUseId"], + } + } + + # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_VideoBlock.html + if "video" in content: + video = content["video"] + source = video["source"] + formatted_source = {} + if "bytes" in source: + formatted_source = {"bytes": source["bytes"]} + result = {"format": video["format"], "source": formatted_source} + return {"video": result} + + # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CitationsContentBlock.html + if "citationsContent" in content: + citations = content["citationsContent"] + result = {} + + if "citations" in citations: + result["citations"] = [] + for citation in citations["citations"]: + filtered_citation: dict[str, Any] = {} + if "location" in citation: + location = citation["location"] + filtered_location = {} + # Filter location fields to only include Bedrock-supported ones + if "documentIndex" in location: + filtered_location["documentIndex"] = location["documentIndex"] + if "start" in location: + filtered_location["start"] = location["start"] + if "end" in location: + filtered_location["end"] = location["end"] + filtered_citation["location"] = filtered_location + if "sourceContent" in citation: + filtered_source_content: list[dict[str, Any]] = [] + for source_content in citation["sourceContent"]: + if "text" in source_content: + filtered_source_content.append({"text": source_content["text"]}) + if filtered_source_content: + filtered_citation["sourceContent"] = filtered_source_content + if "title" in citation: + filtered_citation["title"] = citation["title"] + result["citations"].append(filtered_citation) + + if "content" in citations: + filtered_content: list[dict[str, Any]] = [] + for generated_content in citations["content"]: + if "text" in generated_content: + filtered_content.append({"text": generated_content["text"]}) + if filtered_content: + result["content"] = filtered_content + + return {"citationsContent": result} + + raise TypeError(f"content_type=<{next(iter(content))}> | unsupported type") + def _has_blocked_guardrail(self, guardrail_data: dict[str, Any]) -> bool: """Check if guardrail data contains any blocked policies. @@ -836,34 +911,3 @@ async def structured_output( raise ValueError("No valid tool use or tool use input was found in the Bedrock response.") yield {"output": output_model(**output_response)} - - -def _deep_filter(data: Union[dict[str, Any], Any], schema: dict[str, Any]) -> dict[str, Any]: - """Fast recursive filtering using nested dict schemas. - - Args: - data: Input data to filter (content block or nested dict) - schema: Schema defining allowed fields and nested structure - - Returns: - Filtered dictionary containing only schema-defined fields - """ - if not isinstance(data, dict): - return {} - - result = {} - for key in data.keys() & schema.keys(): - value = data[key] - schema_spec = schema[key] - - if schema_spec is True: - result[key] = value - elif isinstance(schema_spec, dict) and isinstance(value, dict): - filtered = _deep_filter(value, schema_spec) - if filtered: - result[key] = filtered - elif isinstance(schema_spec, dict) and isinstance(value, list): - result[key] = [_deep_filter(item, schema_spec) for item in value if isinstance(item, dict)] - else: - result[key] = value - return result diff --git a/src/strands/types/content.py b/src/strands/types/content.py index 9d3013d96..c3eddca4d 100644 --- a/src/strands/types/content.py +++ b/src/strands/types/content.py @@ -71,22 +71,6 @@ class CachePoint(TypedDict): type: str -# Private type for type-safe access to ContentBlock keys -# Kept private to avoid backwards compatibility issues when adding new content block types -# until a public use case is identified -_ContentBlockType = Literal[ - "image", - "toolResult", - "toolUse", - "document", - "video", - "reasoningContent", - "citationsContent", - "cachePoint", - "guardContent", -] - - class ContentBlock(TypedDict, total=False): """A block of content for a message that you pass to, or receive from, a model. diff --git a/tests/strands/models/test_bedrock.py b/tests/strands/models/test_bedrock.py index b79b59b24..c1cefa60e 100644 --- a/tests/strands/models/test_bedrock.py +++ b/tests/strands/models/test_bedrock.py @@ -1489,7 +1489,7 @@ def test_format_request_filters_image_content_blocks(model, model_id): def test_format_request_filters_nested_image_s3_fields(model, model_id): - """Test deep filtering of nested s3Location fields in image blocks.""" + """Test that s3Location is filtered out and only bytes source is preserved.""" messages = [ { "role": "user", @@ -1497,7 +1497,10 @@ def test_format_request_filters_nested_image_s3_fields(model, model_id): { "image": { "format": "png", - "source": {"s3Location": {"bucket": "my-bucket", "key": "image.png", "extraField": "filtered"}}, + "source": { + "bytes": b"image_data", + "s3Location": {"bucket": "my-bucket", "key": "image.png", "extraField": "filtered"}, + }, } } ], @@ -1505,9 +1508,10 @@ def test_format_request_filters_nested_image_s3_fields(model, model_id): ] formatted_request = model.format_request(messages) - s3_location = formatted_request["messages"][0]["content"][0]["image"]["source"]["s3Location"] + image_source = formatted_request["messages"][0]["content"][0]["image"]["source"] - assert s3_location == {"bucket": "my-bucket", "key": "image.png"} + assert image_source == {"bytes": b"image_data"} + assert "s3Location" not in image_source def test_format_request_filters_document_content_blocks(model, model_id): @@ -1610,25 +1614,6 @@ def test_format_request_filters_cache_point_content_blocks(model, model_id): assert "extraField" not in cache_point_block -def test_format_request_preserves_unknown_content_blocks(model, model_id): - """Test that format_request preserves content blocks that don't need filtering.""" - messages = [ - { - "role": "user", - "content": [ - {"text": "Hello world"}, - {"unknownBlock": {"data": "preserved", "extra": "also preserved"}}, - ], - } - ] - - formatted_request = model.format_request(messages) - - content = formatted_request["messages"][0]["content"] - assert content[0] == {"text": "Hello world"} - assert content[1] == {"unknownBlock": {"data": "preserved", "extra": "also preserved"}} - - def test_config_validation_warns_on_unknown_keys(bedrock_client, captured_warnings): """Test that unknown config keys emit a warning.""" BedrockModel(model_id="test-model", invalid_param="test") From dfd66b60b79c30f97ba28f6bea3b585a2a5a9dbf Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Mon, 15 Sep 2025 16:49:26 -0400 Subject: [PATCH 4/4] Update bedrock.py --- src/strands/models/bedrock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index 41a89b92f..8c9716a4f 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -24,7 +24,7 @@ ModelThrottledException, ) from ..types.streaming import CitationsDelta, StreamEvent -from ..types.tools import ToolChoice, ToolResult, ToolSpec +from ..types.tools import ToolChoice, ToolSpec from ._validation import validate_config_keys from .model import Model