Skip to content

Commit 62ab173

Browse files
committed
feat(langchain): Remove test matrix updates to allow CI to pass
1 parent 9996734 commit 62ab173

3 files changed

Lines changed: 160 additions & 47 deletions

File tree

sentry_sdk/integrations/langchain.py

Lines changed: 134 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import sys
44
import warnings
55
from collections import OrderedDict
6+
from dataclasses import dataclass
67
from functools import wraps
78
from typing import TYPE_CHECKING
89

910
import sentry_sdk
1011
from sentry_sdk.ai.utils import (
1112
GEN_AI_ALLOWED_MESSAGE_ROLES,
13+
_set_span_data_attribute,
1214
get_start_span_function,
1315
normalize_message_roles,
1416
set_data_normalized,
@@ -728,47 +730,116 @@ def on_tool_error(
728730
self._handle_error(run_id, error)
729731

730732

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+
731749
def _extract_tokens(
732750
token_usage: "Any",
733-
) -> "tuple[Optional[int], Optional[int], Optional[int]]":
751+
) -> _TokenUsage:
752+
usage = _TokenUsage()
734753
if not token_usage:
735-
return None, None, None
754+
return usage
736755

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(
738757
token_usage, "input_tokens"
739758
)
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(
741760
token_usage, "output_tokens"
742761
)
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+
)
744771

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
746777

747778

748779
def _extract_tokens_from_generations(
749780
generations: "Any",
750-
) -> "tuple[Optional[int], Optional[int], Optional[int]]":
781+
) -> _TokenUsage:
751782
"""Extract token usage from response.generations structure."""
783+
total = _TokenUsage()
784+
752785
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))
754790

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
758798

759799
for gen_list in generations:
760800
for gen in gen_list:
761801
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
772843

773844

774845
def _get_token_usage(obj: "Any") -> "Optional[Dict[str, Any]]":
@@ -800,26 +871,50 @@ def _get_token_usage(obj: "Any") -> "Optional[Dict[str, Any]]":
800871

801872

802873
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
809897
)
810898

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+
)
823918

824919

