Skip to content

Commit fb837a4

Browse files
committed
chore: filter all ContentBlock fields in BedrockModel
1 parent 9213bc5 commit fb837a4

File tree

3 files changed

+209
-26
lines changed

3 files changed

+209
-26
lines changed

src/strands/models/bedrock.py

Lines changed: 68 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@
1717

1818
from ..event_loop import streaming
1919
from ..tools import convert_pydantic_to_tool_spec
20-
from ..types.content import ContentBlock, Message, Messages
20+
from ..types.content import ContentBlock, Message, Messages, _ContentBlockType
2121
from ..types.exceptions import (
2222
ContextWindowOverflowException,
2323
ModelThrottledException,
2424
)
2525
from ..types.streaming import CitationsDelta, StreamEvent
26-
from ..types.tools import ToolResult, ToolSpec
26+
from ..types.tools import ToolSpec
2727
from ._config_validation import validate_config_keys
2828
from .model import Model
2929

@@ -43,10 +43,57 @@
4343
"anthropic.claude",
4444
]
4545

46+
# Allowed fields for each Bedrock content block type to prevent validation exceptions
47+
# Bedrock strictly validates content blocks and throws exceptions for unknown fields
48+
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ContentBlock.html
49+
_BEDROCK_CONTENT_BLOCK_FIELDS: dict[_ContentBlockType, set[str]] = {
50+
"image": {
51+
"format",
52+
"source",
53+
}, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ImageBlock.html
54+
"toolResult": {
55+
"content",
56+
"toolUseId",
57+
"status",
58+
}, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolResultBlock.html
59+
"toolUse": {
60+
"input",
61+
"name",
62+
"toolUseId",
63+
}, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolUseBlock.html
64+
"document": {
65+
"name",
66+
"source",
67+
"citations",
68+
"context",
69+
"format",
70+
}, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_DocumentBlock.html
71+
"video": {
72+
"format",
73+
"source",
74+
}, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_VideoBlock.html
75+
"reasoningContent": {
76+
"reasoningText",
77+
"redactedContent",
78+
}, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ReasoningContentBlock.html
79+
"citationsContent": {
80+
"citations",
81+
"content",
82+
}, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CitationsContentBlock.html
83+
"cachePoint": {"type"}, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CachePointBlock.html
84+
"guardContent": {
85+
"image",
86+
"text",
87+
}, # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_GuardrailConverseContentBlock.html
88+
# Note: text is handled as a primitive (string)
89+
}
90+
_BEDROCK_CONTENT_BLOCK_TYPES: set[_ContentBlockType] = set(_BEDROCK_CONTENT_BLOCK_FIELDS.keys())
91+
4692
T = TypeVar("T", bound=BaseModel)
4793

4894
DEFAULT_READ_TIMEOUT = 120
4995

96+
5097
class BedrockModel(Model):
5198
"""AWS Bedrock model provider implementation.
5299
@@ -279,9 +326,7 @@ def _format_bedrock_messages(self, messages: Messages) -> Messages:
279326
280327
This function ensures messages conform to Bedrock's expected format by:
281328
- Filtering out SDK_UNKNOWN_MEMBER content blocks
282-
- Cleaning tool result content blocks by removing additional fields that may be
283-
useful for retaining information in hooks but would cause Bedrock validation
284-
exceptions when presented with unexpected fields
329+
- Eagerly filtering content blocks to only include Bedrock-supported fields
285330
- Ensuring all message content blocks are properly formatted for the Bedrock API
286331
287332
Args:
@@ -291,9 +336,11 @@ def _format_bedrock_messages(self, messages: Messages) -> Messages:
291336
Messages formatted for Bedrock API compatibility
292337
293338
Note:
294-
Bedrock will throw validation exceptions when presented with additional
295-
unexpected fields in tool result blocks.
296-
https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolResultBlock.html
339+
Unlike other APIs that ignore unknown fields, Bedrock only accepts a strict
340+
subset of fields for each content block type and throws validation exceptions
341+
when presented with unexpected fields. Therefore, we must eagerly filter all
342+
content blocks to remove any additional fields before sending to Bedrock.
343+
https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ContentBlock.html
297344
"""
298345
cleaned_messages = []
299346

