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.

Files changed (22) hide show
  1. opentelemetry/instrumentation/openai/__init__.py +11 -6
  2. opentelemetry/instrumentation/openai/shared/__init__.py +167 -68
  3. opentelemetry/instrumentation/openai/shared/chat_wrappers.py +544 -231
  4. opentelemetry/instrumentation/openai/shared/completion_wrappers.py +143 -81
  5. opentelemetry/instrumentation/openai/shared/config.py +8 -3
  6. opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py +91 -30
  7. opentelemetry/instrumentation/openai/shared/event_emitter.py +108 -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 +42 -9
  12. opentelemetry/instrumentation/openai/v0/__init__.py +32 -11
  13. opentelemetry/instrumentation/openai/v1/__init__.py +177 -69
  14. opentelemetry/instrumentation/openai/v1/assistant_wrappers.py +208 -109
  15. opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py +41 -19
  16. opentelemetry/instrumentation/openai/v1/responses_wrappers.py +1073 -0
  17. opentelemetry/instrumentation/openai/version.py +1 -1
  18. {opentelemetry_instrumentation_openai-0.34.1.dist-info → opentelemetry_instrumentation_openai-0.49.3.dist-info}/METADATA +7 -8
  19. opentelemetry_instrumentation_openai-0.49.3.dist-info/RECORD +21 -0
  20. {opentelemetry_instrumentation_openai-0.34.1.dist-info → opentelemetry_instrumentation_openai-0.49.3.dist-info}/WHEEL +1 -1
  21. opentelemetry_instrumentation_openai-0.34.1.dist-info/RECORD +0 -17
  22. {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