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,,
|