Skip to content

Commit 81db657

Browse files
authored
feat(openai): add reasoning attributes (#3336)
1 parent 8fefb87 commit 81db657

File tree

9 files changed

+504
-2
lines changed

9 files changed

+504
-2
lines changed

packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,14 @@ async def _handle_request(span, kwargs, instance):
285285
if Config.enable_trace_context_propagation:
286286
propagate_trace_context(span, kwargs)
287287

288+
# Reasoning request attributes
289+
reasoning_effort = kwargs.get("reasoning_effort")
290+
_set_span_attribute(
291+
span,
292+
SpanAttributes.LLM_REQUEST_REASONING_EFFORT,
293+
reasoning_effort or ()
294+
)
295+
288296

289297
@dont_throw
290298
def _handle_response(
@@ -316,6 +324,28 @@ def _handle_response(
316324
# span attributes
317325
_set_response_attributes(span, response_dict)
318326

327+
# Reasoning usage attributes
328+
usage = response_dict.get("usage")
329+
reasoning_tokens = None
330+
if usage:
331+
# Support both dict-style and object-style `usage`
332+
tokens_details = (
333+
usage.get("completion_tokens_details") if isinstance(usage, dict)
334+
else getattr(usage, "completion_tokens_details", None)
335+
)
336+
337+
if tokens_details:
338+
reasoning_tokens = (
339+
tokens_details.get("reasoning_tokens", None) if isinstance(tokens_details, dict)
340+
else getattr(tokens_details, "reasoning_tokens", None)
341+
)
342+
343+
_set_span_attribute(
344+
span,
345+
SpanAttributes.LLM_USAGE_REASONING_TOKENS,
346+
reasoning_tokens or 0,
347+
)
348+
319349
if should_emit_events():
320350
if response.choices is not None:
321351
for choice in response.choices:

packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/utils.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import traceback
66
from contextlib import asynccontextmanager
77
from importlib.metadata import version
8+
from packaging import version as pkg_version
89

910
from opentelemetry import context as context_api
1011
from opentelemetry._events import EventLogger
@@ -18,7 +19,15 @@
1819

1920

2021
def is_openai_v1():
21-
return _OPENAI_VERSION >= "1.0.0"
22+
return pkg_version.parse(_OPENAI_VERSION) >= pkg_version.parse("1.0.0")
23+
24+
25+
def is_reasoning_supported():
26+
# Reasoning has been introduced in OpenAI API on Dec 17, 2024
27+
# as per https://platform.openai.com/docs/changelog.
28+
# The updated OpenAI library version is 1.58.0
29+
# as per https://pypi.org/project/openai/.
30+
return pkg_version.parse(_OPENAI_VERSION) >= pkg_version.parse("1.58.0")
2231

2332

2433
def is_azure_openai(instance):

packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/v1/responses_wrappers.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ class TracedData(pydantic.BaseModel):
132132
request_model: Optional[str] = pydantic.Field(default=None)
133133
response_model: Optional[str] = pydantic.Field(default=None)
134134

135+
# Reasoning attributes
136+
request_reasoning_summary: Optional[str] = pydantic.Field(default=None)
137+
request_reasoning_effort: Optional[str] = pydantic.Field(default=None)
138+
response_reasoning_effort: Optional[str] = pydantic.Field(default=None)
139+
135140

136141
responses: dict[str, TracedData] = {}
137142

@@ -197,7 +202,46 @@ def set_data_attributes(traced_response: TracedData, span: Span):
197202
SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS,
198203
usage.input_tokens_details.cached_tokens,
199204
)
200-
# TODO: add reasoning tokens in output token details
205+
206+
# Usage - count of reasoning tokens
207+
reasoning_tokens = None
208+
# Support both dict-style and object-style `usage`
209+
tokens_details = (
210+
usage.get("output_tokens_details") if isinstance(usage, dict)
211+
else getattr(usage, "output_tokens_details", None)
212+
)
213+
214+
if tokens_details:
215+
reasoning_tokens = (
216+
tokens_details.get("reasoning_tokens", None) if isinstance(tokens_details, dict)
217+
else getattr(tokens_details, "reasoning_tokens", None)
218+
)
219+
220+
_set_span_attribute(
221+
span,
222+
SpanAttributes.LLM_USAGE_REASONING_TOKENS,
223+
reasoning_tokens or 0,
224+
)
225+
226+
# Reasoning attributes
227+
# Request - reasoning summary
228+
_set_span_attribute(
229+
span,
230+
f"{SpanAttributes.LLM_REQUEST_REASONING_SUMMARY}",
231+
traced_response.request_reasoning_summary or (),
232+
)
233+
# Request - reasoning effort
234+
_set_span_attribute(
235+
span,
236+
f"{SpanAttributes.LLM_REQUEST_REASONING_EFFORT}",
237+
traced_response.request_reasoning_effort or (),
238+
)
239+
# Response - reasoning effort
240+
_set_span_attribute(
241+
span,
242+
f"{SpanAttributes.LLM_RESPONSE_REASONING_EFFORT}",
243+
traced_response.response_reasoning_effort or (),
244+
)
201245

202246
if should_send_prompts():
203247
prompt_index = 0
@@ -416,6 +460,18 @@ def responses_get_or_create_wrapper(tracer: Tracer, wrapped, instance, args, kwa
416460
"model", existing_data.get("request_model", "")
417461
),
418462
response_model=existing_data.get("response_model", ""),
463+
# Reasoning attributes
464+
request_reasoning_summary=(
465+
kwargs.get("reasoning", {}).get(
466+
"summary", existing_data.get("request_reasoning_summary")
467+
)
468+
),
469+
request_reasoning_effort=(
470+
kwargs.get("reasoning", {}).get(
471+
"effort", existing_data.get("request_reasoning_effort")
472+
)
473+
),
474+
response_reasoning_effort=kwargs.get("reasoning", {}).get("effort"),
419475
)
420476
except Exception:
421477
traced_data = None
@@ -467,6 +523,18 @@ def responses_get_or_create_wrapper(tracer: Tracer, wrapped, instance, args, kwa
467523
output_text=existing_data.get("output_text", parsed_response_output_text),
468524
request_model=existing_data.get("request_model", kwargs.get("model")),
469525
response_model=existing_data.get("response_model", parsed_response.model),
526+
# Reasoning attributes
527+
request_reasoning_summary=(
528+
kwargs.get("reasoning", {}).get(
529+
"summary", existing_data.get("request_reasoning_summary")
530+
)
531+
),
532+
request_reasoning_effort=(
533+
kwargs.get("reasoning", {}).get(
534+
"effort", existing_data.get("request_reasoning_effort")
535+
)
536+
),
537+
response_reasoning_effort=kwargs.get("reasoning", {}).get("effort"),
470538
)
471539
responses[parsed_response.id] = traced_data
472540
except Exception:
@@ -518,6 +586,18 @@ async def async_responses_get_or_create_wrapper(
518586
output_text=kwargs.get("output_text", existing_data.get("output_text")),
519587
request_model=kwargs.get("model", existing_data.get("request_model")),
520588
response_model=existing_data.get("response_model"),
589+
# Reasoning attributes
590+
request_reasoning_summary=(
591+
kwargs.get("reasoning", {}).get(
592+
"summary", existing_data.get("request_reasoning_summary")
593+
)
594+
),
595+
request_reasoning_effort=(
596+
kwargs.get("reasoning", {}).get(
597+
"effort", existing_data.get("request_reasoning_effort")
598+
)
599+
),
600+
response_reasoning_effort=kwargs.get("reasoning", {}).get("effort"),
521601
)
522602
except Exception:
523603
traced_data = None
@@ -570,6 +650,18 @@ async def async_responses_get_or_create_wrapper(
570650
output_text=existing_data.get("output_text", parsed_response_output_text),
571651
request_model=existing_data.get("request_model", kwargs.get("model")),
572652
response_model=existing_data.get("response_model", parsed_response.model),
653+
# Reasoning attributes
654+
request_reasoning_summary=(
655+
kwargs.get("reasoning", {}).get(
656+
"summary", existing_data.get("request_reasoning_summary")
657+
)
658+
),
659+
request_reasoning_effort=(
660+
kwargs.get("reasoning", {}).get(
661+
"effort", existing_data.get("request_reasoning_effort")
662+
)
663+
),
664+
response_reasoning_effort=kwargs.get("reasoning", {}).get("effort"),
573665
)
574666
responses[parsed_response.id] = traced_data
575667
except Exception:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
interactions:
2+
- request:
3+
body: '{"messages": [{"role": "user", "content": "Count r''s in strawberry"}],
4+
"model": "gpt-5-nano", "reasoning_effort": "low"}'
5+
headers:
6+
accept:
7+
- application/json
8+
accept-encoding:
9+
- gzip, deflate
10+
connection:
11+
- keep-alive
12+
content-length:
13+
- '120'
14+
content-type:
15+
- application/json
16+
host:
17+
- traceloop-stg.openai.azure.com
18+
user-agent:
19+
- AzureOpenAI/Python 1.99.7
20+
x-stainless-arch:
21+
- x64
22+
x-stainless-async:
23+
- 'false'
24+
x-stainless-lang:
25+
- python
26+
x-stainless-os:
27+
- Linux
28+
x-stainless-package-version:
29+
- 1.99.7
30+
x-stainless-read-timeout:
31+
- '600'
32+
x-stainless-retry-count:
33+
- '0'
34+
x-stainless-runtime:
35+
- CPython
36+
x-stainless-runtime-version:
37+
- 3.13.5
38+
method: POST
39+
uri: https://traceloop-stg.openai.azure.com/openai/deployments/gpt-5-nano/chat/completions?api-version=2024-02-01
40+
response:
41+
body:
42+
string: '{"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"filtered":false,"detected":false},"protected_material_text":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"finish_reason":"stop","index":0,"logprobs":null,"message":{"annotations":[],"content":"3","refusal":null,"role":"assistant"}}],"created":1755601034,"id":"chatcmpl-C6EJeKZdEaC0VeeKH3lWwJBjCTcpd","model":"gpt-5-nano-2025-08-07","object":"chat.completion","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"jailbreak":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}],"system_fingerprint":null,"usage":{"completion_tokens":203,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":192,"rejected_prediction_tokens":0},"prompt_tokens":11,"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0},"total_tokens":214}}
43+
44+
'
45+
headers:
46+
Content-Length:
47+
- '1204'
48+
Content-Type:
49+
- application/json
50+
Date:
51+
- Tue, 19 Aug 2025 10:57:14 GMT
52+
Strict-Transport-Security:
53+
- max-age=31536000; includeSubDomains; preload
54+
apim-request-id:
55+
- aebd8320-f701-4e7d-801f-2955f84e3811
56+
azureml-model-session:
57+
- d004-20250815200304
58+
x-accel-buffering:
59+
- 'no'
60+
x-content-type-options:
61+
- nosniff
62+
x-ms-deployment-name:
63+
- gpt-5-nano
64+
x-ms-rai-invoked:
65+
- 'true'
66+
x-ms-region:
67+
- East US 2
68+
x-ratelimit-limit-requests:
69+
- '100'
70+
x-ratelimit-limit-tokens:
71+
- '100000'
72+
x-ratelimit-remaining-requests:
73+
- '99'
74+
x-ratelimit-remaining-tokens:
75+
- '99994'
76+
x-request-id:
77+
- 7acf5821-70fa-4fab-b202-ba3700578d08
78+
status:
79+
code: 200
80+
message: OK
81+
version: 1
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
interactions:
2+
- request:
3+
body: '{"messages": [{"role": "user", "content": "Count r''s in strawberry"}],
4+
"model": "gpt-5-nano", "reasoning_effort": "low"}'
5+
headers:
6+
accept:
7+
- application/json
8+
accept-encoding:
9+
- gzip, deflate
10+
connection:
11+
- keep-alive
12+
content-length:
13+
- '120'
14+
content-type:
15+
- application/json
16+
host:
17+
- api.openai.com
18+
user-agent:
19+
- OpenAI/Python 1.99.7
20+
x-stainless-arch:
21+
- x64
22+
x-stainless-async:
23+
- 'false'
24+
x-stainless-lang:
25+
- python
26+
x-stainless-os:
27+
- Linux
28+
x-stainless-package-version:
29+
- 1.99.7
30+
x-stainless-read-timeout:
31+
- '600'
32+
x-stainless-retry-count:
33+
- '0'
34+
x-stainless-runtime:
35+
- CPython
36+
x-stainless-runtime-version:
37+
- 3.13.5
38+
method: POST
39+
uri: https://api.openai.com/v1/chat/completions
40+
response:
41+
body:
42+
string: !!binary |
43+
H4sIAAAAAAAAA3SSQY/bIBCF7/4VIy7bSvbKceTGzrHtsadVe2pWFguTQIMBwbhtusp/ryBp7FV3
44+
Lxz45j3mzfBcADAt2RaYUJzE6E316cPnb2P9havuT3t8iA9K0bHfONXiqf7IyqRwTz9Q0D/VvXCj
45+
N0ja2QsWATlhcl1t2rbtN11XZzA6iSbJDp6qtrLcuqqpm7aqu6reXMXKaYGRbeF7AQDwnM/UppX4
46+
m20hW+WbEWPkB2TbWxEAC86kG8Zj1JG4JVbOUDhLaHPn653d2a8KAwIPCKQCItyFOzBIhCGCtrBj
47+
kQL/9YQhnHYM3nkXdYoZYV1CVwK3Evr398sXAu6nyFNIOxmzANxaRzyrU7bHKznf0uy11VENAXl0
48+
NnUYyXmW6bkAeMzTmV4EZj640dNA7ojZdrW62LF5JTNsmu5KyRE3C7Duy1f8BonEtYmL+TLBhUI5
49+
S+dl8ElqtwDFIt3/7bzmfUmu7WGRp2/efGAGQqAnlIMPKLV4GXouC5g+7VtltznnllnE8FMLHEhj
50+
SLuQuOeTufwlFk+RcBz22h4w+KDzh0rrLs7FXwAAAP//AwBrW0kCUgMAAA==
51+
headers:
52+
CF-RAY:
53+
- 9718d3ff7b437f99-MAA
54+
Connection:
55+
- keep-alive
56+
Content-Encoding:
57+
- gzip
58+
Content-Type:
59+
- application/json
60+
Date:
61+
- Tue, 19 Aug 2025 10:04:43 GMT
62+
Server:
63+
- cloudflare
64+
Set-Cookie:
65+
- REDACTED
66+
- REDACTED
67+
Strict-Transport-Security:
68+
- max-age=31536000; includeSubDomains; preload
69+
Transfer-Encoding:
70+
- chunked
71+
X-Content-Type-Options:
72+
- nosniff
73+
access-control-expose-headers:
74+
- X-Request-ID
75+
alt-svc:
76+
- h3=":443"; ma=86400
77+
cf-cache-status:
78+
- DYNAMIC
79+
openai-organization:
80+
- REDACTED
81+
openai-processing-ms:
82+
- '3082'
83+
openai-project:
84+
- REDACTED
85+
openai-version:
86+
- '2020-10-01'
87+
x-envoy-upstream-service-time:
88+
- '3130'
89+
x-ratelimit-limit-requests:
90+
- '500'
91+
x-ratelimit-limit-tokens:
92+
- '200000'
93+
x-ratelimit-remaining-requests:
94+
- '499'
95+
x-ratelimit-remaining-tokens:
96+
- '199992'
97+
x-ratelimit-reset-requests:
98+
- 120ms
99+
x-ratelimit-reset-tokens:
100+
- 2ms
101+
x-request-id:
102+
- req_fb455d524b7f4775956fba99734cc8d9
103+
status:
104+
code: 200
105+
message: OK
106+
version: 1

0 commit comments

Comments
 (0)