opentelemetry-instrumentation-openai 0.34.1__py3-none-any.whl → 0.49.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of opentelemetry-instrumentation-openai might be problematic. Click here for more details.
- opentelemetry/instrumentation/openai/__init__.py +11 -6
- opentelemetry/instrumentation/openai/shared/__init__.py +167 -68
- opentelemetry/instrumentation/openai/shared/chat_wrappers.py +544 -231
- opentelemetry/instrumentation/openai/shared/completion_wrappers.py +143 -81
- opentelemetry/instrumentation/openai/shared/config.py +8 -3
- opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py +91 -30
- opentelemetry/instrumentation/openai/shared/event_emitter.py +108 -0
- opentelemetry/instrumentation/openai/shared/event_models.py +41 -0
- opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py +1 -1
- opentelemetry/instrumentation/openai/shared/span_utils.py +0 -0
- opentelemetry/instrumentation/openai/utils.py +42 -9
- opentelemetry/instrumentation/openai/v0/__init__.py +32 -11
- opentelemetry/instrumentation/openai/v1/__init__.py +177 -69
- opentelemetry/instrumentation/openai/v1/assistant_wrappers.py +208 -109
- opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py +41 -19
- opentelemetry/instrumentation/openai/v1/responses_wrappers.py +1073 -0
- opentelemetry/instrumentation/openai/version.py +1 -1
- {opentelemetry_instrumentation_openai-0.34.1.dist-info → opentelemetry_instrumentation_openai-0.49.3.dist-info}/METADATA +7 -8
- opentelemetry_instrumentation_openai-0.49.3.dist-info/RECORD +21 -0
- {opentelemetry_instrumentation_openai-0.34.1.dist-info → opentelemetry_instrumentation_openai-0.49.3.dist-info}/WHEEL +1 -1
- opentelemetry_instrumentation_openai-0.34.1.dist-info/RECORD +0 -17
- {opentelemetry_instrumentation_openai-0.34.1.dist-info → opentelemetry_instrumentation_openai-0.49.3.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pydantic
|
|
3
|
+
import re
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Optional, Union
|
|
7
|
+
|
|
8
|
+
from openai import AsyncStream, Stream
|
|
9
|
+
from openai._legacy_response import LegacyAPIResponse
|
|
10
|
+
from opentelemetry import context as context_api
|
|
11
|
+
from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY
|
|
12
|
+
from opentelemetry.semconv._incubating.attributes import (
|
|
13
|
+
gen_ai_attributes as GenAIAttributes,
|
|
14
|
+
openai_attributes as OpenAIAttributes,
|
|
15
|
+
)
|
|
16
|
+
from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
|
|
17
|
+
from opentelemetry.semconv_ai import SpanAttributes
|
|
18
|
+
from opentelemetry.trace import SpanKind, Span, StatusCode, Tracer
|
|
19
|
+
from typing_extensions import NotRequired
|
|
20
|
+
from wrapt import ObjectProxy
|
|
21
|
+
|
|
22
|
+
from opentelemetry.instrumentation.openai.shared import (
|
|
23
|
+
_extract_model_name_from_provider_format,
|
|
24
|
+
_set_request_attributes,
|
|
25
|
+
_set_span_attribute,
|
|
26
|
+
model_as_dict,
|
|
27
|
+
)
|
|
28
|
+
from opentelemetry.instrumentation.openai.utils import (
|
|
29
|
+
_with_tracer_wrapper,
|
|
30
|
+
dont_throw,
|
|
31
|
+
should_send_prompts,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_openai_sentinel_types() -> tuple:
|
|
36
|
+
"""Dynamically discover OpenAI sentinel types available in this SDK version.
|
|
37
|
+
|
|
38
|
+
OpenAI SDK uses sentinel objects (NOT_GIVEN, Omit) for unset optional parameters.
|
|
39
|
+
These types may not exist in older SDK versions, so we discover them at runtime.
|
|
40
|
+
"""
|
|
41
|
+
sentinel_types = []
|
|
42
|
+
try:
|
|
43
|
+
from openai import NotGiven
|
|
44
|
+
sentinel_types.append(NotGiven)
|
|
45
|
+
except ImportError:
|
|
46
|
+
pass
|
|
47
|
+
try:
|
|
48
|
+
from openai import Omit
|
|
49
|
+
sentinel_types.append(Omit)
|
|
50
|
+
except ImportError:
|
|
51
|
+
pass
|
|
52
|
+
return tuple(sentinel_types)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Tuple of OpenAI sentinel types for isinstance() checks (empty if none available)
|
|
56
|
+
_OPENAI_SENTINEL_TYPES: tuple = _get_openai_sentinel_types()
|
|
57
|
+
|
|
58
|
+
# Conditional imports for backward compatibility
|
|
59
|
+
try:
|
|
60
|
+
from openai.types.responses import (
|
|
61
|
+
FunctionToolParam,
|
|
62
|
+
Response,
|
|
63
|
+
ResponseInputItemParam,
|
|
64
|
+
ResponseInputParam,
|
|
65
|
+
ResponseOutputItem,
|
|
66
|
+
ResponseUsage,
|
|
67
|
+
ToolParam,
|
|
68
|
+
)
|
|
69
|
+
from openai.types.responses.response_output_message_param import (
|
|
70
|
+
ResponseOutputMessageParam,
|
|
71
|
+
)
|
|
72
|
+
RESPONSES_AVAILABLE = True
|
|
73
|
+
except ImportError:
|
|
74
|
+
# Fallback types for older OpenAI SDK versions
|
|
75
|
+
from typing import Dict, List
|
|
76
|
+
|
|
77
|
+
# Create basic fallback types
|
|
78
|
+
FunctionToolParam = Dict[str, Any]
|
|
79
|
+
Response = Any
|
|
80
|
+
ResponseInputItemParam = Dict[str, Any]
|
|
81
|
+
ResponseInputParam = Union[str, List[Dict[str, Any]]]
|
|
82
|
+
ResponseOutputItem = Dict[str, Any]
|
|
83
|
+
ResponseUsage = Dict[str, Any]
|
|
84
|
+
ToolParam = Dict[str, Any]
|
|
85
|
+
ResponseOutputMessageParam = Dict[str, Any]
|
|
86
|
+
RESPONSES_AVAILABLE = False
|
|
87
|
+
|
|
88
|
+
SPAN_NAME = "openai.response"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _sanitize_sentinel_values(kwargs: dict) -> dict:
|
|
92
|
+
"""Remove OpenAI sentinel values (NOT_GIVEN, Omit) from kwargs.
|
|
93
|
+
|
|
94
|
+
OpenAI SDK uses sentinel objects for unset optional parameters.
|
|
95
|
+
These don't have dict methods like .get(), causing errors when
|
|
96
|
+
code chains calls like kwargs.get("reasoning", {}).get("summary").
|
|
97
|
+
|
|
98
|
+
This removes sentinel values so the default (e.g., {}) is used instead
|
|
99
|
+
when calling .get() on the sanitized dict.
|
|
100
|
+
|
|
101
|
+
If no sentinel types are available (older SDK), returns kwargs unchanged.
|
|
102
|
+
"""
|
|
103
|
+
if not _OPENAI_SENTINEL_TYPES:
|
|
104
|
+
return kwargs
|
|
105
|
+
return {k: v for k, v in kwargs.items()
|
|
106
|
+
if not isinstance(v, _OPENAI_SENTINEL_TYPES)}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def prepare_input_param(input_param: ResponseInputItemParam) -> ResponseInputItemParam:
|
|
110
|
+
"""
|
|
111
|
+
Looks like OpenAI API infers the type "message" if the shape is correct,
|
|
112
|
+
but type is not specified.
|
|
113
|
+
It is marked as required on the message types. We add this to our
|
|
114
|
+
traced data to make it work.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
d = model_as_dict(input_param)
|
|
118
|
+
if "type" not in d:
|
|
119
|
+
d["type"] = "message"
|
|
120
|
+
if RESPONSES_AVAILABLE:
|
|
121
|
+
return ResponseInputItemParam(**d)
|
|
122
|
+
else:
|
|
123
|
+
return d
|
|
124
|
+
except Exception:
|
|
125
|
+
return input_param
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def process_input(inp: ResponseInputParam) -> ResponseInputParam:
|
|
129
|
+
if not isinstance(inp, list):
|
|
130
|
+
return inp
|
|
131
|
+
return [prepare_input_param(item) for item in inp]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_validator_iterator(content):
|
|
135
|
+
"""
|
|
136
|
+
Some OpenAI objects contain fields typed as Iterable, which pydantic
|
|
137
|
+
internally converts to a ValidatorIterator, and they cannot be trivially
|
|
138
|
+
serialized without consuming the iterator to, for example, a list.
|
|
139
|
+
|
|
140
|
+
See: https://github.com/pydantic/pydantic/issues/9541#issuecomment-2189045051
|
|
141
|
+
"""
|
|
142
|
+
return re.search(r"pydantic.*ValidatorIterator'>$", str(type(content)))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# OpenAI API accepts output messages without an ID in its inputs, but
|
|
146
|
+
# the ID is marked as required in the output type.
|
|
147
|
+
if RESPONSES_AVAILABLE:
|
|
148
|
+
class ResponseOutputMessageParamWithoutId(ResponseOutputMessageParam):
|
|
149
|
+
id: NotRequired[str]
|
|
150
|
+
else:
|
|
151
|
+
# Fallback for older SDK versions
|
|
152
|
+
ResponseOutputMessageParamWithoutId = dict
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TracedData(pydantic.BaseModel):
|
|
156
|
+
start_time: float # time.time_ns()
|
|
157
|
+
response_id: str
|
|
158
|
+
# actually Union[str, list[Union[ResponseInputItemParam, ResponseOutputMessageParamWithoutId]]],
|
|
159
|
+
# but this only works properly in Python 3.10+ / newer pydantic
|
|
160
|
+
input: Any
|
|
161
|
+
# system message
|
|
162
|
+
instructions: Optional[str] = pydantic.Field(default=None)
|
|
163
|
+
# TODO: remove Any with newer Python / pydantic
|
|
164
|
+
tools: Optional[list[Union[Any, ToolParam]]] = pydantic.Field(default=None)
|
|
165
|
+
output_blocks: Optional[dict[str, ResponseOutputItem]] = pydantic.Field(
|
|
166
|
+
default=None
|
|
167
|
+
)
|
|
168
|
+
usage: Optional[ResponseUsage] = pydantic.Field(default=None)
|
|
169
|
+
output_text: Optional[str] = pydantic.Field(default=None)
|
|
170
|
+
request_model: Optional[str] = pydantic.Field(default=None)
|
|
171
|
+
response_model: Optional[str] = pydantic.Field(default=None)
|
|
172
|
+
|
|
173
|
+
# Reasoning attributes
|
|
174
|
+
request_reasoning_summary: Optional[str] = pydantic.Field(default=None)
|
|
175
|
+
request_reasoning_effort: Optional[str] = pydantic.Field(default=None)
|
|
176
|
+
response_reasoning_effort: Optional[str] = pydantic.Field(default=None)
|
|
177
|
+
|
|
178
|
+
# OpenAI service tier
|
|
179
|
+
request_service_tier: Optional[str] = pydantic.Field(default=None)
|
|
180
|
+
response_service_tier: Optional[str] = pydantic.Field(default=None)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
responses: dict[str, TracedData] = {}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parse_response(response: Union[LegacyAPIResponse, Response]) -> Response:
|
|
187
|
+
if isinstance(response, LegacyAPIResponse):
|
|
188
|
+
return response.parse()
|
|
189
|
+
return response
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_tools_from_kwargs(kwargs: dict) -> list[ToolParam]:
|
|
193
|
+
tools_input = kwargs.get("tools", [])
|
|
194
|
+
# Handle case where tools key exists but value is None
|
|
195
|
+
# (e.g., when wrappers like openai-guardrails pass tools=None)
|
|
196
|
+
if tools_input is None:
|
|
197
|
+
tools_input = []
|
|
198
|
+
tools = []
|
|
199
|
+
|
|
200
|
+
for tool in tools_input:
|
|
201
|
+
if tool.get("type") == "function":
|
|
202
|
+
if RESPONSES_AVAILABLE:
|
|
203
|
+
tools.append(FunctionToolParam(**tool))
|
|
204
|
+
else:
|
|
205
|
+
tools.append(tool)
|
|
206
|
+
|
|
207
|
+
return tools
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def process_content_block(
|
|
211
|
+
block: dict[str, Any],
|
|
212
|
+
) -> dict[str, Any]:
|
|
213
|
+
# TODO: keep the original type once backend supports it
|
|
214
|
+
if block.get("type") in ["text", "input_text", "output_text"]:
|
|
215
|
+
return {"type": "text", "text": block.get("text")}
|
|
216
|
+
elif block.get("type") in ["image", "input_image", "output_image"]:
|
|
217
|
+
return {
|
|
218
|
+
"type": "image",
|
|
219
|
+
"image_url": block.get("image_url"),
|
|
220
|
+
"detail": block.get("detail"),
|
|
221
|
+
"file_id": block.get("file_id"),
|
|
222
|
+
}
|
|
223
|
+
elif block.get("type") in ["file", "input_file", "output_file"]:
|
|
224
|
+
return {
|
|
225
|
+
"type": "file",
|
|
226
|
+
"file_id": block.get("file_id"),
|
|
227
|
+
"filename": block.get("filename"),
|
|
228
|
+
"file_data": block.get("file_data"),
|
|
229
|
+
}
|
|
230
|
+
return block
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dont_throw
|
|
234
|
+
def prepare_kwargs_for_shared_attributes(kwargs):
|
|
235
|
+
"""
|
|
236
|
+
Prepare kwargs for the shared _set_request_attributes function.
|
|
237
|
+
Maps responses API specific parameters to the common format.
|
|
238
|
+
"""
|
|
239
|
+
prepared_kwargs = kwargs.copy()
|
|
240
|
+
|
|
241
|
+
# Map max_output_tokens to max_tokens for the shared function
|
|
242
|
+
if "max_output_tokens" in kwargs:
|
|
243
|
+
prepared_kwargs["max_tokens"] = kwargs["max_output_tokens"]
|
|
244
|
+
|
|
245
|
+
return prepared_kwargs
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def set_data_attributes(traced_response: TracedData, span: Span):
|
|
249
|
+
_set_span_attribute(span, GenAIAttributes.GEN_AI_RESPONSE_ID, traced_response.response_id)
|
|
250
|
+
|
|
251
|
+
response_model = _extract_model_name_from_provider_format(traced_response.response_model)
|
|
252
|
+
_set_span_attribute(span, GenAIAttributes.GEN_AI_RESPONSE_MODEL, response_model)
|
|
253
|
+
|
|
254
|
+
_set_span_attribute(span, OpenAIAttributes.OPENAI_RESPONSE_SERVICE_TIER, traced_response.response_service_tier)
|
|
255
|
+
if usage := traced_response.usage:
|
|
256
|
+
_set_span_attribute(span, GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS, usage.input_tokens)
|
|
257
|
+
_set_span_attribute(span, GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS, usage.output_tokens)
|
|
258
|
+
_set_span_attribute(
|
|
259
|
+
span, SpanAttributes.LLM_USAGE_TOTAL_TOKENS, usage.total_tokens
|
|
260
|
+
)
|
|
261
|
+
if usage.input_tokens_details:
|
|
262
|
+
_set_span_attribute(
|
|
263
|
+
span,
|
|
264
|
+
SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS,
|
|
265
|
+
usage.input_tokens_details.cached_tokens,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
reasoning_tokens = None
|
|
269
|
+
tokens_details = (
|
|
270
|
+
usage.get("output_tokens_details") if isinstance(usage, dict)
|
|
271
|
+
else getattr(usage, "output_tokens_details", None)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if tokens_details:
|
|
275
|
+
reasoning_tokens = (
|
|
276
|
+
tokens_details.get("reasoning_tokens", None) if isinstance(tokens_details, dict)
|
|
277
|
+
else getattr(tokens_details, "reasoning_tokens", None)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
_set_span_attribute(
|
|
281
|
+
span,
|
|
282
|
+
SpanAttributes.LLM_USAGE_REASONING_TOKENS,
|
|
283
|
+
reasoning_tokens or 0,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
_set_span_attribute(
|
|
287
|
+
span,
|
|
288
|
+
f"{SpanAttributes.LLM_REQUEST_REASONING_SUMMARY}",
|
|
289
|
+
traced_response.request_reasoning_summary or (),
|
|
290
|
+
)
|
|
291
|
+
_set_span_attribute(
|
|
292
|
+
span,
|
|
293
|
+
f"{SpanAttributes.LLM_REQUEST_REASONING_EFFORT}",
|
|
294
|
+
traced_response.request_reasoning_effort or (),
|
|
295
|
+
)
|
|
296
|
+
_set_span_attribute(
|
|
297
|
+
span,
|
|
298
|
+
f"{SpanAttributes.LLM_RESPONSE_REASONING_EFFORT}",
|
|
299
|
+
traced_response.response_reasoning_effort or (),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if should_send_prompts():
|
|
303
|
+
prompt_index = 0
|
|
304
|
+
if traced_response.tools:
|
|
305
|
+
for i, tool_param in enumerate(traced_response.tools):
|
|
306
|
+
tool_dict = model_as_dict(tool_param)
|
|
307
|
+
description = tool_dict.get("description")
|
|
308
|
+
parameters = tool_dict.get("parameters")
|
|
309
|
+
name = tool_dict.get("name")
|
|
310
|
+
if parameters is None:
|
|
311
|
+
continue
|
|
312
|
+
_set_span_attribute(
|
|
313
|
+
span,
|
|
314
|
+
f"{SpanAttributes.LLM_REQUEST_FUNCTIONS}.{i}.description",
|
|
315
|
+
description,
|
|
316
|
+
)
|
|
317
|
+
_set_span_attribute(
|
|
318
|
+
span,
|
|
319
|
+
f"{SpanAttributes.LLM_REQUEST_FUNCTIONS}.{i}.parameters",
|
|
320
|
+
json.dumps(parameters),
|
|
321
|
+
)
|
|
322
|
+
_set_span_attribute(
|
|
323
|
+
span,
|
|
324
|
+
f"{SpanAttributes.LLM_REQUEST_FUNCTIONS}.{i}.name",
|
|
325
|
+
name,
|
|
326
|
+
)
|
|
327
|
+
if traced_response.instructions:
|
|
328
|
+
_set_span_attribute(
|
|
329
|
+
span,
|
|
330
|
+
f"{GenAIAttributes.GEN_AI_PROMPT}.{prompt_index}.content",
|
|
331
|
+
traced_response.instructions,
|
|
332
|
+
)
|
|
333
|
+
_set_span_attribute(span, f"{GenAIAttributes.GEN_AI_PROMPT}.{prompt_index}.role", "system")
|
|
334
|
+
prompt_index += 1
|
|
335
|
+
|
|
336
|
+
if isinstance(traced_response.input, str):
|
|
337
|
+
_set_span_attribute(
|
|
338
|
+
span, f"{GenAIAttributes.GEN_AI_PROMPT}.{prompt_index}.content", traced_response.input
|
|
339
|
+
)
|
|
340
|
+
_set_span_attribute(span, f"{GenAIAttributes.GEN_AI_PROMPT}.{prompt_index}.role", "user")
|
|
341
|
+
prompt_index += 1
|
|
342
|
+
else:
|
|
343
|
+
for block in traced_response.input:
|
|
344
|
+
block_dict = model_as_dict(block)
|
|
345
|
+
if block_dict.get("type", "message") == "message":
|
|
346
|
+
content = block_dict.get("content")
|
|
347
|
+
if is_validator_iterator(content):
|
|
348
|
+
# we're after the actual call here, so we can consume the iterator
|
|
349
|
+
content = [process_content_block(block) for block in content]
|
|
350
|
+
try:
|
|
351
|
+
stringified_content = (
|
|
352
|
+
content if isinstance(content, str) else json.dumps(content)
|
|
353
|
+
)
|
|
354
|
+
except Exception:
|
|
355
|
+
stringified_content = (
|
|
356
|
+
str(content) if content is not None else ""
|
|
357
|
+
)
|
|
358
|
+
_set_span_attribute(
|
|
359
|
+
span,
|
|
360
|
+
f"{GenAIAttributes.GEN_AI_PROMPT}.{prompt_index}.content",
|
|
361
|
+
stringified_content,
|
|
362
|
+
)
|
|
363
|
+
_set_span_attribute(
|
|
364
|
+
span,
|
|
365
|
+
f"{GenAIAttributes.GEN_AI_PROMPT}.{prompt_index}.role",
|
|
366
|
+
block_dict.get("role"),
|
|
367
|
+
)
|
|
368
|
+
prompt_index += 1
|
|
369
|
+
elif block_dict.get("type") == "computer_call_output":
|
|
370
|
+
_set_span_attribute(
|
|
371
|
+
span, f"{GenAIAttributes.GEN_AI_PROMPT}.{prompt_index}.role", "computer-call"
|
|
372
|
+
)
|
|
373
|
+
output_image_url = block_dict.get("output", {}).get("image_url")
|
|
374
|
+
if output_image_url:
|
|
375
|
+
_set_span_attribute(
|
|
376
|
+
span,
|
|
377
|
+
f"{GenAIAttributes.GEN_AI_PROMPT}.{prompt_index}.content",
|
|
378
|
+
json.dumps(
|
|
379
|
+
[
|
|
380
|
+
{
|
|
381
|
+
"type": "image_url",
|
|
382
|
+
"image_url": {"url": output_image_url},
|
|
383
|
+
}
|
|
384
|
+
]
|
|
385
|
+
),
|
|
386
|
+
)
|
|
387
|
+
prompt_index += 1
|
|
388
|
+
elif block_dict.get("type") == "computer_call":
|
|
389
|
+
_set_span_attribute(
|
|
390
|
+
span, f"{GenAIAttributes.GEN_AI_PROMPT}.{prompt_index}.role", "assistant"
|
|
391
|
+
)
|
|
392
|
+
call_content = {}
|
|
393
|
+
if block_dict.get("id"):
|
|
394
|
+
call_content["id"] = block_dict.get("id")
|
|
395
|
+
if block_dict.get("call_id"):
|
|
396
|
+
call_content["call_id"] = block_dict.get("call_id")
|
|
397
|
+
if block_dict.get("action"):
|
|
398
|
+
call_content["action"] = block_dict.get("action")
|
|
399
|
+
_set_span_attribute(
|
|
400
|
+
span,
|
|
401
|
+
f"{GenAIAttributes.GEN_AI_PROMPT}.{prompt_index}.content",
|
|
402
|
+
json.dumps(call_content),
|
|
403
|
+
)
|
|
404
|
+
prompt_index += 1
|
|
405
|
+
# TODO: handle other block types
|
|
406
|
+
|
|
407
|
+
_set_span_attribute(span, f"{GenAIAttributes.GEN_AI_COMPLETION}.0.role", "assistant")
|
|
408
|
+
if traced_response.output_text:
|
|
409
|
+
_set_span_attribute(
|
|
410
|
+
span, f"{GenAIAttributes.GEN_AI_COMPLETION}.0.content", traced_response.output_text
|
|
411
|
+
)
|
|
412
|
+
tool_call_index = 0
|
|
413
|
+
for block in traced_response.output_blocks.values():
|
|
414
|
+
block_dict = model_as_dict(block)
|
|
415
|
+
if block_dict.get("type") == "message":
|
|
416
|
+
# either a refusal or handled in output_text above
|
|
417
|
+
continue
|
|
418
|
+
if block_dict.get("type") == "function_call":
|
|
419
|
+
_set_span_attribute(
|
|
420
|
+
span,
|
|
421
|
+
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.id",
|
|
422
|
+
block_dict.get("id"),
|
|
423
|
+
)
|
|
424
|
+
_set_span_attribute(
|
|
425
|
+
span,
|
|
426
|
+
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.name",
|
|
427
|
+
block_dict.get("name"),
|
|
428
|
+
)
|
|
429
|
+
_set_span_attribute(
|
|
430
|
+
span,
|
|
431
|
+
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.arguments",
|
|
432
|
+
block_dict.get("arguments"),
|
|
433
|
+
)
|
|
434
|
+
tool_call_index += 1
|
|
435
|
+
elif block_dict.get("type") == "file_search_call":
|
|
436
|
+
_set_span_attribute(
|
|
437
|
+
span,
|
|
438
|
+
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.id",
|
|
439
|
+
block_dict.get("id"),
|
|
440
|
+
)
|
|
441
|
+
_set_span_attribute(
|
|
442
|
+
span,
|
|
443
|
+
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.name",
|
|
444
|
+
"file_search_call",
|
|
445
|
+
)
|
|
446
|
+
tool_call_index += 1
|
|
447
|
+
elif block_dict.get("type") == "web_search_call":
|
|
448
|
+
_set_span_attribute(
|
|
449
|
+
span,
|
|
450
|
+
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.id",
|
|
451
|
+
block_dict.get("id"),
|
|
452
|
+
)
|
|
453
|
+
_set_span_attribute(
|
|
454
|
+
span,
|
|
455
|
+
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.name",
|
|
456
|
+
"web_search_call",
|
|
457
|
+
)
|
|
458
|
+
tool_call_index += 1
|
|
459
|
+
elif block_dict.get("type") == "computer_call":
|
|
460
|
+
_set_span_attribute(
|
|
461
|
+
span,
|
|
462
|
+
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.id",
|
|
463
|
+
block_dict.get("call_id"),
|
|
464
|
+
)
|
|
465
|
+
_set_span_attribute(
|
|
466
|
+
span,
|
|
467
|
+
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.name",
|
|
468
|
+
"computer_call",
|
|
469
|
+
)
|
|
470
|
+
_set_span_attribute(
|
|
471
|
+
span,
|
|
472
|
+
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.arguments",
|
|
473
|
+
json.dumps(block_dict.get("action")),
|
|
474
|
+
)
|
|
475
|
+
tool_call_index += 1
|
|
476
|
+
elif block_dict.get("type") == "reasoning":
|
|
477
|
+
reasoning_summary = block_dict.get("summary")
|
|
478
|
+
if reasoning_summary is not None and reasoning_summary != []:
|
|
479
|
+
if isinstance(reasoning_summary, (dict, list)):
|
|
480
|
+
reasoning_value = json.dumps(reasoning_summary)
|
|
481
|
+
else:
|
|
482
|
+
reasoning_value = reasoning_summary
|
|
483
|
+
_set_span_attribute(
|
|
484
|
+
span, f"{GenAIAttributes.GEN_AI_COMPLETION}.0.reasoning", reasoning_value
|
|
485
|
+
)
|
|
486
|
+
# TODO: handle other block types, in particular other calls
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@dont_throw
|
|
490
|
+
@_with_tracer_wrapper
|
|
491
|
+
def responses_get_or_create_wrapper(tracer: Tracer, wrapped, instance, args, kwargs):
|
|
492
|
+
if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
|
|
493
|
+
return wrapped(*args, **kwargs)
|
|
494
|
+
start_time = time.time_ns()
|
|
495
|
+
|
|
496
|
+
# Remove OpenAI sentinel values (NOT_GIVEN, Omit) to allow chained .get() calls
|
|
497
|
+
non_sentinel_kwargs = _sanitize_sentinel_values(kwargs)
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
response = wrapped(*args, **kwargs)
|
|
501
|
+
if isinstance(response, Stream):
|
|
502
|
+
span = tracer.start_span(
|
|
503
|
+
SPAN_NAME,
|
|
504
|
+
kind=SpanKind.CLIENT,
|
|
505
|
+
start_time=start_time,
|
|
506
|
+
)
|
|
507
|
+
_set_request_attributes(span, prepare_kwargs_for_shared_attributes(non_sentinel_kwargs), instance)
|
|
508
|
+
|
|
509
|
+
return ResponseStream(
|
|
510
|
+
span=span,
|
|
511
|
+
response=response,
|
|
512
|
+
start_time=start_time,
|
|
513
|
+
request_kwargs=non_sentinel_kwargs,
|
|
514
|
+
tracer=tracer,
|
|
515
|
+
)
|
|
516
|
+
except Exception as e:
|
|
517
|
+
response_id = non_sentinel_kwargs.get("response_id")
|
|
518
|
+
existing_data = {}
|
|
519
|
+
if response_id and response_id in responses:
|
|
520
|
+
existing_data = responses[response_id].model_dump()
|
|
521
|
+
try:
|
|
522
|
+
traced_data = TracedData(
|
|
523
|
+
start_time=existing_data.get("start_time", start_time),
|
|
524
|
+
response_id=response_id or "",
|
|
525
|
+
input=process_input(
|
|
526
|
+
non_sentinel_kwargs.get("input", existing_data.get("input", []))
|
|
527
|
+
),
|
|
528
|
+
instructions=non_sentinel_kwargs.get(
|
|
529
|
+
"instructions", existing_data.get("instructions")
|
|
530
|
+
),
|
|
531
|
+
tools=get_tools_from_kwargs(non_sentinel_kwargs) or existing_data.get("tools", []),
|
|
532
|
+
output_blocks=existing_data.get("output_blocks", {}),
|
|
533
|
+
usage=existing_data.get("usage"),
|
|
534
|
+
output_text=non_sentinel_kwargs.get(
|
|
535
|
+
"output_text", existing_data.get("output_text", "")
|
|
536
|
+
),
|
|
537
|
+
request_model=non_sentinel_kwargs.get(
|
|
538
|
+
"model", existing_data.get("request_model", "")
|
|
539
|
+
),
|
|
540
|
+
response_model=existing_data.get("response_model", ""),
|
|
541
|
+
# Reasoning attributes
|
|
542
|
+
request_reasoning_summary=(
|
|
543
|
+
non_sentinel_kwargs.get("reasoning", {}).get(
|
|
544
|
+
"summary", existing_data.get("request_reasoning_summary")
|
|
545
|
+
)
|
|
546
|
+
),
|
|
547
|
+
request_reasoning_effort=(
|
|
548
|
+
non_sentinel_kwargs.get("reasoning", {}).get(
|
|
549
|
+
"effort", existing_data.get("request_reasoning_effort")
|
|
550
|
+
)
|
|
551
|
+
),
|
|
552
|
+
response_reasoning_effort=non_sentinel_kwargs.get("reasoning", {}).get("effort"),
|
|
553
|
+
request_service_tier=non_sentinel_kwargs.get("service_tier"),
|
|
554
|
+
response_service_tier=existing_data.get("response_service_tier"),
|
|
555
|
+
)
|
|
556
|
+
except Exception:
|
|
557
|
+
traced_data = None
|
|
558
|
+
|
|
559
|
+
span = tracer.start_span(
|
|
560
|
+
SPAN_NAME,
|
|
561
|
+
kind=SpanKind.CLIENT,
|
|
562
|
+
start_time=(
|
|
563
|
+
start_time if traced_data is None else int(traced_data.start_time)
|
|
564
|
+
),
|
|
565
|
+
)
|
|
566
|
+
_set_request_attributes(span, prepare_kwargs_for_shared_attributes(non_sentinel_kwargs), instance)
|
|
567
|
+
span.set_attribute(ERROR_TYPE, e.__class__.__name__)
|
|
568
|
+
span.record_exception(e)
|
|
569
|
+
span.set_status(StatusCode.ERROR, str(e))
|
|
570
|
+
if traced_data:
|
|
571
|
+
set_data_attributes(traced_data, span)
|
|
572
|
+
span.end()
|
|
573
|
+
raise
|
|
574
|
+
parsed_response = parse_response(response)
|
|
575
|
+
|
|
576
|
+
existing_data = responses.get(parsed_response.id)
|
|
577
|
+
if existing_data is None:
|
|
578
|
+
existing_data = {}
|
|
579
|
+
else:
|
|
580
|
+
existing_data = existing_data.model_dump()
|
|
581
|
+
|
|
582
|
+
request_tools = get_tools_from_kwargs(non_sentinel_kwargs)
|
|
583
|
+
|
|
584
|
+
merged_tools = existing_data.get("tools", []) + request_tools
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
parsed_response_output_text = None
|
|
588
|
+
if hasattr(parsed_response, "output_text"):
|
|
589
|
+
parsed_response_output_text = parsed_response.output_text
|
|
590
|
+
else:
|
|
591
|
+
try:
|
|
592
|
+
parsed_response_output_text = parsed_response.output[0].content[0].text
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
traced_data = TracedData(
|
|
596
|
+
start_time=existing_data.get("start_time", start_time),
|
|
597
|
+
response_id=parsed_response.id,
|
|
598
|
+
input=process_input(existing_data.get("input", non_sentinel_kwargs.get("input"))),
|
|
599
|
+
instructions=existing_data.get("instructions", non_sentinel_kwargs.get("instructions")),
|
|
600
|
+
tools=merged_tools if merged_tools else None,
|
|
601
|
+
output_blocks={block.id: block for block in parsed_response.output}
|
|
602
|
+
| existing_data.get("output_blocks", {}),
|
|
603
|
+
usage=existing_data.get("usage", parsed_response.usage),
|
|
604
|
+
output_text=existing_data.get("output_text", parsed_response_output_text),
|
|
605
|
+
request_model=existing_data.get("request_model", non_sentinel_kwargs.get("model")),
|
|
606
|
+
response_model=existing_data.get("response_model", parsed_response.model),
|
|
607
|
+
# Reasoning attributes
|
|
608
|
+
request_reasoning_summary=(
|
|
609
|
+
non_sentinel_kwargs.get("reasoning", {}).get(
|
|
610
|
+
"summary", existing_data.get("request_reasoning_summary")
|
|
611
|
+
)
|
|
612
|
+
),
|
|
613
|
+
request_reasoning_effort=(
|
|
614
|
+
non_sentinel_kwargs.get("reasoning", {}).get(
|
|
615
|
+
"effort", existing_data.get("request_reasoning_effort")
|
|
616
|
+
)
|
|
617
|
+
),
|
|
618
|
+
response_reasoning_effort=non_sentinel_kwargs.get("reasoning", {}).get("effort"),
|
|
619
|
+
request_service_tier=existing_data.get("request_service_tier", non_sentinel_kwargs.get("service_tier")),
|
|
620
|
+
response_service_tier=existing_data.get("response_service_tier", parsed_response.service_tier),
|
|
621
|
+
)
|
|
622
|
+
responses[parsed_response.id] = traced_data
|
|
623
|
+
except Exception:
|
|
624
|
+
return response
|
|
625
|
+
|
|
626
|
+
if parsed_response.status == "completed":
|
|
627
|
+
span = tracer.start_span(
|
|
628
|
+
SPAN_NAME,
|
|
629
|
+
kind=SpanKind.CLIENT,
|
|
630
|
+
start_time=int(traced_data.start_time),
|
|
631
|
+
)
|
|
632
|
+
_set_request_attributes(span, prepare_kwargs_for_shared_attributes(non_sentinel_kwargs), instance)
|
|
633
|
+
set_data_attributes(traced_data, span)
|
|
634
|
+
span.end()
|
|
635
|
+
|
|
636
|
+
return response
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@dont_throw
|
|
640
|
+
@_with_tracer_wrapper
|
|
641
|
+
async def async_responses_get_or_create_wrapper(
|
|
642
|
+
tracer: Tracer, wrapped, instance, args, kwargs
|
|
643
|
+
):
|
|
644
|
+
if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
|
|
645
|
+
return await wrapped(*args, **kwargs)
|
|
646
|
+
start_time = time.time_ns()
|
|
647
|
+
|
|
648
|
+
# Remove OpenAI sentinel values (NOT_GIVEN, Omit) to allow chained .get() calls
|
|
649
|
+
non_sentinel_kwargs = _sanitize_sentinel_values(kwargs)
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
response = await wrapped(*args, **kwargs)
|
|
653
|
+
if isinstance(response, (Stream, AsyncStream)):
|
|
654
|
+
span = tracer.start_span(
|
|
655
|
+
SPAN_NAME,
|
|
656
|
+
kind=SpanKind.CLIENT,
|
|
657
|
+
start_time=start_time,
|
|
658
|
+
)
|
|
659
|
+
_set_request_attributes(span, prepare_kwargs_for_shared_attributes(non_sentinel_kwargs), instance)
|
|
660
|
+
|
|
661
|
+
return ResponseStream(
|
|
662
|
+
span=span,
|
|
663
|
+
response=response,
|
|
664
|
+
start_time=start_time,
|
|
665
|
+
request_kwargs=non_sentinel_kwargs,
|
|
666
|
+
tracer=tracer,
|
|
667
|
+
)
|
|
668
|
+
except Exception as e:
|
|
669
|
+
response_id = non_sentinel_kwargs.get("response_id")
|
|
670
|
+
existing_data = {}
|
|
671
|
+
if response_id and response_id in responses:
|
|
672
|
+
existing_data = responses[response_id].model_dump()
|
|
673
|
+
try:
|
|
674
|
+
traced_data = TracedData(
|
|
675
|
+
start_time=existing_data.get("start_time", start_time),
|
|
676
|
+
response_id=response_id or "",
|
|
677
|
+
input=process_input(
|
|
678
|
+
non_sentinel_kwargs.get("input", existing_data.get("input", []))
|
|
679
|
+
),
|
|
680
|
+
instructions=non_sentinel_kwargs.get(
|
|
681
|
+
"instructions", existing_data.get("instructions", "")
|
|
682
|
+
),
|
|
683
|
+
tools=get_tools_from_kwargs(non_sentinel_kwargs) or existing_data.get("tools", []),
|
|
684
|
+
output_blocks=existing_data.get("output_blocks", {}),
|
|
685
|
+
usage=existing_data.get("usage"),
|
|
686
|
+
output_text=non_sentinel_kwargs.get("output_text", existing_data.get("output_text")),
|
|
687
|
+
request_model=non_sentinel_kwargs.get("model", existing_data.get("request_model")),
|
|
688
|
+
response_model=existing_data.get("response_model"),
|
|
689
|
+
# Reasoning attributes
|
|
690
|
+
request_reasoning_summary=(
|
|
691
|
+
non_sentinel_kwargs.get("reasoning", {}).get(
|
|
692
|
+
"summary", existing_data.get("request_reasoning_summary")
|
|
693
|
+
)
|
|
694
|
+
),
|
|
695
|
+
request_reasoning_effort=(
|
|
696
|
+
non_sentinel_kwargs.get("reasoning", {}).get(
|
|
697
|
+
"effort", existing_data.get("request_reasoning_effort")
|
|
698
|
+
)
|
|
699
|
+
),
|
|
700
|
+
response_reasoning_effort=non_sentinel_kwargs.get("reasoning", {}).get("effort"),
|
|
701
|
+
request_service_tier=non_sentinel_kwargs.get("service_tier"),
|
|
702
|
+
response_service_tier=existing_data.get("response_service_tier"),
|
|
703
|
+
)
|
|
704
|
+
except Exception:
|
|
705
|
+
traced_data = None
|
|
706
|
+
|
|
707
|
+
span = tracer.start_span(
|
|
708
|
+
SPAN_NAME,
|
|
709
|
+
kind=SpanKind.CLIENT,
|
|
710
|
+
start_time=(
|
|
711
|
+
start_time if traced_data is None else int(traced_data.start_time)
|
|
712
|
+
),
|
|
713
|
+
)
|
|
714
|
+
_set_request_attributes(span, prepare_kwargs_for_shared_attributes(non_sentinel_kwargs), instance)
|
|
715
|
+
span.set_attribute(ERROR_TYPE, e.__class__.__name__)
|
|
716
|
+
span.record_exception(e)
|
|
717
|
+
span.set_status(StatusCode.ERROR, str(e))
|
|
718
|
+
if traced_data:
|
|
719
|
+
set_data_attributes(traced_data, span)
|
|
720
|
+
span.end()
|
|
721
|
+
raise
|
|
722
|
+
parsed_response = parse_response(response)
|
|
723
|
+
|
|
724
|
+
existing_data = responses.get(parsed_response.id)
|
|
725
|
+
if existing_data is None:
|
|
726
|
+
existing_data = {}
|
|
727
|
+
else:
|
|
728
|
+
existing_data = existing_data.model_dump()
|
|
729
|
+
|
|
730
|
+
request_tools = get_tools_from_kwargs(non_sentinel_kwargs)
|
|
731
|
+
|
|
732
|
+
merged_tools = existing_data.get("tools", []) + request_tools
|
|
733
|
+
|
|
734
|
+
try:
|
|
735
|
+
parsed_response_output_text = None
|
|
736
|
+
if hasattr(parsed_response, "output_text"):
|
|
737
|
+
parsed_response_output_text = parsed_response.output_text
|
|
738
|
+
else:
|
|
739
|
+
try:
|
|
740
|
+
parsed_response_output_text = parsed_response.output[0].content[0].text
|
|
741
|
+
except Exception:
|
|
742
|
+
pass
|
|
743
|
+
|
|
744
|
+
traced_data = TracedData(
|
|
745
|
+
start_time=existing_data.get("start_time", start_time),
|
|
746
|
+
response_id=parsed_response.id,
|
|
747
|
+
input=process_input(existing_data.get("input", non_sentinel_kwargs.get("input"))),
|
|
748
|
+
instructions=existing_data.get("instructions", non_sentinel_kwargs.get("instructions")),
|
|
749
|
+
tools=merged_tools if merged_tools else None,
|
|
750
|
+
output_blocks={block.id: block for block in parsed_response.output}
|
|
751
|
+
| existing_data.get("output_blocks", {}),
|
|
752
|
+
usage=existing_data.get("usage", parsed_response.usage),
|
|
753
|
+
output_text=existing_data.get("output_text", parsed_response_output_text),
|
|
754
|
+
request_model=existing_data.get("request_model", non_sentinel_kwargs.get("model")),
|
|
755
|
+
response_model=existing_data.get("response_model", parsed_response.model),
|
|
756
|
+
# Reasoning attributes
|
|
757
|
+
request_reasoning_summary=(
|
|
758
|
+
non_sentinel_kwargs.get("reasoning", {}).get(
|
|
759
|
+
"summary", existing_data.get("request_reasoning_summary")
|
|
760
|
+
)
|
|
761
|
+
),
|
|
762
|
+
request_reasoning_effort=(
|
|
763
|
+
non_sentinel_kwargs.get("reasoning", {}).get(
|
|
764
|
+
"effort", existing_data.get("request_reasoning_effort")
|
|
765
|
+
)
|
|
766
|
+
),
|
|
767
|
+
response_reasoning_effort=non_sentinel_kwargs.get("reasoning", {}).get("effort"),
|
|
768
|
+
request_service_tier=existing_data.get("request_service_tier", non_sentinel_kwargs.get("service_tier")),
|
|
769
|
+
response_service_tier=existing_data.get("response_service_tier", parsed_response.service_tier),
|
|
770
|
+
)
|
|
771
|
+
responses[parsed_response.id] = traced_data
|
|
772
|
+
except Exception:
|
|
773
|
+
return response
|
|
774
|
+
|
|
775
|
+
if parsed_response.status == "completed":
|
|
776
|
+
span = tracer.start_span(
|
|
777
|
+
SPAN_NAME,
|
|
778
|
+
kind=SpanKind.CLIENT,
|
|
779
|
+
start_time=int(traced_data.start_time),
|
|
780
|
+
)
|
|
781
|
+
_set_request_attributes(span, prepare_kwargs_for_shared_attributes(non_sentinel_kwargs), instance)
|
|
782
|
+
set_data_attributes(traced_data, span)
|
|
783
|
+
span.end()
|
|
784
|
+
|
|
785
|
+
return response
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
@dont_throw
|
|
789
|
+
@_with_tracer_wrapper
|
|
790
|
+
def responses_cancel_wrapper(tracer: Tracer, wrapped, instance, args, kwargs):
|
|
791
|
+
if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
|
|
792
|
+
return wrapped(*args, **kwargs)
|
|
793
|
+
|
|
794
|
+
non_sentinel_kwargs = _sanitize_sentinel_values(kwargs)
|
|
795
|
+
|
|
796
|
+
response = wrapped(*args, **kwargs)
|
|
797
|
+
if isinstance(response, Stream):
|
|
798
|
+
return response
|
|
799
|
+
parsed_response = parse_response(response)
|
|
800
|
+
existing_data = responses.pop(parsed_response.id, None)
|
|
801
|
+
if existing_data is not None:
|
|
802
|
+
span = tracer.start_span(
|
|
803
|
+
SPAN_NAME,
|
|
804
|
+
kind=SpanKind.CLIENT,
|
|
805
|
+
start_time=existing_data.start_time,
|
|
806
|
+
record_exception=True,
|
|
807
|
+
)
|
|
808
|
+
_set_request_attributes(span, prepare_kwargs_for_shared_attributes(non_sentinel_kwargs), instance)
|
|
809
|
+
span.record_exception(Exception("Response cancelled"))
|
|
810
|
+
set_data_attributes(existing_data, span)
|
|
811
|
+
span.end()
|
|
812
|
+
return response
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
@dont_throw
|
|
816
|
+
@_with_tracer_wrapper
|
|
817
|
+
async def async_responses_cancel_wrapper(
|
|
818
|
+
tracer: Tracer, wrapped, instance, args, kwargs
|
|
819
|
+
):
|
|
820
|
+
if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
|
|
821
|
+
return await wrapped(*args, **kwargs)
|
|
822
|
+
|
|
823
|
+
non_sentinel_kwargs = _sanitize_sentinel_values(kwargs)
|
|
824
|
+
|
|
825
|
+
response = await wrapped(*args, **kwargs)
|
|
826
|
+
if isinstance(response, (Stream, AsyncStream)):
|
|
827
|
+
return response
|
|
828
|
+
parsed_response = parse_response(response)
|
|
829
|
+
existing_data = responses.pop(parsed_response.id, None)
|
|
830
|
+
if existing_data is not None:
|
|
831
|
+
span = tracer.start_span(
|
|
832
|
+
SPAN_NAME,
|
|
833
|
+
kind=SpanKind.CLIENT,
|
|
834
|
+
start_time=existing_data.start_time,
|
|
835
|
+
record_exception=True,
|
|
836
|
+
)
|
|
837
|
+
_set_request_attributes(span, prepare_kwargs_for_shared_attributes(non_sentinel_kwargs), instance)
|
|
838
|
+
span.record_exception(Exception("Response cancelled"))
|
|
839
|
+
set_data_attributes(existing_data, span)
|
|
840
|
+
span.end()
|
|
841
|
+
return response
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
class ResponseStream(ObjectProxy):
|
|
845
|
+
"""Proxy class for streaming responses to capture telemetry data"""
|
|
846
|
+
|
|
847
|
+
_span = None
|
|
848
|
+
_start_time = None
|
|
849
|
+
_request_kwargs = None
|
|
850
|
+
_tracer = None
|
|
851
|
+
_traced_data = None
|
|
852
|
+
|
|
853
|
+
def __init__(
|
|
854
|
+
self,
|
|
855
|
+
span,
|
|
856
|
+
response,
|
|
857
|
+
start_time=None,
|
|
858
|
+
request_kwargs=None,
|
|
859
|
+
tracer=None,
|
|
860
|
+
traced_data=None,
|
|
861
|
+
):
|
|
862
|
+
super().__init__(response)
|
|
863
|
+
self._span = span
|
|
864
|
+
self._start_time = start_time
|
|
865
|
+
# Filter sentinel values (defensive, in case called directly without prior filtering)
|
|
866
|
+
self._request_kwargs = _sanitize_sentinel_values(request_kwargs or {})
|
|
867
|
+
self._tracer = tracer
|
|
868
|
+
self._traced_data = traced_data or TracedData(
|
|
869
|
+
start_time=start_time,
|
|
870
|
+
response_id="",
|
|
871
|
+
input=process_input(self._request_kwargs.get("input", [])),
|
|
872
|
+
instructions=self._request_kwargs.get("instructions"),
|
|
873
|
+
tools=get_tools_from_kwargs(self._request_kwargs),
|
|
874
|
+
output_blocks={},
|
|
875
|
+
usage=None,
|
|
876
|
+
output_text="",
|
|
877
|
+
request_model=self._request_kwargs.get("model", ""),
|
|
878
|
+
response_model="",
|
|
879
|
+
request_reasoning_summary=self._request_kwargs.get("reasoning", {}).get(
|
|
880
|
+
"summary"
|
|
881
|
+
),
|
|
882
|
+
request_reasoning_effort=self._request_kwargs.get("reasoning", {}).get(
|
|
883
|
+
"effort"
|
|
884
|
+
),
|
|
885
|
+
response_reasoning_effort=None,
|
|
886
|
+
request_service_tier=self._request_kwargs.get("service_tier"),
|
|
887
|
+
response_service_tier=None,
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
self._complete_response_data = None
|
|
891
|
+
self._output_text = ""
|
|
892
|
+
|
|
893
|
+
self._cleanup_completed = False
|
|
894
|
+
self._cleanup_lock = threading.Lock()
|
|
895
|
+
|
|
896
|
+
def __del__(self):
|
|
897
|
+
"""Cleanup when object is garbage collected"""
|
|
898
|
+
if hasattr(self, "_cleanup_completed") and not self._cleanup_completed:
|
|
899
|
+
self._ensure_cleanup()
|
|
900
|
+
|
|
901
|
+
def __enter__(self):
|
|
902
|
+
"""Context manager entry"""
|
|
903
|
+
if hasattr(self.__wrapped__, "__enter__"):
|
|
904
|
+
self.__wrapped__.__enter__()
|
|
905
|
+
return self
|
|
906
|
+
|
|
907
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
908
|
+
"""Context manager exit"""
|
|
909
|
+
suppress = False
|
|
910
|
+
try:
|
|
911
|
+
if exc_type is not None:
|
|
912
|
+
self._handle_exception(exc_val)
|
|
913
|
+
else:
|
|
914
|
+
self._process_complete_response()
|
|
915
|
+
finally:
|
|
916
|
+
if hasattr(self.__wrapped__, "__exit__"):
|
|
917
|
+
suppress = bool(self.__wrapped__.__exit__(exc_type, exc_val, exc_tb))
|
|
918
|
+
return suppress
|
|
919
|
+
|
|
920
|
+
async def __aenter__(self):
|
|
921
|
+
"""Async context manager entry"""
|
|
922
|
+
if hasattr(self.__wrapped__, "__aenter__"):
|
|
923
|
+
await self.__wrapped__.__aenter__()
|
|
924
|
+
return self
|
|
925
|
+
|
|
926
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
927
|
+
"""Async context manager exit"""
|
|
928
|
+
suppress = False
|
|
929
|
+
try:
|
|
930
|
+
if exc_type is not None:
|
|
931
|
+
self._handle_exception(exc_val)
|
|
932
|
+
else:
|
|
933
|
+
self._process_complete_response()
|
|
934
|
+
finally:
|
|
935
|
+
if hasattr(self.__wrapped__, "__aexit__"):
|
|
936
|
+
suppress = bool(await self.__wrapped__.__aexit__(exc_type, exc_val, exc_tb))
|
|
937
|
+
return suppress
|
|
938
|
+
|
|
939
|
+
def close(self):
|
|
940
|
+
try:
|
|
941
|
+
self._ensure_cleanup()
|
|
942
|
+
finally:
|
|
943
|
+
if hasattr(self.__wrapped__, "close"):
|
|
944
|
+
return self.__wrapped__.close()
|
|
945
|
+
|
|
946
|
+
async def aclose(self):
|
|
947
|
+
try:
|
|
948
|
+
self._ensure_cleanup()
|
|
949
|
+
finally:
|
|
950
|
+
if hasattr(self.__wrapped__, "aclose"):
|
|
951
|
+
return await self.__wrapped__.aclose()
|
|
952
|
+
|
|
953
|
+
def __iter__(self):
|
|
954
|
+
"""Synchronous iterator"""
|
|
955
|
+
return self
|
|
956
|
+
|
|
957
|
+
def __next__(self):
|
|
958
|
+
"""Synchronous iteration"""
|
|
959
|
+
try:
|
|
960
|
+
chunk = self.__wrapped__.__next__()
|
|
961
|
+
except StopIteration:
|
|
962
|
+
self._process_complete_response()
|
|
963
|
+
raise
|
|
964
|
+
except Exception as e:
|
|
965
|
+
self._handle_exception(e)
|
|
966
|
+
raise
|
|
967
|
+
else:
|
|
968
|
+
self._process_chunk(chunk)
|
|
969
|
+
return chunk
|
|
970
|
+
|
|
971
|
+
def __aiter__(self):
|
|
972
|
+
"""Async iterator"""
|
|
973
|
+
return self
|
|
974
|
+
|
|
975
|
+
async def __anext__(self):
|
|
976
|
+
"""Async iteration"""
|
|
977
|
+
try:
|
|
978
|
+
chunk = await self.__wrapped__.__anext__()
|
|
979
|
+
except StopAsyncIteration:
|
|
980
|
+
self._process_complete_response()
|
|
981
|
+
raise
|
|
982
|
+
except Exception as e:
|
|
983
|
+
self._handle_exception(e)
|
|
984
|
+
raise
|
|
985
|
+
else:
|
|
986
|
+
self._process_chunk(chunk)
|
|
987
|
+
return chunk
|
|
988
|
+
|
|
989
|
+
def _process_chunk(self, chunk):
|
|
990
|
+
"""Process a streaming chunk"""
|
|
991
|
+
if hasattr(chunk, "type"):
|
|
992
|
+
if chunk.type == "response.output_text.delta":
|
|
993
|
+
if hasattr(chunk, "delta") and chunk.delta:
|
|
994
|
+
self._output_text += chunk.delta
|
|
995
|
+
elif chunk.type == "response.completed" and hasattr(chunk, "response"):
|
|
996
|
+
self._complete_response_data = chunk.response
|
|
997
|
+
|
|
998
|
+
if hasattr(chunk, "delta"):
|
|
999
|
+
if hasattr(chunk.delta, "text") and chunk.delta.text:
|
|
1000
|
+
self._output_text += chunk.delta.text
|
|
1001
|
+
|
|
1002
|
+
if hasattr(chunk, "response") and chunk.response:
|
|
1003
|
+
self._complete_response_data = chunk.response
|
|
1004
|
+
|
|
1005
|
+
@dont_throw
|
|
1006
|
+
def _process_complete_response(self):
|
|
1007
|
+
"""Process the complete response and emit span"""
|
|
1008
|
+
with self._cleanup_lock:
|
|
1009
|
+
if self._cleanup_completed:
|
|
1010
|
+
return
|
|
1011
|
+
|
|
1012
|
+
try:
|
|
1013
|
+
if self._complete_response_data:
|
|
1014
|
+
parsed_response = parse_response(self._complete_response_data)
|
|
1015
|
+
|
|
1016
|
+
self._traced_data.response_id = parsed_response.id
|
|
1017
|
+
self._traced_data.response_model = parsed_response.model
|
|
1018
|
+
self._traced_data.output_text = self._output_text
|
|
1019
|
+
|
|
1020
|
+
if parsed_response.usage:
|
|
1021
|
+
self._traced_data.usage = parsed_response.usage
|
|
1022
|
+
|
|
1023
|
+
if parsed_response.output:
|
|
1024
|
+
self._traced_data.output_blocks = {
|
|
1025
|
+
block.id: block for block in parsed_response.output
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
responses[parsed_response.id] = self._traced_data
|
|
1029
|
+
|
|
1030
|
+
set_data_attributes(self._traced_data, self._span)
|
|
1031
|
+
self._span.set_status(StatusCode.OK)
|
|
1032
|
+
self._span.end()
|
|
1033
|
+
self._cleanup_completed = True
|
|
1034
|
+
|
|
1035
|
+
except Exception as e:
|
|
1036
|
+
if self._span and self._span.is_recording():
|
|
1037
|
+
self._span.set_attribute(ERROR_TYPE, e.__class__.__name__)
|
|
1038
|
+
self._span.set_status(StatusCode.ERROR, str(e))
|
|
1039
|
+
self._span.end()
|
|
1040
|
+
self._cleanup_completed = True
|
|
1041
|
+
|
|
1042
|
+
@dont_throw
|
|
1043
|
+
def _handle_exception(self, exception):
|
|
1044
|
+
"""Handle exceptions during streaming"""
|
|
1045
|
+
with self._cleanup_lock:
|
|
1046
|
+
if self._cleanup_completed:
|
|
1047
|
+
return
|
|
1048
|
+
|
|
1049
|
+
if self._span and self._span.is_recording():
|
|
1050
|
+
self._span.set_attribute(ERROR_TYPE, exception.__class__.__name__)
|
|
1051
|
+
self._span.record_exception(exception)
|
|
1052
|
+
self._span.set_status(StatusCode.ERROR, str(exception))
|
|
1053
|
+
self._span.end()
|
|
1054
|
+
|
|
1055
|
+
self._cleanup_completed = True
|
|
1056
|
+
|
|
1057
|
+
@dont_throw
|
|
1058
|
+
def _ensure_cleanup(self):
|
|
1059
|
+
"""Ensure cleanup happens even if stream is not fully consumed"""
|
|
1060
|
+
with self._cleanup_lock:
|
|
1061
|
+
if self._cleanup_completed:
|
|
1062
|
+
return
|
|
1063
|
+
|
|
1064
|
+
try:
|
|
1065
|
+
if self._span and self._span.is_recording():
|
|
1066
|
+
set_data_attributes(self._traced_data, self._span)
|
|
1067
|
+
self._span.set_status(StatusCode.OK)
|
|
1068
|
+
self._span.end()
|
|
1069
|
+
|
|
1070
|
+
self._cleanup_completed = True
|
|
1071
|
+
|
|
1072
|
+
except Exception:
|
|
1073
|
+
self._cleanup_completed = True
|