Skip to content

Commit 0e15925

Browse files
authored
[openai] Record cache-write and audio token usage (#681)
* [openai] Record cache-write and modality token usage Signed-off-by: 1fanwang <1fannnw@gmail.com> * Associate the changelog entry with its pull request Signed-off-by: 1fanwang <1fannnw@gmail.com> * Show exported usage in the HTTP regression output Signed-off-by: 1fanwang <1fannnw@gmail.com> * Buffer Chat Completions usage until stream cleanup Signed-off-by: 1fanwang <1fannnw@gmail.com> * Share OpenAI token usage extraction at stream cleanup Signed-off-by: 1fanwang <1fannnw@gmail.com> * Remove debug output from token usage assertions Signed-off-by: 1fanwang <1fannnw@gmail.com> * Limit Chat Completions modality usage to audio Assisted-by: GitHub Copilot CLI (Auto) Signed-off-by: 1fanwang <1fannnw@gmail.com> * Address OpenAI token usage review feedback Assisted-by: GitHub Copilot CLI (claude-sonnet-5.5) Signed-off-by: 1fanwang <1fannnw@gmail.com> --------- Signed-off-by: 1fanwang <1fannnw@gmail.com>
1 parent 5ad660b commit 0e15925

5 files changed

Lines changed: 424 additions & 58 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Record cache-write and audio token usage from OpenAI Chat Completions responses and streams.

‎instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/chat_wrappers.py‎

Lines changed: 15 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import logging
88

99
from openai import AsyncStream, Stream
10+
from openai.types import CompletionUsage
1011
from openai.types.chat import ChatCompletionChunk
1112

1213
from opentelemetry.semconv._incubating.attributes import (
@@ -25,7 +26,10 @@
2526
)
2627

2728
from .chat_buffers import ChoiceBuffer
28-
from .utils import get_property_value, map_finish_reason
29+
from .utils import (
30+
map_finish_reason,
31+
set_chat_usage,
32+
)
2933

3034
_logger = logging.getLogger(__name__)
3135

@@ -38,10 +42,7 @@ class _ChatStreamMixin:
3842
_self_choice_buffers: list[ChoiceBuffer]
3943
_self_response_id: str | None
4044
_self_service_tier: str | None
41-
_self_prompt_tokens: int | None
42-
_self_completion_tokens: int | None
43-
_self_cached_prompt_tokens: int | None
44-
_self_reasoning_tokens: int | None
45+
_self_usage: CompletionUsage | None
4546

4647
def _set_response_model(self, chunk: ChatCompletionChunk) -> None:
4748
# Set eagerly so the per-chunk streaming timing metrics carry
@@ -94,26 +95,6 @@ def _build_streaming_response(self, chunk: ChatCompletionChunk) -> None:
9495
tool_call
9596
)
9697

97-
def _set_usage(self, chunk: ChatCompletionChunk) -> None:
98-
usage = getattr(chunk, "usage", None)
99-
if usage:
100-
self._self_completion_tokens = usage.completion_tokens
101-
self._self_prompt_tokens = usage.prompt_tokens
102-
prompt_tokens_details = getattr(
103-
usage, "prompt_tokens_details", None
104-
)
105-
if prompt_tokens_details is not None:
106-
self._self_cached_prompt_tokens = get_property_value(
107-
prompt_tokens_details, "cached_tokens"
108-
)
109-
completion_tokens_details = getattr(
110-
usage, "completion_tokens_details", None
111-
)
112-
if completion_tokens_details is not None:
113-
self._self_reasoning_tokens = get_property_value(
114-
completion_tokens_details, "reasoning_tokens"
115-
)
116-
11798
def _process_chunk(self, chunk: ChatCompletionChunk) -> None:
11899
if not isinstance(chunk, ChatCompletionChunk):
119100
# raw-response stream can be parsed into a caller-defined chunk type.
@@ -127,7 +108,9 @@ def _process_chunk(self, chunk: ChatCompletionChunk) -> None:
127108
self._set_response_model(chunk)
128109
self._set_response_service_tier(chunk)
129110
self._build_streaming_response(chunk)
130-
self._set_usage(chunk)
111+
usage: CompletionUsage | None = chunk.usage
112+
if usage is not None:
113+
self._self_usage = usage
131114

132115
def _set_output_messages(self) -> None:
133116
if not self._self_capture_content: # optimization
@@ -172,12 +155,10 @@ def _on_stream_error(self, error: BaseException) -> None:
172155

