|
3 | 3 | import sys |
4 | 4 | import warnings |
5 | 5 | from collections import OrderedDict |
| 6 | +from dataclasses import dataclass |
6 | 7 | from functools import wraps |
7 | 8 | from typing import TYPE_CHECKING |
8 | 9 |
|
9 | 10 | import sentry_sdk |
10 | 11 | from sentry_sdk.ai.utils import ( |
11 | 12 | GEN_AI_ALLOWED_MESSAGE_ROLES, |
| 13 | + _set_span_data_attribute, |
12 | 14 | get_start_span_function, |
13 | 15 | normalize_message_roles, |
14 | 16 | set_data_normalized, |
@@ -728,47 +730,116 @@ def on_tool_error( |
728 | 730 | self._handle_error(run_id, error) |
729 | 731 |
|
730 | 732 |
|
| 733 | +@dataclass |
| 734 | +class _TokenUsage: |
| 735 | + """ |
| 736 | + Normalized token usage, matching UsageMetadata from Langchain |
| 737 | + """ |
| 738 | + |
| 739 | + input_tokens: "Optional[int|float]" = None |
| 740 | + input_tokens_audio: "Optional[int|float]" = None |
| 741 | + input_tokens_cache_read: "Optional[int|float]" = None |
| 742 | + input_tokens_cache_creation: "Optional[int|float]" = None |
| 743 | + output_tokens: "Optional[int|float]" = None |
| 744 | + output_tokens_audio: "Optional[int|float]" = None |
| 745 | + output_tokens_reasoning: "Optional[int|float]" = None |
| 746 | + total_tokens: "Optional[int|float]" = None |
| 747 | + |
| 748 | + |
731 | 749 | def _extract_tokens( |
732 | 750 | token_usage: "Any", |
733 | | -) -> "tuple[Optional[int], Optional[int], Optional[int]]": |
| 751 | +) -> _TokenUsage: |
| 752 | + usage = _TokenUsage() |
734 | 753 | if not token_usage: |
735 | | - return None, None, None |
| 754 | + return usage |
736 | 755 |
|
737 | | - input_tokens = _get_value(token_usage, "prompt_tokens") or _get_value( |
| 756 | + usage.input_tokens = _get_value(token_usage, "prompt_tokens") or _get_value( |
738 | 757 | token_usage, "input_tokens" |
739 | 758 | ) |
740 | | - output_tokens = _get_value(token_usage, "completion_tokens") or _get_value( |
| 759 | + usage.output_tokens = _get_value(token_usage, "completion_tokens") or _get_value( |
741 | 760 | token_usage, "output_tokens" |
742 | 761 | ) |
743 | | - total_tokens = _get_value(token_usage, "total_tokens") |
| 762 | + usage.total_tokens = _get_value(token_usage, "total_tokens") |
| 763 | + |
| 764 | + input_token_details = _get_value(token_usage, "input_token_details") |
| 765 | + if input_token_details is not None: |
| 766 | + usage.input_tokens_audio = _get_value(input_token_details, "audio") |
| 767 | + usage.input_tokens_cache_read = _get_value(input_token_details, "cache_read") |
| 768 | + usage.input_tokens_cache_creation = _get_value( |
| 769 | + input_token_details, "cache_creation" |
| 770 | + ) |
744 | 771 |
|
745 | | - return input_tokens, output_tokens, total_tokens |
| 772 | + output_token_details = _get_value(token_usage, "output_token_details") |
| 773 | + if output_token_details is not None: |
| 774 | + usage.output_tokens_audio = _get_value(output_token_details, "audio") |
| 775 | + usage.output_tokens_reasoning = _get_value(output_token_details, "reasoning") |
| 776 | + return usage |
746 | 777 |
|
747 | 778 |
|
748 | 779 | def _extract_tokens_from_generations( |
749 | 780 | generations: "Any", |
750 | | -) -> "tuple[Optional[int], Optional[int], Optional[int]]": |
| 781 | +) -> _TokenUsage: |
751 | 782 | """Extract token usage from response.generations structure.""" |
| 783 | + total = _TokenUsage() |
| 784 | + |
752 | 785 | if not generations: |
753 | | - return None, None, None |
| 786 | + return total |
| 787 | + |
| 788 | + def _is_number(value: "Any") -> bool: |
| 789 | + return isinstance(value, (int, float)) |
754 | 790 |
|
755 | | - total_input = 0 |
756 | | - total_output = 0 |
757 | | - total_total = 0 |
| 791 | + def _add(left: "Any", right: "Any") -> "Union[int, float]": |
| 792 | + left = left if _is_number(left) else 0 |
| 793 | + right = right if _is_number(right) else 0 |
| 794 | + return left + right |
| 795 | + |
| 796 | + def _none_or_zero(value: "Optional[int|float]") -> bool: |
| 797 | + return value is None or value <= 0 |
758 | 798 |
|
759 | 799 | for gen_list in generations: |
760 | 800 | for gen in gen_list: |
761 | 801 | token_usage = _get_token_usage(gen) |
762 | | - input_tokens, output_tokens, total_tokens = _extract_tokens(token_usage) |
763 | | - total_input += input_tokens if input_tokens is not None else 0 |
764 | | - total_output += output_tokens if output_tokens is not None else 0 |
765 | | - total_total += total_tokens if total_tokens is not None else 0 |
766 | | - |
767 | | - return ( |
768 | | - total_input if total_input > 0 else None, |
769 | | - total_output if total_output > 0 else None, |
770 | | - total_total if total_total > 0 else None, |
771 | | - ) |
| 802 | + tokens = _extract_tokens(token_usage) |
| 803 | + total.input_tokens = _add(total.input_tokens, tokens.input_tokens) |
| 804 | + total.output_tokens = _add(total.output_tokens, tokens.output_tokens) |
| 805 | + total.total_tokens = _add(total.total_tokens, tokens.total_tokens) |
| 806 | + |
| 807 | + # We don't want to default zero here as it oculd be a valid value that is given by the |
| 808 | + # provider and can be a common value for caches |
| 809 | + if _is_number(tokens.input_tokens_cache_read): |
| 810 | + total.input_tokens_cache_read = _add( |
| 811 | + total.input_tokens_cache_read, tokens.input_tokens_cache_read |
| 812 | + ) |
| 813 | + |
| 814 | + if _is_number(tokens.input_tokens_cache_creation): |
| 815 | + total.input_tokens_cache_creation = _add( |
| 816 | + total.input_tokens_cache_creation, |
| 817 | + tokens.input_tokens_cache_creation, |
| 818 | + ) |
| 819 | + |
| 820 | + if _is_number(tokens.input_tokens_audio): |
| 821 | + total.input_tokens_audio = _add( |
| 822 | + total.input_tokens_audio, tokens.input_tokens_audio |
| 823 | + ) |
| 824 | + |
| 825 | + if _is_number(tokens.output_tokens_audio): |
| 826 | + total.output_tokens_audio = _add( |
| 827 | + total.output_tokens_audio, tokens.output_tokens_audio |
| 828 | + ) |
| 829 | + |
| 830 | + if _is_number(tokens.output_tokens_reasoning): |
| 831 | + total.output_tokens_reasoning = _add( |
| 832 | + total.output_tokens_reasoning, tokens.output_tokens_reasoning |
| 833 | + ) |
| 834 | + |
| 835 | + if _none_or_zero(total.input_tokens): |
| 836 | + total.input_tokens = None |
| 837 | + if _none_or_zero(total.output_tokens): |
| 838 | + total.output_tokens = None |
| 839 | + if _none_or_zero(total.total_tokens): |
| 840 | + total.total_tokens = None |
| 841 | + # we keep cached/reasoning token counts as is |
| 842 | + return total |
772 | 843 |
|
773 | 844 |
|
774 | 845 | def _get_token_usage(obj: "Any") -> "Optional[Dict[str, Any]]": |
@@ -800,26 +871,50 @@ def _get_token_usage(obj: "Any") -> "Optional[Dict[str, Any]]": |
800 | 871 |
|
801 | 872 |
|
802 | 873 | def _record_token_usage(span: "Union[Span, StreamedSpan]", response: "Any") -> None: |
803 | | - token_usage = _get_token_usage(response) |
804 | | - if token_usage: |
805 | | - input_tokens, output_tokens, total_tokens = _extract_tokens(token_usage) |
806 | | - else: |
807 | | - input_tokens, output_tokens, total_tokens = _extract_tokens_from_generations( |
808 | | - response.generations |
| 874 | + # Prefer usage_metadata: UsageMetadata from "generations" as it's Langchain's provider-agnostic |
| 875 | + # shape. Legacy usages rely on response.llm_output["token_usage"] |
| 876 | + tokens = _extract_tokens_from_generations(response.generations) |
| 877 | + if ( |
| 878 | + tokens.input_tokens is None |
| 879 | + and tokens.output_tokens is None |
| 880 | + and tokens.total_tokens is None |
| 881 | + ): |
| 882 | + token_usage = _get_token_usage(response) |
| 883 | + if token_usage: |
| 884 | + tokens = _extract_tokens(token_usage) |
| 885 | + |
| 886 | + if tokens.input_tokens is not None: |
| 887 | + _set_span_data_attribute( |
| 888 | + span, SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, tokens.input_tokens |
| 889 | + ) |
| 890 | + if tokens.output_tokens is not None: |
| 891 | + _set_span_data_attribute( |
| 892 | + span, SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, tokens.output_tokens |
| 893 | + ) |
| 894 | + if tokens.total_tokens is not None: |
| 895 | + _set_span_data_attribute( |
| 896 | + span, SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, tokens.total_tokens |
809 | 897 | ) |
810 | 898 |
|
811 | | - set_on_span = ( |
812 | | - span.set_attribute if isinstance(span, StreamedSpan) else span.set_data |
813 | | - ) |
814 | | - |
815 | | - if input_tokens is not None: |
816 | | - set_on_span(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, input_tokens) |
817 | | - |
818 | | - if output_tokens is not None: |
819 | | - set_on_span(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens) |
820 | | - |
821 | | - if total_tokens is not None: |
822 | | - set_on_span(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, total_tokens) |
| 899 | + # TODO: add input/output audio tokens when Sentry supports them |
| 900 | + if tokens.input_tokens_cache_read is not None: |
| 901 | + _set_span_data_attribute( |
| 902 | + span, |
| 903 | + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, |
| 904 | + tokens.input_tokens_cache_read, |
| 905 | + ) |
| 906 | + if tokens.input_tokens_cache_creation is not None: |
| 907 | + _set_span_data_attribute( |
| 908 | + span, |
| 909 | + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE, |
| 910 | + tokens.input_tokens_cache_creation, |
| 911 | + ) |
| 912 | + if tokens.output_tokens_reasoning is not None: |
| 913 | + _set_span_data_attribute( |
| 914 | + span, |
| 915 | + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, |
| 916 | + tokens.output_tokens_reasoning, |
| 917 | + ) |
823 | 918 |
|
824 | 919 |
|
825 | 920 | def _get_request_data( |
|
0 commit comments