telemetry-dev-openai 0.1.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.
@@ -0,0 +1,1210 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping, Sequence
6
+ from functools import wraps
7
+ from typing import Any, TypeVar, cast
8
+
9
+ import openai
10
+ import telemetry_dev
11
+ from openai.resources.chat.completions.completions import AsyncCompletions, Completions
12
+ from openai.resources.embeddings import AsyncEmbeddings, Embeddings
13
+ from openai.resources.responses.responses import AsyncResponses, Responses
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ ProviderResolver = Callable[[object | None], str]
18
+ RequestMapper = Callable[[Mapping[str, Any]], tuple[str, dict[str, Any]]]
19
+ ResponseMapper = Callable[[Any], dict[str, Any]]
20
+
21
+ _WRAPPED_ATTR = "_telemetry_dev_openai_wrapped"
22
+ _ORIGINAL_ATTR = "_telemetry_dev_openai_original"
23
+ _ORIGINALS: list[tuple[type[Any], str, Any]] = []
24
+ _installed = False
25
+ _install_lock = threading.Lock()
26
+ _T = TypeVar("_T")
27
+
28
+
29
+ def _field(value: Any, name: str) -> Any:
30
+ if isinstance(value, Mapping):
31
+ mapping = cast(Mapping[str, Any], value)
32
+ return mapping.get(name)
33
+ return getattr(value, name, None)
34
+
35
+
36
+ def _sequence_items(value: Any) -> list[Any]:
37
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
38
+ return list(cast(Sequence[Any], value))
39
+ return []
40
+
41
+
42
+ def _native(value: Any) -> Any:
43
+ if hasattr(value, "model_dump"):
44
+ return value.model_dump(mode="json", exclude_none=True)
45
+ if isinstance(value, Mapping):
46
+ mapping = cast(Mapping[Any, Any], value)
47
+ return {str(key): _native(item) for key, item in mapping.items() if item is not None}
48
+ sequence = _sequence_items(value)
49
+ if sequence:
50
+ return [_native(item) for item in sequence]
51
+ return value
52
+
53
+
54
+ def _number(value: Any) -> int | float | None:
55
+ if isinstance(value, bool):
56
+ return None
57
+ if isinstance(value, int | float):
58
+ return value
59
+ return None
60
+
61
+
62
+ def _string(value: Any) -> str | None:
63
+ return value if isinstance(value, str) else None
64
+
65
+
66
+ def _usage(fields: dict[str, int | float | None]) -> dict[str, int | float] | None:
67
+ usage = {key: value for key, value in fields.items() if value is not None}
68
+ return usage or None
69
+
70
+
71
+ def _stop_sequences(value: Any) -> list[str] | None:
72
+ if isinstance(value, str):
73
+ return [value]
74
+ strings = [item for item in _sequence_items(value) if isinstance(item, str)]
75
+ return strings or None
76
+
77
+
78
+ def _chat_request(params: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
79
+ model = _string(params.get("model"))
80
+ return (
81
+ f"chat {model or 'unknown'}",
82
+ {
83
+ "type": "generation",
84
+ "model": model,
85
+ "input": params.get("messages"),
86
+ "temperature": _number(params.get("temperature")),
87
+ "top_p": _number(params.get("top_p")),
88
+ "max_tokens": _number(params.get("max_completion_tokens"))
89
+ or _number(params.get("max_tokens")),
90
+ "stop_sequences": _stop_sequences(params.get("stop")),
91
+ "seed": _number(params.get("seed")),
92
+ "frequency_penalty": _number(params.get("frequency_penalty")),
93
+ "presence_penalty": _number(params.get("presence_penalty")),
94
+ },
95
+ )
96
+
97
+
98
+ def _chat_usage(raw: Any) -> dict[str, int | float] | None:
99
+ prompt_details = _field(raw, "prompt_tokens_details")
100
+ completion_details = _field(raw, "completion_tokens_details")
101
+ return _usage(
102
+ {
103
+ "input_tokens": _number(_field(raw, "prompt_tokens")),
104
+ "output_tokens": _number(_field(raw, "completion_tokens")),
105
+ "total_tokens": _number(_field(raw, "total_tokens")),
106
+ "cache_read_input_tokens": _number(_field(prompt_details, "cached_tokens")),
107
+ "reasoning_output_tokens": _number(_field(completion_details, "reasoning_tokens")),
108
+ }
109
+ )
110
+
111
+
112
+ def _chat_output_message(message: Any) -> dict[str, Any]:
113
+ if message is None:
114
+ return {}
115
+ native = _native(message)
116
+ if not isinstance(native, dict):
117
+ return cast(dict[str, Any], native)
118
+ if _field(message, "content") is None:
119
+ native["content"] = None
120
+ return cast(dict[str, Any], native)
121
+
122
+
123
+ def _chat_response(response: Any) -> dict[str, Any]:
124
+ choices = list(_field(response, "choices") or [])
125
+ finish_reasons = [
126
+ reason
127
+ for choice in choices
128
+ if (reason := _string(_field(choice, "finish_reason"))) is not None
129
+ ]
130
+ return {
131
+ "response_model": _string(_field(response, "model")),
132
+ "response_id": _string(_field(response, "id")),
133
+ "finish_reason": finish_reasons[0] if finish_reasons else None,
134
+ "output": [_chat_output_message(_field(choice, "message")) for choice in choices],
135
+ "usage": _chat_usage(_field(response, "usage")),
136
+ # The core SDK maps finish_reason to a single-element array; multi-choice
137
+ # responses need one entry per choice, via the raw-attribute escape hatch.
138
+ "attributes": (
139
+ {"gen_ai.response.finish_reasons": finish_reasons} if len(finish_reasons) > 1 else None
140
+ ),
141
+ }
142
+
143
+
144
+ def _responses_request(params: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
145
+ model = _string(params.get("model"))
146
+ return (
147
+ f"chat {model or 'unknown'}",
148
+ {
149
+ "type": "generation",
150
+ "model": model,
151
+ "input": params.get("input"),
152
+ "system_instructions": params.get("instructions"),
153
+ "temperature": _number(params.get("temperature")),
154
+ "top_p": _number(params.get("top_p")),
155
+ "max_tokens": _number(params.get("max_output_tokens")),
156
+ },
157
+ )
158
+
159
+
160
+ def _responses_usage(raw: Any) -> dict[str, int | float] | None:
161
+ input_details = _field(raw, "input_tokens_details")
162
+ output_details = _field(raw, "output_tokens_details")
163
+ return _usage(
164
+ {
165
+ "input_tokens": _number(_field(raw, "input_tokens")),
166
+ "output_tokens": _number(_field(raw, "output_tokens")),
167
+ "total_tokens": _number(_field(raw, "total_tokens")),
168
+ "cache_read_input_tokens": _number(_field(input_details, "cached_tokens")),
169
+ "reasoning_output_tokens": _number(_field(output_details, "reasoning_tokens")),
170
+ }
171
+ )
172
+
173
+
174
+ def _responses_response(response: Any, *, include_error: bool = True) -> dict[str, Any]:
175
+ status = _string(_field(response, "status"))
176
+ incomplete_details = _field(response, "incomplete_details")
177
+ fields = {
178
+ "response_model": _string(_field(response, "model")),
179
+ "response_id": _string(_field(response, "id")),
180
+ "output": _native(_field(response, "output")),
181
+ "usage": _responses_usage(_field(response, "usage")),
182
+ "finish_reason": "stop"
183
+ if status == "completed"
184
+ else _string(_field(incomplete_details, "reason")) or status,
185
+ }
186
+ if include_error and status == "failed":
187
+ fields["error"] = _response_failed_error(response)
188
+ return fields
189
+
190
+
191
+ def _embeddings_request(params: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
192
+ model = _string(params.get("model"))
193
+ return (
194
+ f"embeddings {model or 'unknown'}",
195
+ {"type": "embedding", "model": model, "input": params.get("input")},
196
+ )
197
+
198
+
199
+ def _embeddings_response(response: Any) -> dict[str, Any]:
200
+ raw_usage = _field(response, "usage")
201
+ return {
202
+ "response_model": _string(_field(response, "model")),
203
+ "usage": _usage(
204
+ {
205
+ "input_tokens": _number(_field(raw_usage, "prompt_tokens")),
206
+ "total_tokens": _number(_field(raw_usage, "total_tokens")),
207
+ }
208
+ ),
209
+ }
210
+
211
+
212
+ def _provider_for_client(client: object | None) -> str:
213
+ return (
214
+ "azure.ai.openai"
215
+ if isinstance(client, openai.AzureOpenAI | openai.AsyncAzureOpenAI)
216
+ else "openai"
217
+ )
218
+
219
+
220
+ def _provider_for_resource(resource: object | None) -> str:
221
+ return _provider_for_client(getattr(resource, "_client", None))
222
+
223
+
224
+ def _clean_fields(fields: Mapping[str, Any]) -> dict[str, Any]:
225
+ return {key: value for key, value in fields.items() if value is not None}
226
+
227
+
228
+ def _end_once(handle: telemetry_dev.SpanHandle) -> Callable[..., None]:
229
+ ended = False
230
+
231
+ def end(**fields: Any) -> None:
232
+ nonlocal ended
233
+ if ended:
234
+ return
235
+ ended = True
236
+ handle.end(**_clean_fields(fields))
237
+
238
+ return end
239
+
240
+
241
+ class _ChatChoice:
242
+ def __init__(self) -> None:
243
+ self.role: str | None = None
244
+ self.content = ""
245
+ self.refusal = ""
246
+ self.tool_calls: dict[int, dict[str, Any]] = {}
247
+ self.finish_reason: str | None = None
248
+
249
+
250
+ def _choice_state(states: dict[int, _ChatChoice], index: int) -> _ChatChoice:
251
+ if index not in states:
252
+ states[index] = _ChatChoice()
253
+ return states[index]
254
+
255
+
256
+ def _merge_tool_call(state: _ChatChoice, delta: Any) -> None:
257
+ index = _field(delta, "index")
258
+ tool_index = index if isinstance(index, int) else len(state.tool_calls)
259
+ current = dict(state.tool_calls.get(tool_index, {}))
260
+ tool_id = _field(delta, "id")
261
+ tool_type = _field(delta, "type")
262
+ if tool_id is not None:
263
+ current["id"] = tool_id
264
+ if tool_type is not None:
265
+ current["type"] = tool_type
266
+ incoming_function = _field(delta, "function")
267
+ if incoming_function is not None:
268
+ current_function = dict(cast(Mapping[str, Any], current.get("function", {})))
269
+ name = _field(incoming_function, "name")
270
+ arguments = _field(incoming_function, "arguments")
271
+ if name is not None:
272
+ current_function["name"] = name
273
+ if isinstance(arguments, str):
274
+ current_function["arguments"] = f"{current_function.get('arguments', '')}{arguments}"
275
+ current["function"] = current_function
276
+ state.tool_calls[tool_index] = current
277
+
278
+
279
+ def _record_chat_chunk(chunk: Any, states: dict[int, _ChatChoice]) -> dict[str, Any]:
280
+ for choice in _sequence_items(_field(chunk, "choices")):
281
+ index = _field(choice, "index")
282
+ state = _choice_state(states, index if isinstance(index, int) else 0)
283
+ delta = _field(choice, "delta")
284
+ role = _field(delta, "role")
285
+ content = _field(delta, "content")
286
+ refusal = _field(delta, "refusal")
287
+ if isinstance(role, str):
288
+ state.role = role
289
+ if isinstance(content, str):
290
+ state.content += content
291
+ if isinstance(refusal, str):
292
+ state.refusal += refusal
293
+ for tool_call in _sequence_items(_field(delta, "tool_calls")):
294
+ _merge_tool_call(state, tool_call)
295
+ finish_reason = _field(choice, "finish_reason")
296
+ if isinstance(finish_reason, str):
297
+ state.finish_reason = finish_reason
298
+ return {
299
+ "response_id": _string(_field(chunk, "id")),
300
+ "response_model": _string(_field(chunk, "model")),
301
+ "usage": _chat_usage(_field(chunk, "usage")),
302
+ }
303
+
304
+
305
+ def _chat_output(states: Mapping[int, _ChatChoice]) -> list[dict[str, Any]]:
306
+ output: list[dict[str, Any]] = []
307
+ for _, state in sorted(states.items()):
308
+ message: dict[str, Any] = {"role": state.role or "assistant"}
309
+ if state.content:
310
+ message["content"] = state.content
311
+ elif state.tool_calls:
312
+ message["content"] = None
313
+ if state.refusal:
314
+ message["refusal"] = state.refusal
315
+ if state.tool_calls:
316
+ message["tool_calls"] = [call for _, call in sorted(state.tool_calls.items())]
317
+ output.append(message)
318
+ return output
319
+
320
+
321
+ def _chat_partial(
322
+ states: Mapping[int, _ChatChoice], usage: dict[str, int | float] | None
323
+ ) -> dict[str, Any]:
324
+ finish_reasons = [
325
+ state.finish_reason
326
+ for _, state in sorted(states.items())
327
+ if state.finish_reason is not None
328
+ ]
329
+ return {
330
+ "output": _chat_output(states) if states else None,
331
+ "usage": usage,
332
+ "finish_reason": finish_reasons[0] if finish_reasons else None,
333
+ "attributes": (
334
+ {"gen_ai.response.finish_reasons": finish_reasons} if len(finish_reasons) > 1 else None
335
+ ),
336
+ }
337
+
338
+
339
+ def _synthetic_usage_chunk(chunk: Any) -> bool:
340
+ choices = _sequence_items(_field(chunk, "choices"))
341
+ return _field(chunk, "usage") is not None and len(choices) == 0
342
+
343
+
344
+ def _response_failed_error(response: Any) -> RuntimeError:
345
+ error = _field(response, "error")
346
+ if error is None:
347
+ return RuntimeError("response.failed")
348
+ code = _string(_field(error, "code"))
349
+ message = _string(_field(error, "message"))
350
+ if code and message:
351
+ return RuntimeError(f"response.failed: {code}: {message}")
352
+ if code:
353
+ return RuntimeError(f"response.failed: {code}")
354
+ if message:
355
+ return RuntimeError(f"response.failed: {message}")
356
+ return RuntimeError("response.failed")
357
+
358
+
359
+ def _response_stream_error(event: Any) -> RuntimeError:
360
+ code = _string(_field(event, "code"))
361
+ message = _string(_field(event, "message"))
362
+ if code and message:
363
+ return RuntimeError(f"response.error: {code}: {message}")
364
+ if code:
365
+ return RuntimeError(f"response.error: {code}")
366
+ if message:
367
+ return RuntimeError(f"response.error: {message}")
368
+ return RuntimeError("response.error")
369
+
370
+
371
+ def _hook_response_close(inner: Any, finish: Callable[[], None]) -> None:
372
+ """End the span when the transport response is closed behind our back.
373
+
374
+ OpenAI's stream managers (``chat.completions.stream()``, ``responses.stream()``)
375
+ wrap the raw stream returned by ``create(stream=True)`` but close
376
+ ``raw_stream.response`` directly on context-manager exit instead of calling the
377
+ raw stream's ``close()``, which would otherwise leave the span open on early
378
+ exit until garbage collection.
379
+ """
380
+ response = getattr(inner, "response", None)
381
+ if response is None:
382
+ return
383
+ close = getattr(response, "close", None)
384
+ if callable(close):
385
+
386
+ def _close_hook(*args: Any, **kwargs: Any) -> Any:
387
+ try:
388
+ return close(*args, **kwargs)
389
+ finally:
390
+ finish()
391
+
392
+ response.close = _close_hook
393
+ aclose = getattr(response, "aclose", None)
394
+ if callable(aclose):
395
+ aclose_fn = cast(Callable[..., Awaitable[Any]], aclose)
396
+
397
+ async def _aclose_hook(*args: Any, **kwargs: Any) -> Any:
398
+ try:
399
+ return await aclose_fn(*args, **kwargs)
400
+ finally:
401
+ finish()
402
+
403
+ response.aclose = _aclose_hook
404
+
405
+
406
+ class _InstrumentedStream:
407
+ def __init__(
408
+ self,
409
+ inner: Any,
410
+ handle: telemetry_dev.SpanHandle,
411
+ injected_usage: bool,
412
+ started_at: float,
413
+ ) -> None:
414
+ self._inner = inner
415
+ self._end = _end_once(handle)
416
+ self._handle = handle
417
+ self._injected_usage = injected_usage
418
+ self._started_at = started_at
419
+ self._states: dict[int, _ChatChoice] = {}
420
+ self._usage: dict[str, int | float] | None = None
421
+ self._saw_first = False
422
+ self._consume: Iterator[Any] | None = None
423
+ self._in_next = False
424
+ _hook_response_close(inner, self._on_response_close)
425
+
426
+ def _iterate(self) -> Iterator[Any]:
427
+ try:
428
+ while True:
429
+ self._in_next = True
430
+ try:
431
+ chunk = next(self._inner)
432
+ except StopIteration:
433
+ break
434
+ except BaseException as exc:
435
+ self._end(**_chat_partial(self._states, self._usage), error=exc)
436
+ raise
437
+ finally:
438
+ self._in_next = False
439
+ self._record(chunk)
440
+ if self._injected_usage and _synthetic_usage_chunk(chunk):
441
+ continue
442
+ yield chunk
443
+ finally:
444
+ self.close()
445
+
446
+ def __iter__(self) -> Iterator[Any]:
447
+ return self._iterate()
448
+
449
+ def __next__(self) -> Any:
450
+ if self._consume is None:
451
+ self._consume = self._iterate()
452
+ return next(self._consume)
453
+
454
+ def __enter__(self) -> _InstrumentedStream:
455
+ enter = getattr(self._inner, "__enter__", None)
456
+ if enter is not None:
457
+ enter()
458
+ return self
459
+
460
+ def __exit__(
461
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any
462
+ ) -> None:
463
+ if exc is not None:
464
+ self._end(**_chat_partial(self._states, self._usage), error=exc)
465
+ self.close()
466
+
467
+ def _finish(self) -> None:
468
+ self._end(**_chat_partial(self._states, self._usage))
469
+
470
+ def _on_response_close(self) -> None:
471
+ # Mid-iteration closes are part of error/exhaustion unwinding inside
472
+ # next(); those paths must win the end race to record the right status.
473
+ if not self._in_next:
474
+ self._finish()
475
+
476
+ def close(self) -> None:
477
+ self._finish()
478
+ close = getattr(self._inner, "close", None)
479
+ if close is not None:
480
+ close()
481
+
482
+ def __getattr__(self, name: str) -> Any:
483
+ return getattr(self._inner, name)
484
+
485
+ def _record(self, chunk: Any) -> None:
486
+ update = _record_chat_chunk(chunk, self._states)
487
+ if not self._saw_first:
488
+ self._saw_first = True
489
+ self._handle.update(
490
+ time_to_first_chunk_ms=(time.perf_counter() - self._started_at) * 1000,
491
+ response_id=update.get("response_id"),
492
+ response_model=update.get("response_model"),
493
+ )
494
+ if update.get("usage") is not None:
495
+ self._usage = update["usage"]
496
+
497
+
498
+ class _InstrumentedAsyncStream:
499
+ def __init__(
500
+ self,
501
+ inner: Any,
502
+ handle: telemetry_dev.SpanHandle,
503
+ injected_usage: bool,
504
+ started_at: float,
505
+ ) -> None:
506
+ self._inner = inner
507
+ self._end = _end_once(handle)
508
+ self._handle = handle
509
+ self._injected_usage = injected_usage
510
+ self._started_at = started_at
511
+ self._states: dict[int, _ChatChoice] = {}
512
+ self._usage: dict[str, int | float] | None = None
513
+ self._saw_first = False
514
+ self._consume: AsyncIterator[Any] | None = None
515
+ self._in_next = False
516
+ _hook_response_close(inner, self._on_response_close)
517
+
518
+ async def _aiterate(self) -> AsyncIterator[Any]:
519
+ try:
520
+ while True:
521
+ self._in_next = True
522
+ try:
523
+ chunk = await self._inner.__anext__()
524
+ except StopAsyncIteration:
525
+ break
526
+ except BaseException as exc:
527
+ self._end(**_chat_partial(self._states, self._usage), error=exc)
528
+ raise
529
+ finally:
530
+ self._in_next = False
531
+ self._record(chunk)
532
+ if self._injected_usage and _synthetic_usage_chunk(chunk):
533
+ continue
534
+ yield chunk
535
+ finally:
536
+ await self.close()
537
+
538
+ def __aiter__(self) -> AsyncIterator[Any]:
539
+ return self._aiterate()
540
+
541
+ async def __anext__(self) -> Any:
542
+ if self._consume is None:
543
+ self._consume = self._aiterate()
544
+ return await self._consume.__anext__()
545
+
546
+ async def __aenter__(self) -> _InstrumentedAsyncStream:
547
+ enter = getattr(self._inner, "__aenter__", None)
548
+ if enter is not None:
549
+ await enter()
550
+ return self
551
+
552
+ async def __aexit__(
553
+ self,
554
+ exc_type: type[BaseException] | None,
555
+ exc: BaseException | None,
556
+ tb: Any,
557
+ ) -> None:
558
+ if exc is not None:
559
+ self._end(**_chat_partial(self._states, self._usage), error=exc)
560
+ await self.close()
561
+
562
+ def _finish(self) -> None:
563
+ self._end(**_chat_partial(self._states, self._usage))
564
+
565
+ def _on_response_close(self) -> None:
566
+ # Mid-iteration closes are part of error/exhaustion unwinding inside
567
+ # __anext__(); those paths must win the end race to record the right status.
568
+ if not self._in_next:
569
+ self._finish()
570
+
571
+ async def close(self) -> None:
572
+ self._finish()
573
+ close = getattr(self._inner, "close", None)
574
+ if close is not None:
575
+ result = close()
576
+ if hasattr(result, "__await__"):
577
+ await result
578
+
579
+ def __getattr__(self, name: str) -> Any:
580
+ return getattr(self._inner, name)
581
+
582
+ def _record(self, chunk: Any) -> None:
583
+ update = _record_chat_chunk(chunk, self._states)
584
+ if not self._saw_first:
585
+ self._saw_first = True
586
+ self._handle.update(
587
+ time_to_first_chunk_ms=(time.perf_counter() - self._started_at) * 1000,
588
+ response_id=update.get("response_id"),
589
+ response_model=update.get("response_model"),
590
+ )
591
+ if update.get("usage") is not None:
592
+ self._usage = update["usage"]
593
+
594
+
595
+ class _InstrumentedResponsesStream:
596
+ def __init__(self, inner: Any, handle: telemetry_dev.SpanHandle, started_at: float) -> None:
597
+ self._inner = inner
598
+ self._handle = handle
599
+ self._end = _end_once(handle)
600
+ self._started_at = started_at
601
+ self._saw_first = False
602
+ self._partial: dict[str, Any] = {}
603
+ self._consume: Iterator[Any] | None = None
604
+ self._in_next = False
605
+ _hook_response_close(inner, self._on_response_close)
606
+
607
+ def _iterate(self) -> Iterator[Any]:
608
+ try:
609
+ while True:
610
+ self._in_next = True
611
+ try:
612
+ event = next(self._inner)
613
+ except StopIteration:
614
+ break
615
+ except BaseException as exc:
616
+ self._end(**self._partial, error=exc)
617
+ raise
618
+ finally:
619
+ self._in_next = False
620
+ self._record(event)
621
+ yield event
622
+ finally:
623
+ self.close()
624
+
625
+ def __iter__(self) -> Iterator[Any]:
626
+ return self._iterate()
627
+
628
+ def __next__(self) -> Any:
629
+ if self._consume is None:
630
+ self._consume = self._iterate()
631
+ return next(self._consume)
632
+
633
+ def __enter__(self) -> _InstrumentedResponsesStream:
634
+ enter = getattr(self._inner, "__enter__", None)
635
+ if enter is not None:
636
+ enter()
637
+ return self
638
+
639
+ def __exit__(
640
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any
641
+ ) -> None:
642
+ if exc is not None:
643
+ self._end(**self._partial, error=exc)
644
+ self.close()
645
+
646
+ def _finish(self) -> None:
647
+ self._end(**self._partial)
648
+
649
+ def _on_response_close(self) -> None:
650
+ # Mid-iteration closes are part of error/exhaustion unwinding inside
651
+ # next(); those paths must win the end race to record the right status.
652
+ if not self._in_next:
653
+ self._finish()
654
+
655
+ def close(self) -> None:
656
+ self._finish()
657
+ close = getattr(self._inner, "close", None)
658
+ if close is not None:
659
+ close()
660
+
661
+ def __getattr__(self, name: str) -> Any:
662
+ return getattr(self._inner, name)
663
+
664
+ def _record(self, event: Any) -> None:
665
+ if not self._saw_first:
666
+ self._saw_first = True
667
+ self._handle.update(
668
+ time_to_first_chunk_ms=(time.perf_counter() - self._started_at) * 1000
669
+ )
670
+ response = _field(event, "response")
671
+ if response is not None:
672
+ # Streams keep error out of the partial: their end paths pass an
673
+ # explicit error= kwarg, which must not collide with mapped fields.
674
+ self._partial = _responses_response(response, include_error=False)
675
+ event_type = _field(event, "type")
676
+ if event_type == "response.completed":
677
+ self._end(**self._partial)
678
+ elif event_type == "response.failed":
679
+ self._end(**self._partial, error=_response_failed_error(response))
680
+ elif event_type == "response.incomplete":
681
+ self._end(**self._partial)
682
+ elif event_type == "error":
683
+ self._end(**self._partial, error=_response_stream_error(event))
684
+
685
+
686
+ class _InstrumentedAsyncResponsesStream:
687
+ def __init__(self, inner: Any, handle: telemetry_dev.SpanHandle, started_at: float) -> None:
688
+ self._inner = inner
689
+ self._handle = handle
690
+ self._end = _end_once(handle)
691
+ self._started_at = started_at
692
+ self._saw_first = False
693
+ self._partial: dict[str, Any] = {}
694
+ self._consume: AsyncIterator[Any] | None = None
695
+ self._in_next = False
696
+ _hook_response_close(inner, self._on_response_close)
697
+
698
+ async def _aiterate(self) -> AsyncIterator[Any]:
699
+ try:
700
+ while True:
701
+ self._in_next = True
702
+ try:
703
+ event = await self._inner.__anext__()
704
+ except StopAsyncIteration:
705
+ break
706
+ except BaseException as exc:
707
+ self._end(**self._partial, error=exc)
708
+ raise
709
+ finally:
710
+ self._in_next = False
711
+ self._record(event)
712
+ yield event
713
+ finally:
714
+ await self.close()
715
+
716
+ def __aiter__(self) -> AsyncIterator[Any]:
717
+ return self._aiterate()
718
+
719
+ async def __anext__(self) -> Any:
720
+ if self._consume is None:
721
+ self._consume = self._aiterate()
722
+ return await self._consume.__anext__()
723
+
724
+ async def __aenter__(self) -> _InstrumentedAsyncResponsesStream:
725
+ enter = getattr(self._inner, "__aenter__", None)
726
+ if enter is not None:
727
+ await enter()
728
+ return self
729
+
730
+ async def __aexit__(
731
+ self,
732
+ exc_type: type[BaseException] | None,
733
+ exc: BaseException | None,
734
+ tb: Any,
735
+ ) -> None:
736
+ if exc is not None:
737
+ self._end(**self._partial, error=exc)
738
+ await self.close()
739
+
740
+ def _finish(self) -> None:
741
+ self._end(**self._partial)
742
+
743
+ def _on_response_close(self) -> None:
744
+ # Mid-iteration closes are part of error/exhaustion unwinding inside
745
+ # __anext__(); those paths must win the end race to record the right status.
746
+ if not self._in_next:
747
+ self._finish()
748
+
749
+ async def close(self) -> None:
750
+ self._finish()
751
+ close = getattr(self._inner, "close", None)
752
+ if close is not None:
753
+ result = close()
754
+ if hasattr(result, "__await__"):
755
+ await result
756
+
757
+ def __getattr__(self, name: str) -> Any:
758
+ return getattr(self._inner, name)
759
+
760
+ def _record(self, event: Any) -> None:
761
+ if not self._saw_first:
762
+ self._saw_first = True
763
+ self._handle.update(
764
+ time_to_first_chunk_ms=(time.perf_counter() - self._started_at) * 1000
765
+ )
766
+ response = _field(event, "response")
767
+ if response is not None:
768
+ # Streams keep error out of the partial: their end paths pass an
769
+ # explicit error= kwarg, which must not collide with mapped fields.
770
+ self._partial = _responses_response(response, include_error=False)
771
+ event_type = _field(event, "type")
772
+ if event_type == "response.completed":
773
+ self._end(**self._partial)
774
+ elif event_type == "response.failed":
775
+ self._end(**self._partial, error=_response_failed_error(response))
776
+ elif event_type == "response.incomplete":
777
+ self._end(**self._partial)
778
+ elif event_type == "error":
779
+ self._end(**self._partial, error=_response_stream_error(event))
780
+
781
+
782
+ def _inject_chat_usage(kwargs: Mapping[str, Any]) -> tuple[dict[str, Any], bool]:
783
+ current = dict(kwargs)
784
+ stream_options = current.get("stream_options")
785
+ options: dict[str, Any] = (
786
+ dict(cast(Mapping[str, Any], stream_options)) if isinstance(stream_options, Mapping) else {}
787
+ )
788
+ if options.get("include_usage") is True:
789
+ return current, False
790
+ options["include_usage"] = True
791
+ current["stream_options"] = options
792
+ return current, True
793
+
794
+
795
+ def _start_span(
796
+ params: Mapping[str, Any], mapper: RequestMapper, provider: str
797
+ ) -> tuple[telemetry_dev.SpanHandle, Callable[..., None], float]:
798
+ name, fields = mapper(params)
799
+ handle = telemetry_dev.start_span(name, provider=provider, **_clean_fields(fields))
800
+ return handle, _end_once(handle), time.perf_counter()
801
+
802
+
803
+ def _wrap_sync(
804
+ original: Callable[..., Any],
805
+ operation: str,
806
+ request_mapper: RequestMapper,
807
+ response_mapper: ResponseMapper,
808
+ provider: ProviderResolver,
809
+ inject_usage: bool,
810
+ ) -> Callable[..., Any]:
811
+ @wraps(original)
812
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
813
+ resource = args[0] if args else None
814
+ call_kwargs: dict[str, Any] = dict(kwargs)
815
+ streaming = call_kwargs.get("stream") is True
816
+ injected = False
817
+ if operation == "chat" and streaming and inject_usage:
818
+ call_kwargs, injected = _inject_chat_usage(call_kwargs)
819
+ handle, end, started_at = _start_span(call_kwargs, request_mapper, provider(resource))
820
+ try:
821
+ result = original(*args, **call_kwargs)
822
+ except BaseException as exc:
823
+ end(error=exc)
824
+ raise
825
+ if streaming and operation == "chat":
826
+ return _InstrumentedStream(result, handle, injected, started_at)
827
+ if streaming and operation == "responses":
828
+ return _InstrumentedResponsesStream(result, handle, started_at)
829
+ end(**response_mapper(result))
830
+ return result
831
+
832
+ setattr(wrapper, _WRAPPED_ATTR, True)
833
+ setattr(wrapper, _ORIGINAL_ATTR, original)
834
+ return wrapper
835
+
836
+
837
+ def _wrap_async(
838
+ original: Callable[..., Any],
839
+ operation: str,
840
+ request_mapper: RequestMapper,
841
+ response_mapper: ResponseMapper,
842
+ provider: ProviderResolver,
843
+ inject_usage: bool,
844
+ ) -> Callable[..., Any]:
845
+ @wraps(original)
846
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
847
+ resource = args[0] if args else None
848
+ call_kwargs: dict[str, Any] = dict(kwargs)
849
+ streaming = call_kwargs.get("stream") is True
850
+ injected = False
851
+ if operation == "chat" and streaming and inject_usage:
852
+ call_kwargs, injected = _inject_chat_usage(call_kwargs)
853
+ handle, end, started_at = _start_span(call_kwargs, request_mapper, provider(resource))
854
+ try:
855
+ result = await original(*args, **call_kwargs)
856
+ except BaseException as exc:
857
+ end(error=exc)
858
+ raise
859
+ if streaming and operation == "chat":
860
+ return _InstrumentedAsyncStream(result, handle, injected, started_at)
861
+ if streaming and operation == "responses":
862
+ return _InstrumentedAsyncResponsesStream(result, handle, started_at)
863
+ end(**response_mapper(result))
864
+ return result
865
+
866
+ setattr(wrapper, _WRAPPED_ATTR, True)
867
+ setattr(wrapper, _ORIGINAL_ATTR, original)
868
+ return wrapper
869
+
870
+
871
+ def _responses_retrieve_request(params: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
872
+ return (
873
+ "chat unknown",
874
+ _clean_fields(
875
+ {
876
+ "type": "generation",
877
+ "response_id": _string(params.get("response_id")),
878
+ }
879
+ ),
880
+ )
881
+
882
+
883
+ def _retrieve_response_id(args: tuple[Any, ...], kwargs: Mapping[str, Any]) -> Any:
884
+ if "response_id" in kwargs:
885
+ return kwargs["response_id"]
886
+ if args and hasattr(args[0], "_client"):
887
+ return args[1] if len(args) > 1 else None
888
+ return args[0] if args else None
889
+
890
+
891
+ def _wrap_sync_retrieve(
892
+ original: Callable[..., Any],
893
+ operation: str,
894
+ request_mapper: RequestMapper,
895
+ response_mapper: ResponseMapper,
896
+ provider: ProviderResolver,
897
+ inject_usage: bool,
898
+ ) -> Callable[..., Any]:
899
+ del operation, response_mapper, inject_usage
900
+
901
+ @wraps(original)
902
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
903
+ if kwargs.get("stream") is not True:
904
+ return original(*args, **kwargs)
905
+ resource = args[0] if args and hasattr(args[0], "_client") else None
906
+ params = {**kwargs, "response_id": _retrieve_response_id(args, kwargs)}
907
+ handle, end, started_at = _start_span(params, request_mapper, provider(resource))
908
+ try:
909
+ result = original(*args, **kwargs)
910
+ except BaseException as exc:
911
+ end(error=exc)
912
+ raise
913
+ return _InstrumentedResponsesStream(result, handle, started_at)
914
+
915
+ setattr(wrapper, _WRAPPED_ATTR, True)
916
+ setattr(wrapper, _ORIGINAL_ATTR, original)
917
+ return wrapper
918
+
919
+
920
+ def _wrap_async_retrieve(
921
+ original: Callable[..., Any],
922
+ operation: str,
923
+ request_mapper: RequestMapper,
924
+ response_mapper: ResponseMapper,
925
+ provider: ProviderResolver,
926
+ inject_usage: bool,
927
+ ) -> Callable[..., Any]:
928
+ del operation, response_mapper, inject_usage
929
+
930
+ @wraps(original)
931
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
932
+ if kwargs.get("stream") is not True:
933
+ return await original(*args, **kwargs)
934
+ resource = args[0] if args and hasattr(args[0], "_client") else None
935
+ params = {**kwargs, "response_id": _retrieve_response_id(args, kwargs)}
936
+ handle, end, started_at = _start_span(params, request_mapper, provider(resource))
937
+ try:
938
+ result = await original(*args, **kwargs)
939
+ except BaseException as exc:
940
+ end(error=exc)
941
+ raise
942
+ return _InstrumentedAsyncResponsesStream(result, handle, started_at)
943
+
944
+ setattr(wrapper, _WRAPPED_ATTR, True)
945
+ setattr(wrapper, _ORIGINAL_ATTR, original)
946
+ return wrapper
947
+
948
+
949
+ def _patch_instance(
950
+ resource: object,
951
+ method: str,
952
+ wrapper_factory: Callable[
953
+ [Callable[..., Any], str, RequestMapper, ResponseMapper, ProviderResolver, bool],
954
+ Callable[..., Any],
955
+ ],
956
+ operation: str,
957
+ request_mapper: RequestMapper,
958
+ response_mapper: ResponseMapper,
959
+ provider_name: str,
960
+ inject_usage: bool,
961
+ ) -> None:
962
+ current = getattr(resource, method)
963
+ if getattr(current, _WRAPPED_ATTR, False):
964
+ # An instance-level wrapper means this resource is already wrapped. A
965
+ # wrapper inherited from instrument_openai()'s class patch must still be
966
+ # shadowed by an instance wrapper over the underlying original, so the
967
+ # client stays instrumented after uninstrument_openai() restores the class.
968
+ if method in vars(resource):
969
+ return
970
+ original = getattr(current, _ORIGINAL_ATTR, None)
971
+ if original is None:
972
+ return
973
+ current = original.__get__(resource, type(resource))
974
+ wrapped = wrapper_factory(
975
+ current,
976
+ operation,
977
+ request_mapper,
978
+ response_mapper,
979
+ lambda _: provider_name,
980
+ inject_usage,
981
+ )
982
+ setattr(resource, method, wrapped)
983
+
984
+
985
+ def _patch_class(
986
+ cls: type[Any],
987
+ method: str,
988
+ wrapper_factory: Callable[
989
+ [Callable[..., Any], str, RequestMapper, ResponseMapper, ProviderResolver, bool],
990
+ Callable[..., Any],
991
+ ],
992
+ operation: str,
993
+ request_mapper: RequestMapper,
994
+ response_mapper: ResponseMapper,
995
+ inject_usage: bool,
996
+ ) -> None:
997
+ original = getattr(cls, method)
998
+ if getattr(original, _WRAPPED_ATTR, False):
999
+ return
1000
+ _ORIGINALS.append((cls, method, original))
1001
+ setattr(
1002
+ cls,
1003
+ method,
1004
+ wrapper_factory(
1005
+ original,
1006
+ operation,
1007
+ request_mapper,
1008
+ response_mapper,
1009
+ _provider_for_resource,
1010
+ inject_usage,
1011
+ ),
1012
+ )
1013
+
1014
+
1015
+ def wrap_openai(client: _T, *, inject_stream_usage: bool = False) -> _T:
1016
+ if getattr(client, _WRAPPED_ATTR, False):
1017
+ return client
1018
+ provider_name = _provider_for_client(cast(object, client))
1019
+ async_client = isinstance(client, openai.AsyncOpenAI)
1020
+ wrapper_factory = _wrap_async if async_client else _wrap_sync
1021
+ _patch_instance(
1022
+ client.chat.completions, # type: ignore[attr-defined]
1023
+ "create",
1024
+ wrapper_factory,
1025
+ "chat",
1026
+ _chat_request,
1027
+ _chat_response,
1028
+ provider_name,
1029
+ inject_stream_usage,
1030
+ )
1031
+ _patch_instance(
1032
+ client.chat.completions, # type: ignore[attr-defined]
1033
+ "parse",
1034
+ wrapper_factory,
1035
+ "chat",
1036
+ _chat_request,
1037
+ _chat_response,
1038
+ provider_name,
1039
+ inject_stream_usage,
1040
+ )
1041
+ _patch_instance(
1042
+ client.responses, # type: ignore[attr-defined]
1043
+ "create",
1044
+ wrapper_factory,
1045
+ "responses",
1046
+ _responses_request,
1047
+ _responses_response,
1048
+ provider_name,
1049
+ inject_stream_usage,
1050
+ )
1051
+ _patch_instance(
1052
+ client.responses, # type: ignore[attr-defined]
1053
+ "retrieve",
1054
+ _wrap_async_retrieve if async_client else _wrap_sync_retrieve,
1055
+ "responses",
1056
+ _responses_retrieve_request,
1057
+ _responses_response,
1058
+ provider_name,
1059
+ inject_stream_usage,
1060
+ )
1061
+ _patch_instance(
1062
+ client.responses, # type: ignore[attr-defined]
1063
+ "parse",
1064
+ wrapper_factory,
1065
+ "responses",
1066
+ _responses_request,
1067
+ _responses_response,
1068
+ provider_name,
1069
+ inject_stream_usage,
1070
+ )
1071
+ _patch_instance(
1072
+ client.embeddings, # type: ignore[attr-defined]
1073
+ "create",
1074
+ wrapper_factory,
1075
+ "embeddings",
1076
+ _embeddings_request,
1077
+ _embeddings_response,
1078
+ provider_name,
1079
+ inject_stream_usage,
1080
+ )
1081
+ setattr(client, _WRAPPED_ATTR, True)
1082
+ return client
1083
+
1084
+
1085
+ def instrument_openai(*, inject_stream_usage: bool = False) -> None:
1086
+ global _installed
1087
+ with _install_lock:
1088
+ if _installed:
1089
+ return
1090
+ _patch_class(
1091
+ Completions,
1092
+ "create",
1093
+ _wrap_sync,
1094
+ "chat",
1095
+ _chat_request,
1096
+ _chat_response,
1097
+ inject_stream_usage,
1098
+ )
1099
+ _patch_class(
1100
+ Completions,
1101
+ "parse",
1102
+ _wrap_sync,
1103
+ "chat",
1104
+ _chat_request,
1105
+ _chat_response,
1106
+ inject_stream_usage,
1107
+ )
1108
+ _patch_class(
1109
+ AsyncCompletions,
1110
+ "create",
1111
+ _wrap_async,
1112
+ "chat",
1113
+ _chat_request,
1114
+ _chat_response,
1115
+ inject_stream_usage,
1116
+ )
1117
+ _patch_class(
1118
+ AsyncCompletions,
1119
+ "parse",
1120
+ _wrap_async,
1121
+ "chat",
1122
+ _chat_request,
1123
+ _chat_response,
1124
+ inject_stream_usage,
1125
+ )
1126
+ _patch_class(
1127
+ Responses,
1128
+ "create",
1129
+ _wrap_sync,
1130
+ "responses",
1131
+ _responses_request,
1132
+ _responses_response,
1133
+ inject_stream_usage,
1134
+ )
1135
+ _patch_class(
1136
+ Responses,
1137
+ "retrieve",
1138
+ _wrap_sync_retrieve,
1139
+ "responses",
1140
+ _responses_retrieve_request,
1141
+ _responses_response,
1142
+ inject_stream_usage,
1143
+ )
1144
+ _patch_class(
1145
+ Responses,
1146
+ "parse",
1147
+ _wrap_sync,
1148
+ "responses",
1149
+ _responses_request,
1150
+ _responses_response,
1151
+ inject_stream_usage,
1152
+ )
1153
+ _patch_class(
1154
+ AsyncResponses,
1155
+ "create",
1156
+ _wrap_async,
1157
+ "responses",
1158
+ _responses_request,
1159
+ _responses_response,
1160
+ inject_stream_usage,
1161
+ )
1162
+ _patch_class(
1163
+ AsyncResponses,
1164
+ "retrieve",
1165
+ _wrap_async_retrieve,
1166
+ "responses",
1167
+ _responses_retrieve_request,
1168
+ _responses_response,
1169
+ inject_stream_usage,
1170
+ )
1171
+ _patch_class(
1172
+ AsyncResponses,
1173
+ "parse",
1174
+ _wrap_async,
1175
+ "responses",
1176
+ _responses_request,
1177
+ _responses_response,
1178
+ inject_stream_usage,
1179
+ )
1180
+ _patch_class(
1181
+ Embeddings,
1182
+ "create",
1183
+ _wrap_sync,
1184
+ "embeddings",
1185
+ _embeddings_request,
1186
+ _embeddings_response,
1187
+ inject_stream_usage,
1188
+ )
1189
+ _patch_class(
1190
+ AsyncEmbeddings,
1191
+ "create",
1192
+ _wrap_async,
1193
+ "embeddings",
1194
+ _embeddings_request,
1195
+ _embeddings_response,
1196
+ inject_stream_usage,
1197
+ )
1198
+ _installed = True
1199
+
1200
+
1201
+ def uninstrument_openai() -> None:
1202
+ global _installed
1203
+ with _install_lock:
1204
+ while _ORIGINALS:
1205
+ cls, method, original = _ORIGINALS.pop()
1206
+ setattr(cls, method, original)
1207
+ _installed = False
1208
+
1209
+
1210
+ __all__ = ["__version__", "instrument_openai", "uninstrument_openai", "wrap_openai"]
File without changes
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: telemetry-dev-openai
3
+ Version: 0.1.0
4
+ Summary: OpenAI integration for telemetry.dev Python SDK
5
+ Keywords: telemetry,opentelemetry,openai,llm,genai,tracing
6
+ Author: telemetry.dev
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Dist: telemetry-dev>=0.1.0
17
+ Requires-Dist: openai>=2,<3
18
+ Requires-Python: >=3.10
19
+ Project-URL: Homepage, https://telemetry.dev
20
+ Project-URL: Repository, https://github.com/telemetry-dev/telemetry.dev
21
+ Description-Content-Type: text/markdown
22
+
23
+ # telemetry-dev-openai
24
+
25
+ OpenAI SDK instrumentation for telemetry.dev. It wraps the official `openai` Python SDK and emits telemetry.dev generation and embedding spans through `telemetry-dev`.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ pip install telemetry-dev-openai
31
+ ```
32
+
33
+ Initialize the core SDK first:
34
+
35
+ ```py
36
+ import telemetry_dev
37
+
38
+ telemetry_dev.init(
39
+ api_key="td_live_...",
40
+ base_url="http://localhost:4318",
41
+ service_name="my-service",
42
+ )
43
+ ```
44
+
45
+ ## Per-client wrapping
46
+
47
+ ```py
48
+ from openai import OpenAI
49
+ from telemetry_dev_openai import wrap_openai
50
+
51
+ client = wrap_openai(OpenAI())
52
+
53
+ client.chat.completions.create(
54
+ model="gpt-4o-mini",
55
+ messages=[{"role": "user", "content": "Tell me a joke about OpenTelemetry"}],
56
+ )
57
+ ```
58
+
59
+ Use this when you want explicit control over which sync or async clients are instrumented.
60
+
61
+ ## Global instrumentation
62
+
63
+ ```py
64
+ from openai import OpenAI
65
+ from telemetry_dev_openai import instrument_openai, uninstrument_openai
66
+
67
+ instrument_openai()
68
+ client = OpenAI()
69
+
70
+ try:
71
+ client.responses.create(model="gpt-4o-mini", input="Tell me a joke about OpenTelemetry")
72
+ finally:
73
+ uninstrument_openai()
74
+ ```
75
+
76
+ Use this as the app-wide one-liner at startup when all OpenAI clients should be instrumented.
77
+
78
+ ## Instrumented surfaces
79
+
80
+ Sync and async variants are covered:
81
+
82
+ - `client.chat.completions.create(...)`
83
+ - `client.chat.completions.parse(...)`
84
+ - `client.chat.completions.stream(...)`
85
+ - `client.responses.create(...)`
86
+ - `client.responses.parse(...)`
87
+ - `client.responses.stream(...)` when starting a new response
88
+ - `client.embeddings.create(...)`
89
+
90
+ The integration maps native OpenAI request/response shapes directly into telemetry.dev fields. It does not normalize messages into another schema.
91
+
92
+ ## Streaming
93
+
94
+ Chat completion streams are traced. Requests are sent unchanged by default, so token usage is only captured when the caller sets `stream_options={"include_usage": True}` themselves. Pass `inject_stream_usage=True` to `wrap_openai` or `instrument_openai` to inject it automatically; the synthetic usage-only chunk is then hidden from the caller. Injection is opt-in because some providers reject `stream_options` — for example Azure OpenAI "on your data" (`data_sources`) returns 400 for it while plain `stream=True` works.
95
+
96
+ ```py
97
+ client = wrap_openai(OpenAI(), inject_stream_usage=True)
98
+ ```
99
+
100
+ Responses API streams are traced through `responses.create(stream=True)`; terminal `response.completed`, `response.failed`, and `response.incomplete` events close the span.
101
+
102
+ ## Embeddings
103
+
104
+ Embedding calls emit `gen_ai.operation.name = "embeddings"`, request model/input, response model, and token usage. Embedding vectors are intentionally not captured as output.
105
+
106
+ ## Azure OpenAI
107
+
108
+ `wrap_openai(AzureOpenAI(...))` and `wrap_openai(AsyncAzureOpenAI(...))` record provider `azure.ai.openai`. Global class instrumentation detects Azure from the resource client when available.
109
+
110
+ ## Limitations
111
+
112
+ - `with_raw_response` snapshots bound methods on first access in the OpenAI Python SDK. Call `wrap_openai()` or `instrument_openai()` before accessing `with_raw_response` if those methods need instrumentation.
113
+ - `responses.stream(response_id=...)` resumes an existing response through `retrieve()`, which is not instrumented in this version.
114
+ - Unconsumed streams end their spans only when the stream is exhausted, errors, or is closed.
@@ -0,0 +1,5 @@
1
+ telemetry_dev_openai/__init__.py,sha256=wPGHMlQMfoIKWYZHpoxQ0rVY2SpeFXwRJ71HchQ05Qk,40677
2
+ telemetry_dev_openai/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ telemetry_dev_openai-0.1.0.dist-info/WHEEL,sha256=bu7Cckf7DKpj8ztfOQHBiWtXM4xigujiTkrhvV6U2aU,81
4
+ telemetry_dev_openai-0.1.0.dist-info/METADATA,sha256=WbYnFaOIVGudvmOrSgP7LtSLg-pcnweFb8dFgHcDMyg,4175
5
+ telemetry_dev_openai-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.27
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any