opentelemetry-instrumentation-openai 0.40.13__py3-none-any.whl → 0.41.0__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.

Files changed (22) hide show
  1. opentelemetry/instrumentation/openai/__init__.py +3 -2
  2. opentelemetry/instrumentation/openai/shared/__init__.py +125 -28
  3. opentelemetry/instrumentation/openai/shared/chat_wrappers.py +191 -55
  4. opentelemetry/instrumentation/openai/shared/completion_wrappers.py +93 -36
  5. opentelemetry/instrumentation/openai/shared/config.py +8 -2
  6. opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py +79 -28
  7. opentelemetry/instrumentation/openai/shared/event_emitter.py +100 -0
  8. opentelemetry/instrumentation/openai/shared/event_models.py +41 -0
  9. opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py +1 -1
  10. opentelemetry/instrumentation/openai/shared/span_utils.py +0 -0
  11. opentelemetry/instrumentation/openai/utils.py +30 -4
  12. opentelemetry/instrumentation/openai/v0/__init__.py +31 -11
  13. opentelemetry/instrumentation/openai/v1/__init__.py +176 -69
  14. opentelemetry/instrumentation/openai/v1/assistant_wrappers.py +121 -42
  15. opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py +31 -15
  16. opentelemetry/instrumentation/openai/v1/responses_wrappers.py +623 -0
  17. opentelemetry/instrumentation/openai/version.py +1 -1
  18. {opentelemetry_instrumentation_openai-0.40.13.dist-info → opentelemetry_instrumentation_openai-0.41.0.dist-info}/METADATA +2 -2
  19. opentelemetry_instrumentation_openai-0.41.0.dist-info/RECORD +21 -0
  20. opentelemetry_instrumentation_openai-0.40.13.dist-info/RECORD +0 -17
  21. {opentelemetry_instrumentation_openai-0.40.13.dist-info → opentelemetry_instrumentation_openai-0.41.0.dist-info}/WHEEL +0 -0
  22. {opentelemetry_instrumentation_openai-0.40.13.dist-info → opentelemetry_instrumentation_openai-0.41.0.dist-info}/entry_points.txt +0 -0
@@ -1,13 +1,16 @@
1
- from opentelemetry.instrumentation.openai.shared import (
2
- _set_span_attribute,
3
- )
1
+ from opentelemetry.instrumentation.openai.shared import _set_span_attribute
2
+ from opentelemetry.instrumentation.openai.shared.event_emitter import emit_event
3
+ from opentelemetry.instrumentation.openai.shared.event_models import ChoiceEvent
4
+ from opentelemetry.instrumentation.openai.utils import should_emit_events
5
+ from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
4
6
  from opentelemetry.semconv_ai import SpanAttributes
5
- from openai import AssistantEventHandler
7
+ from opentelemetry.trace import Status, StatusCode
6
8
  from typing_extensions import override
7
9
 
10
+ from openai import AssistantEventHandler
8
11
 
9
- class EventHandleWrapper(AssistantEventHandler):
10
12
 
13
+ class EventHandleWrapper(AssistantEventHandler):
11
14
  _current_text_index = 0
12
15
  _prompt_tokens = 0
13
16
  _completion_tokens = 0
@@ -65,6 +68,9 @@ class EventHandleWrapper(AssistantEventHandler):
65
68
 
66
69
  @override
67
70
  def on_exception(self, exception: Exception):
71
+ self._span.set_attribute(ERROR_TYPE, exception.__class__.__name__)
72
+ self._span.record_exception(exception)
73
+ self._span.set_status(Status(StatusCode.ERROR, str(exception)))
68
74
  self._original_handler.on_exception(exception)
69
75
 
70
76
  @override
@@ -86,6 +92,15 @@ class EventHandleWrapper(AssistantEventHandler):
86
92
  f"gen_ai.response.{self._current_text_index}.id",
87
93
  message.id,
88
94
  )
95
+ emit_event(
96
+ ChoiceEvent(
97
+ index=self._current_text_index,
98
+ message={
99
+ "content": [item.model_dump() for item in message.content],
100
+ "role": message.role,
101
+ },
102
+ )
103
+ )
89
104
  self._original_handler.on_message_done(message)
90
105
  self._current_text_index += 1
91
106
 
@@ -100,16 +115,17 @@ class EventHandleWrapper(AssistantEventHandler):
100
115
  @override