@@ -315,30 +362,25 @@ def _format_bedrock_messages(self, messages: Messages) -> Messages:
315362
dropped_deepseek_reasoning_content = True
316363
continue
317364

318-
if "toolResult" in content_block:
319-
# Create a new content block with only the cleaned toolResult
320-
tool_result: ToolResult = content_block["toolResult"]
365+
# Clean content blocks that need field filtering for Bedrock API compatibility
366+
filterable_block_types = set(content_block.keys()) & _BEDROCK_CONTENT_BLOCK_TYPES
321367

322-
if self._should_include_tool_result_status():
323-
# Include status field
324-
cleaned_tool_result = ToolResult(
325-
content=tool_result["content"],
326-
toolUseId=tool_result["toolUseId"],
327-
status=tool_result["status"],
328-
)
329-
else:
330-
# Remove status field
331-
cleaned_tool_result = ToolResult( # type: ignore[typeddict-item]
332-
toolUseId=tool_result["toolUseId"], content=tool_result["content"]
333-
)
368+
if filterable_block_types:
369+
# Should only be one block type per content block since it is a discriminated union
370+
block_type = cast(_ContentBlockType, next(iter(filterable_block_types)))
371+
block_data = content_block[block_type]
372+
allowed_fields = _BEDROCK_CONTENT_BLOCK_FIELDS[block_type].copy()
373+
374+
if block_type == "toolResult" and not self._should_include_tool_result_status():
375+
allowed_fields.discard("status")
334376

335-
cleaned_block: ContentBlock = {"toolResult": cleaned_tool_result}
336-
cleaned_content.append(cleaned_block)
377+
cleaned_data = {k: v for k, v in block_data.items() if k in allowed_fields}
378+
cleaned_content.append(cast(ContentBlock, {block_type: cleaned_data}))
337379
else:
338380
# Keep other content blocks as-is
339381
cleaned_content.append(content_block)
340382

341-
# Create new message with cleaned content (skip if empty for DeepSeek)
383+
# Create new message with cleaned content (skip if empty)
342384
if cleaned_content:
343385
cleaned_message: Message = Message(content=cleaned_content, role=message["role"])
344386
cleaned_messages.append(cleaned_message)

src/strands/types/content.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@ class CachePoint(TypedDict):
7171
type: str
7272

7373

