|
1 | 1 | import inspect |
2 | 2 | import json |
3 | | -from copy import deepcopy |
4 | 3 | from typing import TYPE_CHECKING |
5 | 4 |
|
6 | | -from sentry_sdk._types import BLOB_DATA_SUBSTITUTE |
7 | | -from sentry_sdk.ai.consts import DATA_URL_BASE64_REGEX |
8 | | - |
9 | 5 | if TYPE_CHECKING: |
10 | | - from typing import Any, Dict, List, Optional, Tuple, Union |
| 6 | + from typing import Any, Dict, Optional, Tuple, Union |
11 | 7 |
|
12 | 8 | from sentry_sdk.tracing import Span |
13 | 9 |
|
14 | 10 | import sentry_sdk |
15 | 11 | from sentry_sdk.traces import StreamedSpan |
16 | 12 | from sentry_sdk.utils import logger |
17 | 13 |
|
18 | | -MAX_GEN_AI_MESSAGE_BYTES = 20_000 # 20KB |
19 | | -# Maximum characters when only a single message is left after bytes truncation |
20 | | -MAX_SINGLE_MESSAGE_CONTENT_CHARS = 10_000 |
21 | | - |
22 | 14 |
|
23 | 15 | class GEN_AI_ALLOWED_MESSAGE_ROLES: |
24 | 16 | SYSTEM = "system" |
@@ -431,38 +423,6 @@ def transform_content_part( |
431 | 423 | return None |
432 | 424 |
|
433 | 425 |
|
434 | | -def transform_message_content(content: "Any") -> "Any": |
435 | | - """ |
436 | | - Transform message content, handling both string content and list of content blocks. |
437 | | -
|
438 | | - For list content, each item is transformed using transform_content_part(). |
439 | | - Items that cannot be transformed (return None) are kept as-is. |
440 | | -
|
441 | | - Args: |
442 | | - content: Message content - can be a string, list of content blocks, or other |
443 | | -
|
444 | | - Returns: |
445 | | - - String content: returned as-is |
446 | | - - List content: list with each transformable item converted to standardized format |
447 | | - - Other: returned as-is |
448 | | - """ |
449 | | - if isinstance(content, str): |
450 | | - return content |
451 | | - |
452 | | - if isinstance(content, (list, tuple)): |
453 | | - transformed = [] |
454 | | - for item in content: |
455 | | - if isinstance(item, dict): |
456 | | - result = transform_content_part(item) |
457 | | - # If transformation succeeded, use the result; otherwise keep original |
458 | | - transformed.append(result if result is not None else item) |
459 | | - else: |
460 | | - transformed.append(item) |
461 | | - return transformed |
462 | | - |
463 | | - return content |
464 | | - |
465 | | - |
466 | 426 | def _normalize_data(data: "Any", unpack: bool = True) -> "Any": |
467 | 427 | # convert pydantic data (e.g. OpenAI v1+) to json compatible format |
468 | 428 | if hasattr(data, "model_dump"): |
@@ -536,235 +496,6 @@ def normalize_message_roles(messages: "list[dict[str, Any]]") -> "list[dict[str, |
536 | 496 | return normalized_messages |
537 | 497 |
|
538 | 498 |
|
539 | | -def _truncate_single_message_content_if_present( |
540 | | - message: "Dict[str, Any]", max_chars: int |
541 | | -) -> "Dict[str, Any]": |
542 | | - """ |
543 | | - Truncate a message's content to at most `max_chars` characters and append an |
544 | | - ellipsis if truncation occurs. |
545 | | - """ |
546 | | - if not isinstance(message, dict) or "content" not in message: |
547 | | - return message |
548 | | - content = message["content"] |
549 | | - |
550 | | - if isinstance(content, str): |
551 | | - if len(content) <= max_chars: |
552 | | - return message |
553 | | - message["content"] = content[:max_chars] + "..." |
554 | | - return message |
555 | | - |
556 | | - if isinstance(content, list): |
557 | | - remaining = max_chars |
558 | | - for item in content: |
559 | | - if isinstance(item, dict) and "text" in item: |
560 | | - text = item["text"] |
561 | | - if isinstance(text, str): |
562 | | - if len(text) > remaining: |
563 | | - item["text"] = text[:remaining] + "..." |
564 | | - remaining = 0 |
565 | | - else: |
566 | | - remaining -= len(text) |
567 | | - return message |
568 | | - |
569 | | - return message |
570 | | - |
571 | | - |
572 | | -def _find_truncation_index(messages: "List[Dict[str, Any]]", max_bytes: int) -> int: |
573 | | - """ |
574 | | - Find the index of the first message that would exceed the max bytes limit. |
575 | | - Compute the individual message sizes, and return the index of the first message from the back |
576 | | - of the list that would exceed the max bytes limit. |
577 | | - """ |
578 | | - running_sum = 0 |
579 | | - for idx in range(len(messages) - 1, -1, -1): |
580 | | - size = len(json.dumps(messages[idx], separators=(",", ":")).encode("utf-8")) |
581 | | - running_sum += size |
582 | | - if running_sum > max_bytes: |
583 | | - return idx + 1 |
584 | | - |
585 | | - return 0 |
586 | | - |
587 | | - |
588 | | -def _is_image_type_with_blob_content(item: "Dict[str, Any]") -> bool: |
589 | | - """ |
590 | | - Some content blocks contain an image_url property with base64 content as its value. |
591 | | - This is used to identify those while not leading to unnecessary copying of data when the image URL does not contain base64 content. |
592 | | - """ |
593 | | - if item.get("type") != "image_url": |
594 | | - return False |
595 | | - |
596 | | - image_url_val = item.get("image_url") |
597 | | - image_url = ( |
598 | | - image_url_val.get("url", "") |
599 | | - if isinstance(image_url_val, dict) |
600 | | - else (image_url_val or "") |
601 | | - ) |
602 | | - data_url_match = DATA_URL_BASE64_REGEX.match(image_url) |
603 | | - |
604 | | - return bool(data_url_match) |
605 | | - |
606 | | - |
607 | | -def redact_blob_message_parts( |
608 | | - messages: "List[Dict[str, Any]]", |
609 | | -) -> "List[Dict[str, Any]]": |
610 | | - """ |
611 | | - Redact blob message parts from the messages by replacing blob content with "[Filtered]". |
612 | | -
|
613 | | - This function creates a deep copy of messages that contain blob content to avoid |
614 | | - mutating the original message dictionaries. Messages without blob content are |
615 | | - returned as-is to minimize copying overhead. |
616 | | -
|
617 | | - e.g: |
618 | | - { |
619 | | - "role": "user", |
620 | | - "content": [ |
621 | | - { |
622 | | - "text": "How many ponies do you see in the image?", |
623 | | - "type": "text" |
624 | | - }, |
625 | | - { |
626 | | - "type": "blob", |
627 | | - "modality": "image", |
628 | | - "mime_type": "image/jpeg", |
629 | | - "content": "data:image/jpeg;base64,..." |
630 | | - } |
631 | | - ] |
632 | | - } |
633 | | - becomes: |
634 | | - { |
635 | | - "role": "user", |
636 | | - "content": [ |
637 | | - { |
638 | | - "text": "How many ponies do you see in the image?", |
639 | | - "type": "text" |
640 | | - }, |
641 | | - { |
642 | | - "type": "blob", |
643 | | - "modality": "image", |
644 | | - "mime_type": "image/jpeg", |
645 | | - "content": "[Filtered]" |
646 | | - } |
647 | | - ] |
648 | | - } |
649 | | - """ |
650 | | - |
651 | | - # First pass: check if any message contains blob content |
652 | | - has_blobs = False |
653 | | - for message in messages: |
654 | | - if not isinstance(message, dict): |
655 | | - continue |
656 | | - content = message.get("content") |
657 | | - if isinstance(content, list): |
658 | | - for item in content: |
659 | | - if isinstance(item, dict) and ( |
660 | | - item.get("type") == "blob" or _is_image_type_with_blob_content(item) |
661 | | - ): |
662 | | - has_blobs = True |
663 | | - break |
664 | | - if has_blobs: |
665 | | - break |
666 | | - |
667 | | - # If no blobs found, return original messages to avoid unnecessary copying |
668 | | - if not has_blobs: |
669 | | - return messages |
670 | | - |
671 | | - # Deep copy messages to avoid mutating the original |
672 | | - messages_copy = deepcopy(messages) |
673 | | - |
674 | | - # Second pass: redact blob content in the copy |
675 | | - for message in messages_copy: |
676 | | - if not isinstance(message, dict): |
677 | | - continue |
678 | | - |
679 | | - content = message.get("content") |
680 | | - if isinstance(content, list): |
681 | | - for item in content: |
682 | | - if isinstance(item, dict): |
683 | | - if item.get("type") == "blob": |
684 | | - item["content"] = BLOB_DATA_SUBSTITUTE |
685 | | - elif _is_image_type_with_blob_content(item): |
686 | | - if isinstance(item["image_url"], dict): |
687 | | - item["image_url"]["url"] = BLOB_DATA_SUBSTITUTE |
688 | | - else: |
689 | | - item["image_url"] = BLOB_DATA_SUBSTITUTE |
690 | | - |
691 | | - return messages_copy |
692 | | - |
693 | | - |
694 | | -def truncate_messages_by_size( |
695 | | - messages: "List[Dict[str, Any]]", |
696 | | - max_bytes: int = MAX_GEN_AI_MESSAGE_BYTES, |
697 | | - max_single_message_chars: int = MAX_SINGLE_MESSAGE_CONTENT_CHARS, |
698 | | -) -> "Tuple[List[Dict[str, Any]], int]": |
699 | | - """ |
700 | | - Returns a truncated messages list, consisting of |
701 | | - - the last message, with its content truncated to `max_single_message_chars` characters, |
702 | | - if the last message's size exceeds `max_bytes` bytes; otherwise, |
703 | | - - the maximum number of messages, starting from the end of the `messages` list, whose total |
704 | | - serialized size does not exceed `max_bytes` bytes. |
705 | | -
|
706 | | - In the single message case, the serialized message size may exceed `max_bytes`, because |
707 | | - truncation is based only on character count in that case. |
708 | | - """ |
709 | | - serialized_json = json.dumps(messages, separators=(",", ":")) |
710 | | - current_size = len(serialized_json.encode("utf-8")) |
711 | | - |
712 | | - if current_size <= max_bytes: |
713 | | - return messages, 0 |
714 | | - |
715 | | - truncation_index = _find_truncation_index(messages, max_bytes) |
716 | | - if truncation_index < len(messages): |
717 | | - truncated_messages = messages[truncation_index:] |
718 | | - else: |
719 | | - truncation_index = len(messages) - 1 |
720 | | - truncated_messages = messages[-1:] |
721 | | - |
722 | | - if len(truncated_messages) == 1: |
723 | | - truncated_messages[0] = _truncate_single_message_content_if_present( |
724 | | - deepcopy(truncated_messages[0]), max_chars=max_single_message_chars |
725 | | - ) |
726 | | - |
727 | | - return truncated_messages, truncation_index |
728 | | - |
729 | | - |
730 | | -def truncate_and_annotate_messages( |
731 | | - messages: "Optional[List[Dict[str, Any]]]", |
732 | | - span: "Any", |
733 | | - scope: "Any", |
734 | | - max_single_message_chars: int = MAX_SINGLE_MESSAGE_CONTENT_CHARS, |
735 | | -) -> "Optional[List[Dict[str, Any]]]": |
736 | | - if not messages: |
737 | | - return None |
738 | | - |
739 | | - messages = redact_blob_message_parts(messages) |
740 | | - |
741 | | - truncated_message = _truncate_single_message_content_if_present( |
742 | | - deepcopy(messages[-1]), max_chars=max_single_message_chars |
743 | | - ) |
744 | | - if len(messages) > 1: |
745 | | - scope._gen_ai_original_message_count[span.span_id] = len(messages) |
746 | | - |
747 | | - return [truncated_message] |
748 | | - |
749 | | - |
750 | | -def truncate_and_annotate_embedding_inputs( |
751 | | - messages: "Optional[List[Dict[str, Any]]]", |
752 | | - span: "Any", |
753 | | - scope: "Any", |
754 | | - max_bytes: int = MAX_GEN_AI_MESSAGE_BYTES, |
755 | | -) -> "Optional[List[Dict[str, Any]]]": |
756 | | - if not messages: |
757 | | - return None |
758 | | - |
759 | | - messages = redact_blob_message_parts(messages) |
760 | | - |
761 | | - truncated_messages, removed_count = truncate_messages_by_size(messages, max_bytes) |
762 | | - if removed_count > 0: |
763 | | - scope._gen_ai_original_message_count[span.span_id] = len(messages) |
764 | | - |
765 | | - return truncated_messages |
766 | | - |
767 | | - |
768 | 499 | def set_conversation_id(conversation_id: str) -> None: |
769 | 500 | """ |
770 | 501 | Set the conversation_id in the scope. |
|
0 commit comments