Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions sentry_sdk/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,20 +471,44 @@

class TextPart(TypedDict):
type: Literal["text"]
content: str
content: NotRequired[str]

class ReasoningPart(TypedDict):
type: Literal["reasoning"]
content: str
content: NotRequired[str]

class ToolCallPart(TypedDict):
type: Literal["tool_call"]
name: NotRequired[str]
arguments: NotRequired[Any]

class ToolCallResponsePart(TypedDict):
type: Literal["tool_call_response"]
response: NotRequired[Any]

Check warning on line 487 in sentry_sdk/_types.py

View check run for this annotation

@sentry/warden / warden: code-review

ToolCallResponsePart uses `response` instead of documented `result`

Please rename `response` to `result` (and update the Bedrock transform) so `tool_call_response` parts match the `gen_ai.input.messages` contract documented in `SPANDATA.GEN_AI_INPUT_MESSAGES`.

Check warning on line 487 in sentry_sdk/_types.py

View check run for this annotation

@sentry/warden / warden: code-review

[TLK-LPB] toolUseId not mapped onto tool_call/tool_call_response id (additional location)

Please map Bedrock `toolUseId` to the `id` field on both `tool_call` and `tool_call_response` parts (and extend the TypedDicts); the IDs are present on the source blocks and `gen_ai.input.messages` documents them for correlating calls with responses.
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Resolved

Modality = Literal["image", "video", "audio", "document"]

class UriPart(TypedDict):
type: Literal["uri"]
modality: NotRequired[Modality]
uri: NotRequired[str]

class BlobPart(TypedDict):
type: Literal["blob"]
modality: NotRequired[Modality]

class InputMessage(TypedDict):
role: Literal["user", "assistant", "tool"]
parts: list[Union[TextPart, ReasoningPart, ToolCallPart]]
parts: list[
Union[
TextPart,
ReasoningPart,
ToolCallPart,
ToolCallResponsePart,
UriPart,
BlobPart,
]
]

class OutputMessage(TypedDict):
role: Literal["assistant"]
Expand Down
207 changes: 206 additions & 1 deletion sentry_sdk/integrations/boto3/_services/bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,17 @@
ConverseStreamResponseTypeDef,
)

from sentry_sdk._types import Attributes, TextPart, ToolDefinition
from sentry_sdk._types import (
Attributes,
BlobPart,
InputMessage,
ReasoningPart,
TextPart,
ToolCallPart,
ToolCallResponsePart,
ToolDefinition,
UriPart,
)
from sentry_sdk.integrations.boto3._context import AwsCallContext
from sentry_sdk.tracing import Span