74+
# Private type for type-safe access to ContentBlock keys
75+
# Kept private to avoid backwards compatibility issues when adding new content block types
76+
# until a public use case is identified
77+
_ContentBlockType = Literal[
78+
"image",
79+
"toolResult",
80+
"toolUse",
81+
"document",
82+
"video",
83+
"reasoningContent",
84+
"citationsContent",
85+
"cachePoint",
86+
"guardContent",
87+
]
88+
89+
7490
class ContentBlock(TypedDict, total=False):
7591
"""A block of content for a message that you pass to, or receive from, a model.
7692

tests/strands/models/test_bedrock.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1461,6 +1461,131 @@ async def test_stream_deepseek_skips_empty_messages(bedrock_client, alist):
14611461
assert sent_messages[1]["content"] == [{"text": "Follow up"}]
14621462

14631463

1464+
def test_format_request_filters_image_content_blocks(model, model_id):
1465+
"""Test that format_request filters extra fields from image content blocks."""
1466+
messages = [
1467+
{
1468+
"role": "user",
1469+
"content": [
1470+
{
1471+
"image": {
1472+
"format": "png",
1473+
"source": {"bytes": b"image_data"},
1474+
"filename": "test.png", # Extra field that should be filtered
1475+
"metadata": {"size": 1024}, # Extra field that should be filtered
1476+
}
1477+
},
1478+
],
1479+
}
1480+
]
1481+
1482+
formatted_request = model.format_request(messages)
1483+
1484+
image_block = formatted_request["messages"][0]["content"][0]["image"]
1485+
expected = {"format": "png", "source": {"bytes": b"image_data"}}
1486+
assert image_block == expected
1487+
assert "filename" not in image_block
1488+
assert "metadata" not in image_block
1489+
1490+
1491+
def test_format_request_filters_document_content_blocks(model, model_id):
1492+
"""Test that format_request filters extra fields from document content blocks."""
1493+
messages = [
1494+
{
1495+
"role": "user",
1496+
"content": [
1497+
{
1498+
"document": {
1499+
"name": "test.pdf",
1500+
"source": {"bytes": b"pdf_data"},
1501+
"format": "pdf",
1502+
"extraField": "should be removed",
1503+
"metadata": {"pages": 10},
1504+
}
1505+
},
1506+
],
1507+
}
1508+
]
1509+
1510+
formatted_request = model.format_request(messages)
1511+
1512+
document_block = formatted_request["messages"][0]["content"][0]["document"]
1513+
expected = {"name": "test.pdf", "source": {"bytes": b"pdf_data"}, "format": "pdf"}
1514+
assert document_block == expected
1515+
assert "extraField" not in document_block
1516+
assert "metadata" not in document_block
1517+
1518+
1519+
def test_format_request_filters_video_content_blocks(model, model_id):
1520+
"""Test that format_request filters extra fields from video content blocks."""
1521+
messages = [
1522+
{
1523+
"role": "user",
1524+
"content": [
1525+
{
1526+
"video": {
1527+
"format": "mp4",
1528+
"source": {"bytes": b"video_data"},
1529+
"duration": 120, # Extra field that should be filtered
1530+
"resolution": "1080p", # Extra field that should be filtered
1531+
}
1532+
},
1533+
],
1534+
}
1535+
]
1536+
1537+
formatted_request = model.format_request(messages)
1538+
1539+
video_block = formatted_request["messages"][0]["content"][0]["video"]
1540+
expected = {"format": "mp4", "source": {"bytes": b"video_data"}}
1541+
assert video_block == expected
1542+
assert "duration" not in video_block
1543+
assert "resolution" not in video_block
1544+
1545+
1546+
def test_format_request_filters_cache_point_content_blocks(model, model_id):
1547+
"""Test that format_request filters extra fields from cachePoint content blocks."""
1548+
messages = [
1549+
{
1550+
"role": "user",
1551+
"content": [
1552+
{
1553+
"cachePoint": {
1554+
"type": "default",
1555+
"extraField": "should be removed",
1556+
}
1557+
},
1558+
],
1559+
}
1560+
]
1561+
1562+
formatted_request = model.format_request(messages)
1563+
1564+
cache_point_block = formatted_request["messages"][0]["content"][0]["cachePoint"]
1565+
expected = {"type": "default"}
1566+
assert cache_point_block == expected
1567+
assert "extraField" not in cache_point_block
1568+
1569+
1570+
def test_format_request_preserves_unknown_content_blocks(model, model_id):
1571+
"""Test that format_request preserves content blocks that don't need filtering."""
1572+
messages = [
1573+
{
1574+
"role": "user",
1575+
"content": [
1576+
{"text": "Hello world"},
1577+
{"unknownBlock": {"data": "preserved", "extra": "also preserved"}},
1578+
],
1579+
}
1580+
]
1581+
1582+
formatted_request = model.format_request(messages)
1583+
1584+
content = formatted_request["messages"][0]["content"]
1585+
assert content[0] == {"text": "Hello world"}
1586+
assert content[1] == {"unknownBlock": {"data": "preserved", "extra": "also preserved"}}
1587+
1588+
14641589
def test_config_validation_warns_on_unknown_keys(bedrock_client, captured_warnings):
14651590
"""Test that unknown config keys emit a warning."""
14661591
BedrockModel(model_id="test-model", invalid_param="test")

0 commit comments

Comments
 (0)