Skip to content

Commit 04c6ede

Browse files
ericapisaniclaude
andcommitted
refactor(openai): Split token counting into separate Completions and Responses API functions
Replace the shared `_calculate_token_usage()` and `_get_usage()` with two API-specific functions: `_calculate_completions_token_usage()` and `_calculate_responses_token_usage()`. This makes it clear which token fields belong to which API and enables clean removal of Chat Completions support when it is deprecated. - Completions function extracts `prompt_tokens`, `completion_tokens`, `total_tokens` and supports `streaming_message_token_usage` for stream_options include_usage - Responses function extracts `input_tokens`, `output_tokens`, `total_tokens` plus `cached_tokens` and `reasoning_tokens` details - Add API section comments in `_set_common_output_data` - Update all call sites to use the appropriate API-specific function - Convert Completions call sites to use keyword arguments - Update and rename unit tests; add Responses API token usage tests - Add sync and async streaming tests for usage-in-stream Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 60a3f06 commit 04c6ede

2 files changed

Lines changed: 404 additions & 73 deletions

File tree

sentry_sdk/integrations/openai.py

Lines changed: 138 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,12 @@
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

5761
try:
@@ -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

Comments
 (0)