Expand Down Expand Up @@ -160,6 +170,193 @@ def _transform_system_instructions(
return instruction_text_parts


def _transform_messages(
input_messages: "Union[list[dict[str, Any]], tuple[dict[str, Any], ...]]",
) -> "list[InputMessage]":
"""
Unlike other providers, AWS declares that input and output messages use the same message type.
For this reason, the same transform is used for user and assistant messages.
"""
messages: "list[InputMessage]" = []
for message in input_messages:
if not isinstance(message, dict):
continue

content = message.get("content")
if not isinstance(content, (list, tuple)) or "role" not in message:
continue

parts: "list[Union[TextPart, ReasoningPart, ToolCallPart, ToolCallResponsePart, UriPart, BlobPart]]" = []
for block in content:
if not isinstance(block, dict):
continue

if "text" in block:
parts.append({"type": "text", "content": block["text"]})
continue

tool_use = block.get("toolUse")
if tool_use is not None:
tool_call_part: "ToolCallPart" = {"type": "tool_call"}

if not isinstance(tool_use, dict):
parts.append(tool_call_part)
continue

if "name" in tool_use:
tool_call_part["name"] = tool_use["name"]
if "input" in tool_use:
tool_call_part["arguments"] = tool_use["input"]
parts.append(tool_call_part)
continue

tool_result = block.get("toolResult")
if tool_result is not None:
tool_call_response_part: "ToolCallResponsePart" = {
"type": "tool_call_response",
}

if (
not isinstance(tool_result, dict)
or "content" not in tool_result
or not isinstance(tool_result["content"], (list, tuple))
):
parts.append(tool_call_response_part)
continue

tool_call_response_part["response"] = [
item
for item in tool_result["content"]
if isinstance(item, dict) and ("json" in item or "text" in item)
]
parts.append(tool_call_response_part)
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Resolved
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Resolved
continue

reasoning_content = block.get("reasoningContent")
if reasoning_content is not None:
reasoning_part: "ReasoningPart" = {"type": "reasoning"}

if (
not isinstance(reasoning_content, dict)
or "reasoningText" not in reasoning_content
or not isinstance(reasoning_content["reasoningText"], dict)
or "text" not in reasoning_content["reasoningText"]
):
parts.append(reasoning_part)
continue

reasoning_part["content"] = reasoning_content["reasoningText"]["text"]
parts.append(reasoning_part)
continue

image = block.get("image")
if (
isinstance(image, dict)
and isinstance(image.get("source"), dict)
and "s3Location" in image["source"]
):
uri_part: "UriPart" = {
"type": "uri",
"modality": "image",
}
s3_location = image["source"]["s3Location"]
if not isinstance(s3_location, dict) or "uri" not in s3_location:
parts.append(uri_part)
continue

uri_part["uri"] = s3_location["uri"]
parts.append(uri_part)
continue
elif (
isinstance(image, dict)
and isinstance(image.get("source"), dict)
and "bytes" in image["source"]
):
parts.append({"type": "blob", "modality": "image"})
continue

video = block.get("video")
if (
isinstance(video, dict)
and isinstance(video.get("source"), dict)
and "s3Location" in video["source"]
):
uri_part = {
"type": "uri",
"modality": "video",
}
s3_location = video["source"]["s3Location"]
if not isinstance(s3_location, dict) or "uri" not in s3_location:
parts.append(uri_part)
continue

uri_part["uri"] = s3_location["uri"]
parts.append(uri_part)
continue
elif (
isinstance(video, dict)
and isinstance(video.get("source"), dict)
and "bytes" in video["source"]
):
parts.append({"type": "blob", "modality": "video"})
continue

audio = block.get("audio")
if (
isinstance(audio, dict)
and isinstance(audio.get("source"), dict)
and "s3Location" in audio["source"]
):
uri_part = {
"type": "uri",
"modality": "audio",
}
s3_location = audio["source"]["s3Location"]
if not isinstance(s3_location, dict) or "uri" not in s3_location:
parts.append(uri_part)
continue

uri_part["uri"] = s3_location["uri"]
parts.append(uri_part)
continue
elif (
isinstance(audio, dict)
and isinstance(audio.get("source"), dict)
and "bytes" in audio["source"]
):
parts.append({"type": "blob", "modality": "audio"})
continue

document = block.get("document")
if (
not isinstance(document, dict)
or "source" not in document
or not isinstance(document["source"], dict)
):
continue

if "s3Location" in document["source"]:
uri_part = {
"type": "uri",
"modality": "document",
}
s3_location = document["source"]["s3Location"]
if not isinstance(s3_location, dict) or "uri" not in s3_location:
parts.append(uri_part)
continue

uri_part["uri"] = s3_location["uri"]
parts.append(uri_part)
continue

if "bytes" in document["source"]:
parts.append({"type": "blob", "modality": "document"})
continue

messages.append({"role": message["role"], "parts": parts})
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Resolved
return messages


def _transform_tool_definitions(
tools: "list[dict[str, Any]]",
) -> "list[ToolDefinition]":
Expand Down Expand Up @@ -258,6 +455,14 @@ def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes":
_transform_system_instructions(system_instructions)
)

input_messages = ctx.params.get("messages")
if (
has_data_collection_enabled(client_options) or should_send_default_pii()
) and isinstance(input_messages, (list, tuple)):
attributes[SPANDATA.GEN_AI_INPUT_MESSAGES] = json.dumps(
_transform_messages(input_messages)
)

tool_config = ctx.params.get("toolConfig")
if not isinstance(tool_config, dict):
return attributes
Expand Down
Loading
Loading