825920
def _get_request_data(

tests/conftest.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,6 +1350,12 @@ def streaming_chat_completions_model_response():
13501350
prompt_tokens=10,
13511351
completion_tokens=20,
13521352
total_tokens=30,
1353+
completion_tokens_details=openai.types.CompletionTokensDetails(
1354+
reasoning_tokens=12
1355+
),
1356+
prompt_tokens_details=openai.types.PromptTokensDetails(
1357+
cached_tokens=8, cache_write_tokens=2
1358+
),
13531359
),
13541360
),
13551361
]
@@ -1431,8 +1437,8 @@ def nonstreaming_responses_model_response():
14311437
usage=openai.types.responses.ResponseUsage(
14321438
input_tokens=10,
14331439
input_tokens_details=openai.types.responses.response_usage.InputTokensDetails(
1434-
cached_tokens=0,
1435-
cache_write_tokens=0,
1440+
cached_tokens=4,
1441+
cache_write_tokens=6,
14361442
),
14371443
output_tokens=20,
14381444
output_tokens_details=openai.types.responses.response_usage.OutputTokensDetails(

tests/integrations/langchain/test_langchain.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,9 @@ def test_langchain_create_agent(
634634
assert chat_spans[0]["attributes"]["gen_ai.agent.name"] == "word_length_agent"
635635

636636
assert chat_spans[0]["attributes"]["gen_ai.usage.input_tokens"] == 10
637+
assert chat_spans[0]["attributes"]["gen_ai.usage.input_tokens.cached"] == 4
637638
assert chat_spans[0]["attributes"]["gen_ai.usage.output_tokens"] == 20
639+
assert chat_spans[0]["attributes"]["gen_ai.usage.output_tokens.reasoning"] == 5
638640
assert chat_spans[0]["attributes"]["gen_ai.usage.total_tokens"] == 30
639641

640642
if send_default_pii and include_prompts:
@@ -698,7 +700,9 @@ def test_langchain_create_agent(
698700
assert chat_spans[0]["data"]["gen_ai.agent.name"] == "word_length_agent"
699701

700702
assert chat_spans[0]["data"]["gen_ai.usage.input_tokens"] == 10
703+
assert chat_spans[0]["data"]["gen_ai.usage.input_tokens.cached"] == 4
701704
assert chat_spans[0]["data"]["gen_ai.usage.output_tokens"] == 20
705+
assert chat_spans[0]["data"]["gen_ai.usage.output_tokens.reasoning"] == 5
702706
assert chat_spans[0]["data"]["gen_ai.usage.total_tokens"] == 30
703707

704708
if send_default_pii and include_prompts:
@@ -767,24 +771,24 @@ def test_tool_execution_span(
767771
ResponseUsage(
768772
input_tokens=142,
769773
input_tokens_details=InputTokensDetails(
770-
cached_tokens=0,
771-
cache_write_tokens=0,
774+
cached_tokens=69,
775+
cache_write_tokens=31,
772776
),
773777
output_tokens=50,
774778
output_tokens_details=OutputTokensDetails(
775-
reasoning_tokens=0,
779+
reasoning_tokens=10,
776780
),
777781
total_tokens=192,
778782
),
779783
ResponseUsage(
780784
input_tokens=89,
781785
input_tokens_details=InputTokensDetails(
782-
cached_tokens=0,
783-
cache_write_tokens=0,
786+
cached_tokens=69,
787+
cache_write_tokens=10,
784788
),
785789
output_tokens=28,
786790
output_tokens_details=OutputTokensDetails(
787-
reasoning_tokens=0,
791+
reasoning_tokens=11,
788792
),
789793
total_tokens=117,
790794
),
@@ -860,12 +864,16 @@ def test_tool_execution_span(
860864
assert tool_exec_span["attributes"]["gen_ai.agent.name"] == "word_length_agent"
861865

862866
assert chat_spans[0]["attributes"]["gen_ai.usage.input_tokens"] == 142
867+
assert chat_spans[0]["attributes"]["gen_ai.usage.input_tokens.cached"] == 69
863868
assert chat_spans[0]["attributes"]["gen_ai.usage.output_tokens"] == 50
869+
assert chat_spans[0]["attributes"]["gen_ai.usage.output_tokens.reasoning"] == 10
864870
assert chat_spans[0]["attributes"]["gen_ai.usage.total_tokens"] == 192
865871
assert chat_spans[0]["attributes"]["gen_ai.system"] == "openai-chat"
866872

867873
assert chat_spans[1]["attributes"]["gen_ai.usage.input_tokens"] == 89
874+
assert chat_spans[1]["attributes"]["gen_ai.usage.input_tokens.cached"] == 69
868875
assert chat_spans[1]["attributes"]["gen_ai.usage.output_tokens"] == 28
876+
assert chat_spans[1]["attributes"]["gen_ai.usage.output_tokens.reasoning"] == 11
869877
assert chat_spans[1]["attributes"]["gen_ai.usage.total_tokens"] == 117
870878
assert chat_spans[1]["attributes"]["gen_ai.system"] == "openai-chat"
871879

@@ -963,12 +971,16 @@ def test_tool_execution_span(
963971
assert tool_exec_span["data"]["gen_ai.agent.name"] == "word_length_agent"
964972

965973
assert chat_spans[0]["data"]["gen_ai.usage.input_tokens"] == 142
974+
assert chat_spans[0]["data"]["gen_ai.usage.input_tokens.cached"] == 69
966975
assert chat_spans[0]["data"]["gen_ai.usage.output_tokens"] == 50
976+
assert chat_spans[0]["data"]["gen_ai.usage.output_tokens.reasoning"] == 10
967977
assert chat_spans[0]["data"]["gen_ai.usage.total_tokens"] == 192
968978
assert chat_spans[0]["data"]["gen_ai.system"] == "openai-chat"
969979

970980
assert chat_spans[1]["data"]["gen_ai.usage.input_tokens"] == 89
981+
assert chat_spans[1]["data"]["gen_ai.usage.input_tokens.cached"] == 69
971982
assert chat_spans[1]["data"]["gen_ai.usage.output_tokens"] == 28
983+
assert chat_spans[1]["data"]["gen_ai.usage.output_tokens.reasoning"] == 11
972984
assert chat_spans[1]["data"]["gen_ai.usage.total_tokens"] == 117
973985
assert chat_spans[1]["data"]["gen_ai.system"] == "openai-chat"
974986

0 commit comments

Comments
 (0)