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