101
116
  def on_text_done(self, text):
102
117
  self._original_handler.on_text_done(text)
103
- _set_span_attribute(
104
- self._span,
105
- f"{SpanAttributes.LLM_COMPLETIONS}.{self._current_text_index}.role",
106
- "assistant",
107
- )
108
- _set_span_attribute(
109
- self._span,
110
- f"{SpanAttributes.LLM_COMPLETIONS}.{self._current_text_index}.content",
111
- text.value,
112
- )
118
+ if not should_emit_events():
119
+ _set_span_attribute(
120
+ self._span,
121
+ f"{SpanAttributes.LLM_COMPLETIONS}.{self._current_text_index}.role",
122
+ "assistant",
123
+ )
124
+ _set_span_attribute(
125
+ self._span,
126
+ f"{SpanAttributes.LLM_COMPLETIONS}.{self._current_text_index}.content",
127
+ text.value,
128
+ )
113
129
 
114
130
  @override
115
131
  def on_image_file_done(self, image_file):
@@ -0,0 +1,623 @@
1
+ import json
2
+ import pydantic
3
+ import re
4
+ import time
5
+
6
+ from openai import AsyncStream, Stream
7
+
8
+ # Conditional imports for backward compatibility
9
+ try:
10
+ from openai.types.responses import (
11
+ FunctionToolParam,
12
+ Response,
13
+ ResponseInputItemParam,
14
+ ResponseInputParam,
15
+ ResponseOutputItem,
16
+ ResponseUsage,
17
+ ToolParam,
18
+ )
19
+ from openai.types.responses.response_output_message_param import (
20
+ ResponseOutputMessageParam,
21
+ )
22
+ RESPONSES_AVAILABLE = True
23
+ except ImportError:
24
+ # Fallback types for older OpenAI SDK versions
25
+ from typing import Any, Dict, List, Union
26
+
27
+ # Create basic fallback types
28
+ FunctionToolParam = Dict[str, Any]
29
+ Response = Any
30
+ ResponseInputItemParam = Dict[str, Any]
31
+ ResponseInputParam = Union[str, List[Dict[str, Any]]]
32
+ ResponseOutputItem = Dict[str, Any]
33
+ ResponseUsage = Dict[str, Any]
34
+ ToolParam = Dict[str, Any]
35
+ ResponseOutputMessageParam = Dict[str, Any]
36
+ RESPONSES_AVAILABLE = False
37
+
38
+ from openai._legacy_response import LegacyAPIResponse
39
+ from opentelemetry import context as context_api
40
+ from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY
41
+ from opentelemetry.semconv_ai import SpanAttributes
42
+ from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
43
+ from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import (
44
+ GEN_AI_COMPLETION,
45
+ GEN_AI_PROMPT,
46
+ GEN_AI_USAGE_INPUT_TOKENS,
47
+ GEN_AI_USAGE_OUTPUT_TOKENS,
48
+ GEN_AI_RESPONSE_ID,
49
+ GEN_AI_REQUEST_MODEL,
50
+ GEN_AI_RESPONSE_MODEL,
51
+ GEN_AI_SYSTEM,
52
+ )
53
+ from opentelemetry.trace import SpanKind, Span, StatusCode, Tracer
54
+ from typing import Any, Optional, Union
55
+ from typing_extensions import NotRequired
56
+
57
+ from opentelemetry.instrumentation.openai.shared import (
58
+ _set_span_attribute,
59
+ model_as_dict,
60
+ )
61
+
62
+ from opentelemetry.instrumentation.openai.utils import (
63
+ _with_tracer_wrapper,
64
+ dont_throw,
65
+ should_send_prompts,
66
+ )
67
+
68
+ SPAN_NAME = "openai.response"
69
+
70
+
71
+ def prepare_input_param(input_param: ResponseInputItemParam) -> ResponseInputItemParam:
72
+ """
73
+ Looks like OpenAI API infers the type "message" if the shape is correct,
74
+ but type is not specified.
75
+ It is marked as required on the message types. We add this to our
76
+ traced data to make it work.
77
+ """
78
+ try:
79
+ d = model_as_dict(input_param)
80
+ if "type" not in d:
81
+ d["type"] = "message"
82
+ if RESPONSES_AVAILABLE:
83
+ return ResponseInputItemParam(**d)
84
+ else:
85
+ return d
86
+ except Exception:
87
+ return input_param
88
+
89
+
90
+ def process_input(inp: ResponseInputParam) -> ResponseInputParam:
91
+ if not isinstance(inp, list):
92
+ return inp
93
+ return [prepare_input_param(item) for item in inp]
94
+
95
+
96
+ def is_validator_iterator(content):
97
+ """
98
+ Some OpenAI objects contain fields typed as Iterable, which pydantic
99
+ internally converts to a ValidatorIterator, and they cannot be trivially
100
+ serialized without consuming the iterator to, for example, a list.
101
+
102
+ See: https://github.com/pydantic/pydantic/issues/9541#issuecomment-2189045051
103
+ """
104
+ return re.search(r"pydantic.*ValidatorIterator'>$", str(type(content)))
105
+
106
+
107
+ # OpenAI API accepts output messages without an ID in its inputs, but
108
+ # the ID is marked as required in the output type.
109
+ if RESPONSES_AVAILABLE:
110
+ class ResponseOutputMessageParamWithoutId(ResponseOutputMessageParam):
111
+ id: NotRequired[str]
112
+ else:
113
+ # Fallback for older SDK versions
114
+ ResponseOutputMessageParamWithoutId = dict
115
+
116
+
117
+ class TracedData(pydantic.BaseModel):
118
+ start_time: float # time.time_ns()
119
+ response_id: str
120
+ # actually Union[str, list[Union[ResponseInputItemParam, ResponseOutputMessageParamWithoutId]]],
121
+ # but this only works properly in Python 3.10+ / newer pydantic
122
+ input: Any
123
+ # system message
124
+ instructions: Optional[str] = pydantic.Field(default=None)
125
+ # TODO: remove Any with newer Python / pydantic
126
+ tools: Optional[list[Union[Any, ToolParam]]] = pydantic.Field(default=None)
127
+ output_blocks: Optional[dict[str, ResponseOutputItem]] = pydantic.Field(
128
+ default=None
129
+ )
130
+ usage: Optional[ResponseUsage] = pydantic.Field(default=None)
131
+ output_text: Optional[str] = pydantic.Field(default=None)
132
+ request_model: Optional[str] = pydantic.Field(default=None)
133
+ response_model: Optional[str] = pydantic.Field(default=None)
134
+
135
+
136
+ responses: dict[str, TracedData] = {}
137
+
138
+
139
+ def parse_response(response: Union[LegacyAPIResponse, Response]) -> Response:
140
+ if isinstance(response, LegacyAPIResponse):
141
+ return response.parse()
142
+ return response
143
+
144
+
145
+ def get_tools_from_kwargs(kwargs: dict) -> list[ToolParam]:
146
+ tools_input = kwargs.get("tools", [])
147
+ tools = []
148
+
149
+ for tool in tools_input:
150
+ if tool.get("type") == "function":
151
+ if RESPONSES_AVAILABLE:
152
+ tools.append(FunctionToolParam(**tool))
153
+ else:
154
+ tools.append(tool)
155
+
156
+ return tools
157
+
158
+
159
+ def process_content_block(
160
+ block: dict[str, Any],
161
+ ) -> dict[str, Any]:
162
+ # TODO: keep the original type once backend supports it
163
+ if block.get("type") in ["text", "input_text", "output_text"]:
164
+ return {"type": "text", "text": block.get("text")}
165
+ elif block.get("type") in ["image", "input_image", "output_image"]:
166
+ return {
167
+ "type": "image",
168
+ "image_url": block.get("image_url"),
169
+ "detail": block.get("detail"),
170
+ "file_id": block.get("file_id"),
171
+ }
172
+ elif block.get("type") in ["file", "input_file", "output_file"]:
173
+ return {
174
+ "type": "file",
175
+ "file_id": block.get("file_id"),
176
+ "filename": block.get("filename"),
177
+ "file_data": block.get("file_data"),
178
+ }
179
+ return block
180
+
181
+
182
+ @dont_throw
183
+ def set_data_attributes(traced_response: TracedData, span: Span):
184
+ _set_span_attribute(span, GEN_AI_SYSTEM, "openai")
185
+ _set_span_attribute(span, GEN_AI_REQUEST_MODEL, traced_response.request_model)
186
+ _set_span_attribute(span, GEN_AI_RESPONSE_ID, traced_response.response_id)
187
+ _set_span_attribute(span, GEN_AI_RESPONSE_MODEL, traced_response.response_model)
188
+ if usage := traced_response.usage:
189
+ _set_span_attribute(span, GEN_AI_USAGE_INPUT_TOKENS, usage.input_tokens)
190
+ _set_span_attribute(span, GEN_AI_USAGE_OUTPUT_TOKENS, usage.output_tokens)
191
+ _set_span_attribute(
192
+ span, SpanAttributes.LLM_USAGE_TOTAL_TOKENS, usage.total_tokens
193
+ )
194
+ if usage.input_tokens_details:
195
+ _set_span_attribute(
196
+ span,
197
+ SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS,
198
+ usage.input_tokens_details.cached_tokens,
199
+ )
200
+ # TODO: add reasoning tokens in output token details
201
+
202
+ if should_send_prompts():
203
+ prompt_index = 0
204
+ if traced_response.tools:
205
+ for i, tool_param in enumerate(traced_response.tools):
206
+ tool_dict = model_as_dict(tool_param)
207
+ description = tool_dict.get("description")
208
+ parameters = tool_dict.get("parameters")
209
+ name = tool_dict.get("name")
210
+ if parameters is None:
211
+ continue
212
+ _set_span_attribute(
213
+ span,
214
+ f"{SpanAttributes.LLM_REQUEST_FUNCTIONS}.{i}.description",
215
+ description,
216
+ )
217
+ _set_span_attribute(
218
+ span,
219
+ f"{SpanAttributes.LLM_REQUEST_FUNCTIONS}.{i}.parameters",
220
+ json.dumps(parameters),
221
+ )
222
+ _set_span_attribute(
223
+ span,
224
+ f"{SpanAttributes.LLM_REQUEST_FUNCTIONS}.{i}.name",
225
+ name,
226
+ )
227
+ if traced_response.instructions:
228
+ _set_span_attribute(
229
+ span,
230
+ f"{GEN_AI_PROMPT}.{prompt_index}.content",
231
+ traced_response.instructions,
232
+ )
233
+ _set_span_attribute(span, f"{GEN_AI_PROMPT}.{prompt_index}.role", "system")
234
+ prompt_index += 1
235
+
236
+ if isinstance(traced_response.input, str):
237
+ _set_span_attribute(
238
+ span, f"{GEN_AI_PROMPT}.{prompt_index}.content", traced_response.input
239
+ )
240
+ _set_span_attribute(span, f"{GEN_AI_PROMPT}.{prompt_index}.role", "user")
241
+ prompt_index += 1
242
+ else:
243
+ for block in traced_response.input:
244
+ block_dict = model_as_dict(block)
245
+ if block_dict.get("type", "message") == "message":
246
+ content = block_dict.get("content")
247
+ if is_validator_iterator(content):
248
+ # we're after the actual call here, so we can consume the iterator
249
+ content = [process_content_block(block) for block in content]
250
+ try:
251
+ stringified_content = (
252
+ content if isinstance(content, str) else json.dumps(content)
253
+ )
254
+ except Exception:
255
+ stringified_content = (
256
+ str(content) if content is not None else ""
257
+ )
258
+ _set_span_attribute(
259
+ span,
260
+ f"{GEN_AI_PROMPT}.{prompt_index}.content",
261
+ stringified_content,
262
+ )
263
+ _set_span_attribute(
264
+ span,
265
+ f"{GEN_AI_PROMPT}.{prompt_index}.role",
266
+ block_dict.get("role"),
267
+ )
268
+ prompt_index += 1
269
+ elif block_dict.get("type") == "computer_call_output":
270
+ _set_span_attribute(
271
+ span, f"{GEN_AI_PROMPT}.{prompt_index}.role", "computer-call"
272
+ )
273
+ output_image_url = block_dict.get("output", {}).get("image_url")
274
+ if output_image_url:
275
+ _set_span_attribute(
276
+ span,
277
+ f"{GEN_AI_PROMPT}.{prompt_index}.content",
278
+ json.dumps(
279
+ [
280
+ {
281
+ "type": "image_url",
282
+ "image_url": {"url": output_image_url},
283
+ }
284
+ ]
285
+ ),
286
+ )
287
+ prompt_index += 1
288
+ elif block_dict.get("type") == "computer_call":
289
+ _set_span_attribute(
290
+ span, f"{GEN_AI_PROMPT}.{prompt_index}.role", "assistant"
291
+ )
292
+ call_content = {}
293
+ if block_dict.get("id"):
294
+ call_content["id"] = block_dict.get("id")
295
+ if block_dict.get("call_id"):
296
+ call_content["call_id"] = block_dict.get("call_id")
297
+ if block_dict.get("action"):
298
+ call_content["action"] = block_dict.get("action")
299
+ _set_span_attribute(
300
+ span,
301
+ f"{GEN_AI_PROMPT}.{prompt_index}.content",
302
+ json.dumps(call_content),
303
+ )
304
+ prompt_index += 1
305
+ # TODO: handle other block types
306
+
307
+ _set_span_attribute(span, f"{GEN_AI_COMPLETION}.0.role", "assistant")
308
+ if traced_response.output_text:
309
+ _set_span_attribute(
310
+ span, f"{GEN_AI_COMPLETION}.0.content", traced_response.output_text
311
+ )
312
+ tool_call_index = 0
313
+ for block in traced_response.output_blocks.values():
314
+ block_dict = model_as_dict(block)
315
+ if block_dict.get("type") == "message":
316
+ # either a refusal or handled in output_text above
317
+ continue
318
+ if block_dict.get("type") == "function_call":
319
+ _set_span_attribute(
320
+ span,
321
+ f"{GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.id",
322
+ block_dict.get("id"),
323
+ )
324
+ _set_span_attribute(
325
+ span,
326
+ f"{GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.name",
327
+ block_dict.get("name"),
328
+ )
329
+ _set_span_attribute(
330
+ span,
331
+ f"{GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.arguments",
332
+ block_dict.get("arguments"),
333
+ )
334
+ tool_call_index += 1
335
+ elif block_dict.get("type") == "file_search_call":
336
+ _set_span_attribute(
337
+ span,
338
+ f"{GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.id",
339
+ block_dict.get("id"),
340
+ )
341
+ _set_span_attribute(
342
+ span,
343
+ f"{GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.name",
344
+ "file_search_call",
345
+ )
346
+ tool_call_index += 1
347
+ elif block_dict.get("type") == "web_search_call":
348
+ _set_span_attribute(
349
+ span,
350
+ f"{GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.id",
351
+ block_dict.get("id"),
352
+ )
353
+ _set_span_attribute(
354
+ span,
355
+ f"{GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.name",
356
+ "web_search_call",
357
+ )
358
+ tool_call_index += 1
359
+ elif block_dict.get("type") == "computer_call":
360
+ _set_span_attribute(
361
+ span,
362
+ f"{GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.id",
363
+ block_dict.get("call_id"),
364
+ )
365
+ _set_span_attribute(
366
+ span,
367
+ f"{GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.name",
368
+ "computer_call",
369
+ )
370
+ _set_span_attribute(
371
+ span,
372
+ f"{GEN_AI_COMPLETION}.0.tool_calls.{tool_call_index}.arguments",
373
+ json.dumps(block_dict.get("action")),
374
+ )
375
+ tool_call_index += 1
376
+ elif block_dict.get("type") == "reasoning":
377
+ _set_span_attribute(
378
+ span, f"{GEN_AI_COMPLETION}.0.reasoning", block_dict.get("summary")
379
+ )
380
+ # TODO: handle other block types, in particular other calls
381
+
382
+
383
+ @dont_throw
384
+ @_with_tracer_wrapper
385
+ def responses_get_or_create_wrapper(tracer: Tracer, wrapped, instance, args, kwargs):
386
+ if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
387
+ return wrapped(*args, **kwargs)
388
+ start_time = time.time_ns()
389
+
390
+ try:
391
+ response = wrapped(*args, **kwargs)
392
+ if isinstance(response, Stream):
393
+ return response
394
+ except Exception as e:
395
+ response_id = kwargs.get("response_id")
396
+ existing_data = {}
397
+ if response_id and response_id in responses:
398
+ existing_data = responses[response_id].model_dump()
399
+ try:
400
+ traced_data = TracedData(
401
+ start_time=existing_data.get("start_time", start_time),
402
+ response_id=response_id or "",
403
+ input=process_input(
404
+ kwargs.get("input", existing_data.get("input", []))
405
+ ),
406
+ instructions=kwargs.get(
407
+ "instructions", existing_data.get("instructions")
408
+ ),
409
+ tools=get_tools_from_kwargs(kwargs) or existing_data.get("tools", []),
410
+ output_blocks=existing_data.get("output_blocks", {}),
411
+ usage=existing_data.get("usage"),
412
+ output_text=kwargs.get(
413
+ "output_text", existing_data.get("output_text", "")
414
+ ),
415
+ request_model=kwargs.get(
416
+ "model", existing_data.get("request_model", "")
417
+ ),
418
+ response_model=existing_data.get("response_model", ""),
419
+ )
420
+ except Exception:
421
+ traced_data = None
422
+
423
+ span = tracer.start_span(
424
+ SPAN_NAME,
425
+ kind=SpanKind.CLIENT,
426
+ start_time=(
427
+ start_time if traced_data is None else int(traced_data.start_time)
428
+ ),
429
+ )
430
+ span.set_attribute(ERROR_TYPE, e.__class__.__name__)
431
+ span.record_exception(e)
432
+ span.set_status(StatusCode.ERROR, str(e))
433
+ if traced_data:
434
+ set_data_attributes(traced_data, span)
435
+ span.end()
436
+ raise
437
+ parsed_response = parse_response(response)
438
+
439
+ existing_data = responses.get(parsed_response.id)
440
+ if existing_data is None:
441
+ existing_data = {}
442
+ else:
443
+ existing_data = existing_data.model_dump()
444
+
445
+ request_tools = get_tools_from_kwargs(kwargs)
446
+
447
+ merged_tools = existing_data.get("tools", []) + request_tools
448
+
449
+ try:
450
+ traced_data = TracedData(
451
+ start_time=existing_data.get("start_time", start_time),
452
+ response_id=parsed_response.id,
453
+ input=process_input(existing_data.get("input", kwargs.get("input"))),
454
+ instructions=existing_data.get("instructions", kwargs.get("instructions")),
455
+ tools=merged_tools if merged_tools else None,
456
+ output_blocks={block.id: block for block in parsed_response.output}
457
+ | existing_data.get("output_blocks", {}),
458
+ usage=existing_data.get("usage", parsed_response.usage),
459
+ output_text=existing_data.get("output_text", parsed_response.output_text),
460
+ request_model=existing_data.get("request_model", kwargs.get("model")),
461
+ response_model=existing_data.get("response_model", parsed_response.model),
462
+ )
463
+ responses[parsed_response.id] = traced_data
464
+ except Exception:
465
+ return response
466
+
467
+ if parsed_response.status == "completed":
468
+ span = tracer.start_span(
469
+ SPAN_NAME,
470
+ kind=SpanKind.CLIENT,
471
+ start_time=int(traced_data.start_time),
472
+ )
473
+ set_data_attributes(traced_data, span)
474
+ span.end()
475
+
476
+ return response
477
+
478
+
479
+ @dont_throw
480
+ @_with_tracer_wrapper
481
+ async def async_responses_get_or_create_wrapper(
482
+ tracer: Tracer, wrapped, instance, args, kwargs
483
+ ):
484
+ if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
485
+ return await wrapped(*args, **kwargs)
486
+ start_time = time.time_ns()
487
+
488
+ try:
489
+ response = await wrapped(*args, **kwargs)
490
+ if isinstance(response, (Stream, AsyncStream)):
491
+ return response
492
+ except Exception as e:
493
+ response_id = kwargs.get("response_id")
494
+ existing_data = {}
495
+ if response_id and response_id in responses:
496
+ existing_data = responses[response_id].model_dump()
497
+ try:
498
+ traced_data = TracedData(
499
+ start_time=existing_data.get("start_time", start_time),
500
+ response_id=response_id or "",
501
+ input=process_input(
502
+ kwargs.get("input", existing_data.get("input", []))
503
+ ),
504
+ instructions=kwargs.get(
505
+ "instructions", existing_data.get("instructions", "")
506
+ ),
507
+ tools=get_tools_from_kwargs(kwargs) or existing_data.get("tools", []),
508
+ output_blocks=existing_data.get("output_blocks", {}),
509
+ usage=existing_data.get("usage"),
510
+ output_text=kwargs.get("output_text", existing_data.get("output_text")),
511
+ request_model=kwargs.get("model", existing_data.get("request_model")),
512
+ response_model=existing_data.get("response_model"),
513
+ )
514
+ except Exception:
515
+ traced_data = None
516
+
517
+ span = tracer.start_span(
518
+ SPAN_NAME,
519
+ kind=SpanKind.CLIENT,
520
+ start_time=(
521
+ start_time if traced_data is None else int(traced_data.start_time)
522
+ ),
523
+ )
524
+ span.set_attribute(ERROR_TYPE, e.__class__.__name__)
525
+ span.record_exception(e)
526
+ span.set_status(StatusCode.ERROR, str(e))
527
+ if traced_data:
528
+ set_data_attributes(traced_data, span)
529
+ span.end()
530
+ raise
531
+ parsed_response = parse_response(response)
532
+
533
+ existing_data = responses.get(parsed_response.id)
534
+ if existing_data is None:
535
+ existing_data = {}
536
+ else:
537
+ existing_data = existing_data.model_dump()
538
+
539
+ request_tools = get_tools_from_kwargs(kwargs)
540
+
541
+ merged_tools = existing_data.get("tools", []) + request_tools
542
+
543
+ try:
544
+ traced_data = TracedData(
545
+ start_time=existing_data.get("start_time", start_time),
546
+ response_id=parsed_response.id,
547
+ input=process_input(existing_data.get("input", kwargs.get("input"))),
548
+ instructions=existing_data.get("instructions", kwargs.get("instructions")),
549
+ tools=merged_tools if merged_tools else None,
550
+ output_blocks={block.id: block for block in parsed_response.output}
551
+ | existing_data.get("output_blocks", {}),
552
+ usage=existing_data.get("usage", parsed_response.usage),
553
+ output_text=existing_data.get("output_text", parsed_response.output_text),
554
+ request_model=existing_data.get("request_model", kwargs.get("model")),
555
+ response_model=existing_data.get("response_model", parsed_response.model),
556
+ )
557
+ responses[parsed_response.id] = traced_data
558
+ except Exception:
559
+ return response
560
+
561
+ if parsed_response.status == "completed":
562
+ span = tracer.start_span(
563
+ SPAN_NAME,
564
+ kind=SpanKind.CLIENT,
565
+ start_time=int(traced_data.start_time),
566
+ )
567
+ set_data_attributes(traced_data, span)
568
+ span.end()
569
+
570
+ return response
571
+
572
+
573
+ @dont_throw
574
+ @_with_tracer_wrapper
575
+ def responses_cancel_wrapper(tracer: Tracer, wrapped, instance, args, kwargs):
576
+ if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
577
+ return wrapped(*args, **kwargs)
578
+
579
+ response = wrapped(*args, **kwargs)
580
+ if isinstance(response, Stream):
581
+ return response
582
+ parsed_response = parse_response(response)
583
+ existing_data = responses.pop(parsed_response.id, None)
584
+ if existing_data is not None:
585
+ span = tracer.start_span(
586
+ SPAN_NAME,
587
+ kind=SpanKind.CLIENT,
588
+ start_time=existing_data.start_time,
589
+ record_exception=True,
590
+ )
591
+ span.record_exception(Exception("Response cancelled"))
592
+ set_data_attributes(existing_data, span)
593
+ span.end()
594
+ return response
595
+
596
+
597
+ @dont_throw
598
+ @_with_tracer_wrapper
599
+ async def async_responses_cancel_wrapper(
600
+ tracer: Tracer, wrapped, instance, args, kwargs
601
+ ):
602
+ if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
603
+ return await wrapped(*args, **kwargs)
604
+
605
+ response = await wrapped(*args, **kwargs)
606
+ if isinstance(response, (Stream, AsyncStream)):
607
+ return response
608
+ parsed_response = parse_response(response)
609
+ existing_data = responses.pop(parsed_response.id, None)
610
+ if existing_data is not None:
611
+ span = tracer.start_span(
612
+ SPAN_NAME,
613
+ kind=SpanKind.CLIENT,
614
+ start_time=existing_data.start_time,
615
+ record_exception=True,
616
+ )
617
+ span.record_exception(Exception("Response cancelled"))
618
+ set_data_attributes(existing_data, span)
619
+ span.end()
620
+ return response
621
+
622
+
623
+ # TODO: build streaming responses
@@ -1 +1 @@
1
- __version__ = "0.40.13"
1
+ __version__ = "0.41.0"