5050 from sentry_sdk .tracing import Span
5151 from sentry_sdk ._types import TextPart
5252
53- from openai .types .responses import ResponseInputParam , SequenceNotStr
54- from openai .types .responses import ResponseStreamEvent
53+ from openai .types .responses import (
54+ ResponseInputParam ,
55+ SequenceNotStr ,
56+ ResponseStreamEvent ,
57+ )
58+ from openai .types import CompletionUsage
5559 from openai import Omit
5660
5761try :
@@ -144,44 +148,37 @@ def _capture_exception(exc: "Any", manual_span_cleanup: bool = True) -> None:
144148 sentry_sdk .capture_event (event , hint = hint )
145149
146150
147- def _get_usage (usage : "Any" , names : "List[str]" ) -> int :
148- for name in names :
149- if hasattr (usage , name ) and isinstance (getattr (usage , name ), int ):
150- return getattr (usage , name )
151- return 0
152-
153-
154- def _calculate_token_usage (
151+ def _calculate_completions_token_usage (
155152 messages : "Optional[Iterable[ChatCompletionMessageParam]]" ,
156153 response : "Any" ,
157154 span : "Span" ,
158155 streaming_message_responses : "Optional[List[str]]" ,
156+ streaming_message_token_usage : "Optional[CompletionUsage]" ,
159157 count_tokens : "Callable[..., Any]" ,
160158) -> None :
159+ """Extract and record token usage from a Chat Completions API response."""
161160 input_tokens : "Optional[int]" = 0
162- input_tokens_cached : "Optional[int]" = 0
163161 output_tokens : "Optional[int]" = 0
164- output_tokens_reasoning : "Optional[int]" = 0
165162 total_tokens : "Optional[int]" = 0
163+ usage = None
166164
167- if hasattr (response , "usage" ):
168- input_tokens = _get_usage (response .usage , ["input_tokens" , "prompt_tokens" ])
169- if hasattr (response .usage , "input_tokens_details" ):
170- input_tokens_cached = _get_usage (
171- response .usage .input_tokens_details , ["cached_tokens" ]
172- )
173-
174- output_tokens = _get_usage (
175- response .usage , ["output_tokens" , "completion_tokens" ]
176- )
177- if hasattr (response .usage , "output_tokens_details" ):
178- output_tokens_reasoning = _get_usage (
179- response .usage .output_tokens_details , ["reasoning_tokens" ]
180- )
165+ if streaming_message_token_usage :
166+ usage = streaming_message_token_usage
181167
182- total_tokens = _get_usage (response .usage , ["total_tokens" ])
183-
184- # Manually count tokens
168+ if hasattr (response , "usage" ):
169+ usage = response .usage
170+
171+ if usage is not None :
172+ if hasattr (usage , "prompt_tokens" ) and isinstance (usage .prompt_tokens , int ):
173+ input_tokens = usage .prompt_tokens
174+ if hasattr (usage , "completion_tokens" ) and isinstance (
175+ usage .completion_tokens , int
176+ ):
177+ output_tokens = usage .completion_tokens
178+ if hasattr (usage , "total_tokens" ) and isinstance (usage .total_tokens , int ):
179+ total_tokens = usage .total_tokens
180+
181+ # Manually count input tokens
185182 if input_tokens == 0 :
186183 for message in messages or []:
187184 if isinstance (message , str ):
@@ -191,11 +188,11 @@ def _calculate_token_usage(
191188 message_content = message .get ("content" )
192189 if message_content is None :
193190 continue
194- # Deliberate use of Completions function for both Completions and Responses input format.
195191 text_items = _get_text_items (message_content )
196192 input_tokens += sum (count_tokens (text ) for text in text_items )
197193 continue
198194
195+ # Manually count output tokens
199196 if output_tokens == 0 :
200197 if streaming_message_responses is not None :
201198 for message in streaming_message_responses :
@@ -205,6 +202,71 @@ def _calculate_token_usage(
205202 if hasattr (choice , "message" ) and hasattr (choice .message , "content" ):
206203 output_tokens += count_tokens (choice .message .content )
207204
205+ # Do not set token data if it is 0
206+ input_tokens = input_tokens or None
207+ output_tokens = output_tokens or None
208+ total_tokens = total_tokens or None
209+
210+ record_token_usage (
211+ span ,
212+ input_tokens = input_tokens ,
213+ output_tokens = output_tokens ,
214+ total_tokens = total_tokens ,
215+ )
216+
217+
218+ def _calculate_responses_token_usage (
219+ input : "Any" ,
220+ response : "Any" ,
221+ span : "Span" ,
222+ streaming_message_responses : "Optional[List[str]]" ,
223+ count_tokens : "Callable[..., Any]" ,
224+ ) -> None :
225+ """Extract and record token usage from a Responses API response."""
226+ input_tokens : "Optional[int]" = 0
227+ input_tokens_cached : "Optional[int]" = 0
228+ output_tokens : "Optional[int]" = 0
229+ output_tokens_reasoning : "Optional[int]" = 0
230+ total_tokens : "Optional[int]" = 0
231+
232+ if hasattr (response , "usage" ):
233+ usage = response .usage
234+ if hasattr (usage , "input_tokens" ) and isinstance (usage .input_tokens , int ):
235+ input_tokens = usage .input_tokens
236+ if hasattr (usage , "input_tokens_details" ):
237+ cached = getattr (usage .input_tokens_details , "cached_tokens" , None )
238+ if isinstance (cached , int ):
239+ input_tokens_cached = cached
240+ if hasattr (usage , "output_tokens" ) and isinstance (usage .output_tokens , int ):
241+ output_tokens = usage .output_tokens
242+ if hasattr (usage , "output_tokens_details" ):
243+ reasoning = getattr (usage .output_tokens_details , "reasoning_tokens" , None )
244+ if isinstance (reasoning , int ):
245+ output_tokens_reasoning = reasoning
246+ if hasattr (usage , "total_tokens" ) and isinstance (usage .total_tokens , int ):
247+ total_tokens = usage .total_tokens
248+
249+ # Manually count input tokens
250+ if input_tokens == 0 :
251+ for message in input or []:
252+ if isinstance (message , str ):
253+ input_tokens += count_tokens (message )
254+ continue
255+ elif isinstance (message , dict ):
256+ message_content = message .get ("content" )
257+ if message_content is None :
258+ continue
259+ # Deliberate use of Completions function for both Completions and Responses input format.
260+ text_items = _get_text_items (message_content )
261+ input_tokens += sum (count_tokens (text ) for text in text_items )
262+ continue
263+
264+ # Manually count output tokens
265+ if output_tokens == 0 :
266+ if streaming_message_responses is not None :
267+ for message in streaming_message_responses :
268+ output_tokens += count_tokens (message )
269+
208270 # Do not set token data if it is 0
209271 input_tokens = input_tokens or None
210272 input_tokens_cached = input_tokens_cached or None
@@ -486,6 +548,7 @@ def _set_common_output_data(
486548 if hasattr (response , "model" ):
487549 set_data_normalized (span , SPANDATA .GEN_AI_RESPONSE_MODEL , response .model )
488550
551+ # Chat Completions API
489552 if hasattr (response , "choices" ):
490553 if should_send_default_pii () and integration .include_prompts :
491554 response_text = [
@@ -496,11 +559,19 @@ def _set_common_output_data(
496559 if len (response_text ) > 0 :
497560 set_data_normalized (span , SPANDATA .GEN_AI_RESPONSE_TEXT , response_text )
498561
499- _calculate_token_usage (input , response , span , None , integration .count_tokens )
562+ _calculate_completions_token_usage (
563+ messages = input ,
564+ response = response ,
565+ span = span ,
566+ streaming_message_responses = None ,
567+ streaming_message_token_usage = None ,
568+ count_tokens = integration .count_tokens ,
569+ )
500570
501571 if finish_span :
502572 span .__exit__ (None , None , None )
503573
574+ # Responses API
504575 elif hasattr (response , "output" ):
505576 if should_send_default_pii () and integration .include_prompts :
506577 output_messages : "dict[str, list[Any]]" = {
@@ -532,12 +603,22 @@ def _set_common_output_data(
532603 span , SPANDATA .GEN_AI_RESPONSE_TEXT , output_messages ["response" ]
533604 )
534605
535- _calculate_token_usage (input , response , span , None , integration .count_tokens )
606+ _calculate_responses_token_usage (
607+ input , response , span , None , integration .count_tokens
608+ )
536609
537610 if finish_span :
538611 span .__exit__ (None , None , None )
612+ # Embeddings API (fallback for responses with neither choices nor output)
539613 else :
540- _calculate_token_usage (input , response , span , None , integration .count_tokens )
614+ _calculate_completions_token_usage (
615+ messages = input ,
616+ response = response ,
617+ span = span ,
618+ streaming_message_responses = None ,
619+ streaming_message_token_usage = None ,
620+ count_tokens = integration .count_tokens ,
621+ )
541622 if finish_span :
542623 span .__exit__ (None , None , None )
543624
@@ -655,6 +736,7 @@ def _wrap_synchronous_completions_chunk_iterator(
655736 """
656737 ttft = None
657738 data_buf : "list[list[str]]" = [] # one for each choice
739+ streaming_message_token_usage = None
658740
659741 for x in old_iterator :
660742 span .set_data (SPANDATA .GEN_AI_RESPONSE_MODEL , x .model )
@@ -671,6 +753,8 @@ def _wrap_synchronous_completions_chunk_iterator(
671753 data_buf .append ([])
672754 data_buf [choice_index ].append (content or "" )
673755 choice_index += 1
756+ if hasattr (x , "usage" ):
757+ streaming_message_token_usage = x .usage
674758
675759 yield x
676760
@@ -683,12 +767,13 @@ def _wrap_synchronous_completions_chunk_iterator(
683767 all_responses = ["" .join (chunk ) for chunk in data_buf ]
684768 if should_send_default_pii () and integration .include_prompts :
685769 set_data_normalized (span , SPANDATA .GEN_AI_RESPONSE_TEXT , all_responses )
686- _calculate_token_usage (
687- messages ,
688- response ,
689- span ,
690- all_responses ,
691- integration .count_tokens ,
770+ _calculate_completions_token_usage (
771+ messages = messages ,
772+ response = response ,
773+ span = span ,
774+ streaming_message_responses = all_responses ,
775+ streaming_message_token_usage = streaming_message_token_usage ,
776+ count_tokens = integration .count_tokens ,
692777 )
693778
694779 if finish_span :
@@ -711,6 +796,7 @@ async def _wrap_asynchronous_completions_chunk_iterator(
711796 """
712797 ttft = None
713798 data_buf : "list[list[str]]" = [] # one for each choice
799+ streaming_message_token_usage = None
714800
715801 async for x in old_iterator :
716802 span .set_data (SPANDATA .GEN_AI_RESPONSE_MODEL , x .model )
@@ -727,6 +813,8 @@ async def _wrap_asynchronous_completions_chunk_iterator(
727813 data_buf .append ([])
728814 data_buf [choice_index ].append (content or "" )
729815 choice_index += 1
816+ if hasattr (x , "usage" ):
817+ streaming_message_token_usage = x .usage
730818
731819 yield x
732820
@@ -739,12 +827,13 @@ async def _wrap_asynchronous_completions_chunk_iterator(
739827 all_responses = ["" .join (chunk ) for chunk in data_buf ]
740828 if should_send_default_pii () and integration .include_prompts :
741829 set_data_normalized (span , SPANDATA .GEN_AI_RESPONSE_TEXT , all_responses )
742- _calculate_token_usage (
743- messages ,
744- response ,
745- span ,
746- all_responses ,
747- integration .count_tokens ,
830+ _calculate_completions_token_usage (
831+ messages = messages ,
832+ response = response ,
833+ span = span ,
834+ streaming_message_responses = all_responses ,
835+ streaming_message_token_usage = streaming_message_token_usage ,
836+ count_tokens = integration .count_tokens ,
748837 )
749838
750839 if finish_span :
@@ -781,7 +870,7 @@ def _wrap_synchronous_responses_event_iterator(
781870 if isinstance (x , ResponseCompletedEvent ):
782871 span .set_data (SPANDATA .GEN_AI_RESPONSE_MODEL , x .response .model )
783872
784- _calculate_token_usage (
873+ _calculate_responses_token_usage (
785874 input ,
786875 x .response ,
787876 span ,
@@ -802,7 +891,7 @@ def _wrap_synchronous_responses_event_iterator(
802891 if should_send_default_pii () and integration .include_prompts :
803892 set_data_normalized (span , SPANDATA .GEN_AI_RESPONSE_TEXT , all_responses )
804893 if count_tokens_manually :
805- _calculate_token_usage (
894+ _calculate_responses_token_usage (
806895 input ,
807896 response ,
808897 span ,
@@ -844,7 +933,7 @@ async def _wrap_asynchronous_responses_event_iterator(
844933 if isinstance (x , ResponseCompletedEvent ):
845934 span .set_data (SPANDATA .GEN_AI_RESPONSE_MODEL , x .response .model )
846935
847- _calculate_token_usage (
936+ _calculate_responses_token_usage (
848937 input ,
849938 x .response ,
850939 span ,
@@ -865,7 +954,7 @@ async def _wrap_asynchronous_responses_event_iterator(
865954 if should_send_default_pii () and integration .include_prompts :
866955 set_data_normalized (span , SPANDATA .GEN_AI_RESPONSE_TEXT , all_responses )
867956 if count_tokens_manually :
868- _calculate_token_usage (
957+ _calculate_responses_token_usage (
869958 input ,
870959 response ,
871960 span ,
0 commit comments