|
30 | 30 | ConverseStreamResponseTypeDef, |
31 | 31 | ) |
32 | 32 |
|
33 | | - from sentry_sdk._types import Attributes, TextPart, ToolDefinition |
| 33 | + from sentry_sdk._types import ( |
| 34 | + Attributes, |
| 35 | + BlobPart, |
| 36 | + InputMessage, |
| 37 | + ReasoningPart, |
| 38 | + TextPart, |
| 39 | + ToolCallPart, |
| 40 | + ToolCallResponsePart, |
| 41 | + ToolDefinition, |
| 42 | + UriPart, |
| 43 | + ) |
34 | 44 | from sentry_sdk.integrations.boto3._context import AwsCallContext |
35 | 45 | from sentry_sdk.tracing import Span |
36 | 46 |
|
@@ -202,6 +212,189 @@ def _transform_system_instructions( |
202 | 212 | return instruction_text_parts |
203 | 213 |
|
204 | 214 |
|
| 215 | +def _transform_messages( |
| 216 | + input_messages: "Union[list[dict[str, Any]], tuple[dict[str, Any], ...]]", |
| 217 | +) -> "list[InputMessage]": |
| 218 | + """ |
| 219 | + Unlike other providers, AWS declares that input and output messages use the same message type. |
| 220 | + For this reason, the same transform is used for user and assistant messages. |
| 221 | + """ |
| 222 | + messages: "list[InputMessage]" = [] |
| 223 | + for message in input_messages: |
| 224 | + if not isinstance(message, dict): |
| 225 | + continue |
| 226 | + |
| 227 | + content = message.get("content") |
| 228 | + if not isinstance(content, (list, tuple)) or "role" not in message: |
| 229 | + continue |
| 230 | + |
| 231 | + parts: "list[Union[TextPart, ReasoningPart, ToolCallPart, ToolCallResponsePart, UriPart, BlobPart]]" = [] |
| 232 | + for block in content: |
| 233 | + if not isinstance(block, dict): |
| 234 | + continue |
| 235 | + |
| 236 | + if "text" in block: |
| 237 | + parts.append({"type": "text", "content": block["text"]}) |
| 238 | + continue |
| 239 | + |
| 240 | + tool_use = block.get("toolUse") |
| 241 | + if tool_use is not None: |
| 242 | + tool_call_part: "ToolCallPart" = {"type": "tool_call"} |
| 243 | + |
| 244 | + if not isinstance(tool_use, dict): |
| 245 | + parts.append(tool_call_part) |
| 246 | + continue |
| 247 | + |
| 248 | + if "name" in tool_use: |
| 249 | + tool_call_part["name"] = tool_use["name"] |
| 250 | + if "input" in tool_use: |
| 251 | + tool_call_part["arguments"] = tool_use["input"] |
| 252 | + parts.append(tool_call_part) |
| 253 | + continue |
| 254 | + |
| 255 | + tool_result = block.get("toolResult") |
| 256 | + if tool_result is not None: |
| 257 | + tool_call_response_part: "ToolCallResponsePart" = { |
| 258 | + "type": "tool_call_response", |
| 259 | + } |
| 260 | + |
| 261 | + if not isinstance(tool_result, dict) or "content" not in tool_result: |
| 262 | + parts.append(tool_call_response_part) |
| 263 | + continue |
| 264 | + |
| 265 | + tool_call_response_part["response"] = [ |
| 266 | + item |
| 267 | + for item in tool_result["content"] |
| 268 | + if isinstance(item, dict) and ("json" in item or "text" in item) |
| 269 | + ] |
| 270 | + parts.append(tool_call_response_part) |
| 271 | + continue |
| 272 | + |
| 273 | + reasoning_content = block.get("reasoningContent") |
| 274 | + if reasoning_content is not None: |
| 275 | + reasoning_part: "ReasoningPart" = {"type": "reasoning"} |
| 276 | + |
| 277 | + if ( |
| 278 | + not isinstance(reasoning_content, dict) |
| 279 | + or "reasoningText" not in reasoning_content |
| 280 | + or not isinstance(reasoning_content["reasoningText"], dict) |
| 281 | + or "text" not in reasoning_content["reasoningText"] |
| 282 | + ): |
| 283 | + parts.append(reasoning_part) |
| 284 | + continue |
| 285 | + |
| 286 | + reasoning_part["content"] = reasoning_content["reasoningText"]["text"] |
| 287 | + parts.append(reasoning_part) |
| 288 | + continue |
| 289 | + |
| 290 | + image = block.get("image") |
| 291 | + if ( |
| 292 | + isinstance(image, dict) |
| 293 | + and isinstance(image.get("source"), dict) |
| 294 | + and "s3Location" in image["source"] |
| 295 | + ): |
| 296 | + uri_part: "UriPart" = { |
| 297 | + "type": "uri", |
| 298 | + "modality": "image", |
| 299 | + } |
| 300 | + s3_location = image["source"]["s3Location"] |
| 301 | + if not isinstance(s3_location, dict) or "uri" not in s3_location: |
| 302 | + parts.append(uri_part) |
| 303 | + continue |
| 304 | + |
| 305 | + uri_part["uri"] = s3_location["uri"] |
| 306 | + parts.append(uri_part) |
| 307 | + continue |
| 308 | + elif ( |
| 309 | + isinstance(image, dict) |
| 310 | + and isinstance(image.get("source"), dict) |
| 311 | + and "bytes" in image["source"] |
| 312 | + ): |
| 313 | + parts.append({"type": "blob", "modality": "image"}) |
| 314 | + continue |
| 315 | + |
| 316 | + video = block.get("video") |
| 317 | + if ( |
| 318 | + isinstance(video, dict) |
| 319 | + and isinstance(video.get("source"), dict) |
| 320 | + and "s3Location" in video["source"] |
| 321 | + ): |
| 322 | + uri_part = { |
| 323 | + "type": "uri", |
| 324 | + "modality": "video", |
| 325 | + } |
| 326 | + s3_location = video["source"]["s3Location"] |
| 327 | + if not isinstance(s3_location, dict) or "uri" not in s3_location: |
| 328 | + parts.append(uri_part) |
| 329 | + continue |
| 330 | + |
| 331 | + uri_part["uri"] = s3_location["uri"] |
| 332 | + parts.append(uri_part) |
| 333 | + continue |
| 334 | + elif ( |
| 335 | + isinstance(video, dict) |
| 336 | + and isinstance(video.get("source"), dict) |
| 337 | + and "bytes" in video["source"] |
| 338 | + ): |
| 339 | + parts.append({"type": "blob", "modality": "video"}) |
| 340 | + continue |
| 341 | + |
| 342 | + audio = block.get("audio") |
| 343 | + if ( |
| 344 | + isinstance(audio, dict) |
| 345 | + and isinstance(audio.get("source"), dict) |
| 346 | + and "s3Location" in audio["source"] |
| 347 | + ): |
| 348 | + uri_part = { |
| 349 | + "type": "uri", |
| 350 | + "modality": "audio", |
| 351 | + } |
| 352 | + s3_location = audio["source"]["s3Location"] |
| 353 | + if not isinstance(s3_location, dict) or "uri" not in s3_location: |
| 354 | + parts.append(uri_part) |
| 355 | + continue |
| 356 | + |
| 357 | + uri_part["uri"] = s3_location["uri"] |
| 358 | + parts.append(uri_part) |
| 359 | + continue |
| 360 | + elif ( |
| 361 | + isinstance(audio, dict) |
| 362 | + and isinstance(audio.get("source"), dict) |
| 363 | + and "bytes" in audio["source"] |
| 364 | + ): |
| 365 | + parts.append({"type": "blob", "modality": "audio"}) |
| 366 | + continue |
| 367 | + |
| 368 | + document = block.get("document") |
| 369 | + if ( |
| 370 | + not isinstance(document, dict) |
| 371 | + or "source" not in document |
| 372 | + or not isinstance(document["source"], dict) |
| 373 | + ): |
| 374 | + continue |
| 375 | + |
| 376 | + if "s3Location" in document["source"]: |
| 377 | + uri_part = { |
| 378 | + "type": "uri", |
| 379 | + "modality": "document", |
| 380 | + } |
| 381 | + s3_location = document["source"]["s3Location"] |
| 382 | + if not isinstance(s3_location, dict) or "uri" not in s3_location: |
| 383 | + parts.append(uri_part) |
| 384 | + continue |
| 385 | + |
| 386 | + uri_part["uri"] = s3_location["uri"] |
| 387 | + parts.append(uri_part) |
| 388 | + continue |
| 389 | + |
| 390 | + if "bytes" in document["source"]: |
| 391 | + parts.append({"type": "blob", "modality": "document"}) |
| 392 | + continue |
| 393 | + |
| 394 | + messages.append({"role": message["role"], "parts": parts}) |
| 395 | + return messages |
| 396 | + |
| 397 | + |
205 | 398 | class _BedrockExtension(_ServiceExtension): |
206 | 399 | __slots__ = () |
207 | 400 | skip_if_no_active_span = False |
@@ -250,6 +443,14 @@ def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": |
250 | 443 | if not client_options["data_collection"]["gen_ai"]["inputs"]: |
251 | 444 | return attributes |
252 | 445 |
|
| 446 | + input_messages = ctx.params.get("messages") |
| 447 | + if ( |
| 448 | + has_data_collection_enabled(client_options) or should_send_default_pii() |
| 449 | + ) and isinstance(input_messages, (list, tuple)): |
| 450 | + attributes[SPANDATA.GEN_AI_INPUT_MESSAGES] = json.dumps( |
| 451 | + _transform_messages(input_messages) |
| 452 | + ) |
| 453 | + |
253 | 454 | system_instructions = ctx.params.get("system") |
254 | 455 | if ( |
255 | 456 | has_data_collection_enabled(client_options) or should_send_default_pii() |
|
0 commit comments