173156
def _cleanup(self, error: BaseException | None = None) -> None:
174157
self._self_invocation.response_id = self._self_response_id
175-
self._self_invocation.input_tokens = self._self_prompt_tokens
176-
self._self_invocation.output_tokens = self._self_completion_tokens
177-
self._self_invocation.cache_read_input_tokens = (
178-
self._self_cached_prompt_tokens
179-
)
180-
self._self_invocation.thinking_tokens = self._self_reasoning_tokens
158+
if self._self_usage is not None:
159+
set_chat_usage(
160+
invocation=self._self_invocation, usage=self._self_usage
161+
)
181162
finish_reasons = [
182163
choice.finish_reason
183164
for choice in self._self_choice_buffers
@@ -217,10 +198,7 @@ def __init__(
217198
self._self_capture_content = capture_content
218199
self._self_response_id = None
219200
self._self_service_tier = None
220-
self._self_prompt_tokens = None
221-
self._self_completion_tokens = None
222-
self._self_cached_prompt_tokens = None
223-
self._self_reasoning_tokens = None
201+
self._self_usage = None
224202

225203

226204
class AsyncChatStreamWrapper(
@@ -240,10 +218,7 @@ def __init__(
240218
self._self_capture_content = capture_content
241219
self._self_response_id = None
242220
self._self_service_tier = None
243-
self._self_prompt_tokens = None
244-
self._self_completion_tokens = None
245-
self._self_cached_prompt_tokens = None
246-
self._self_reasoning_tokens = None
221+
self._self_usage = None
247222

248223

249224
__all__ = [

‎instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/patch.py‎

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66

77
from openai.types import CreateEmbeddingResponse
8+
from openai.types.chat import ChatCompletion
89

910
from opentelemetry.semconv._incubating.attributes import (
1011
gen_ai_attributes as GenAIAttributes,
@@ -23,10 +24,10 @@
2324
from .utils import (
2425
_prepare_output_messages,
2526
create_chat_invocation,
26-
get_property_value,
2727
get_server_address_and_port,
2828
get_value,
2929
is_streaming,
30+
set_chat_usage,
3031
)
3132

3233
_logger = logging.getLogger(__name__)
@@ -158,7 +159,9 @@ async def traced_method(wrapped, instance, args, kwargs):
158159

159160

160161
def _set_response_properties(
161-
chat_invocation: InferenceInvocation, result, capture_content: bool
162+
chat_invocation: InferenceInvocation,
163+
result: ChatCompletion | ParsableResponse,
164+
capture_content: bool,
162165
) -> InferenceInvocation:
163166
if isinstance(result, ParsableResponse):
164167
# with_raw_response: safe to parse() here since this is the
@@ -196,22 +199,7 @@ def _set_response_properties(
196199
)
197200

198201
if getattr(result, "usage", None):
199-
chat_invocation.input_tokens = result.usage.prompt_tokens
200-
chat_invocation.output_tokens = result.usage.completion_tokens
201-
prompt_tokens_details = getattr(
202-
result.usage, "prompt_tokens_details", None
203-
)
204-
if prompt_tokens_details is not None:
205-
chat_invocation.cache_read_input_tokens = get_property_value(
206-
prompt_tokens_details, "cached_tokens"
207-
)
208-
completion_tokens_details = getattr(
209-
result.usage, "completion_tokens_details", None
210-
)
211-
if completion_tokens_details is not None:
212-
chat_invocation.thinking_tokens = get_property_value(
213-
completion_tokens_details, "reasoning_tokens"
214-
)
202+
set_chat_usage(invocation=chat_invocation, usage=result.usage)
215203

216204
if getattr(result, "system_fingerprint", None):
217205
chat_invocation.attributes.update(

‎instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/utils.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import openai
1313
from openai import NotGiven
14+
from openai.types import CompletionUsage
1415

1516
from opentelemetry.semconv._incubating.attributes import (
1617
gen_ai_attributes as GenAIAttributes,
@@ -68,6 +69,50 @@ def get_property_value(obj, property_name):
6869
return getattr(obj, property_name, None)
6970

7071

72+
def set_chat_usage(
73+
invocation: InferenceInvocation, usage: CompletionUsage
74+
) -> None:
75+
invocation.input_tokens = usage.prompt_tokens
76+
invocation.output_tokens = usage.completion_tokens
77+
prompt_details: object = get_property_value(
78+
obj=usage, property_name="prompt_tokens_details"
79+
)
80+
completion_details: object = get_property_value(
81+
obj=usage, property_name="completion_tokens_details"
82+
)
83+
invocation.cache_read_input_tokens = get_property_value(
84+
obj=prompt_details, property_name="cached_tokens"
85+
)
86+
invocation.cache_write_input_tokens = get_property_value(
87+
obj=prompt_details, property_name="cache_write_tokens"
88+
)
89+
invocation.thinking_tokens = get_property_value(
90+
obj=completion_details, property_name="reasoning_tokens"
91+
)
92+
invocation.set_input_tokens(
93+
entries=(
94+
(
95+
"audio",
96+
get_property_value(
97+
obj=prompt_details,
98+
property_name="audio_tokens",
99+
),
100+
),
101+
)
102+
)
103+
invocation.set_output_tokens(
104+
entries=(
105+
(
106+
"audio",
107+
get_property_value(
108+
obj=completion_details,
109+
property_name="audio_tokens",
110+
),
111+
),
112+
)
113+
)
114+
115+
71116
def get_server_address_and_port(
72117
client_instance,
73118
) -> tuple[str | None, int | None]:

0 commit comments

Comments
 (0)