telemetry-dev-openrouter 0.1.1__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,1583 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping, Sequence
|
|
7
|
+
from functools import wraps
|
|
8
|
+
from typing import Any, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
import telemetry_dev
|
|
11
|
+
from openrouter.chat import Chat
|
|
12
|
+
from openrouter.embeddings import Embeddings
|
|
13
|
+
from openrouter.responses import Responses
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.1"
|
|
16
|
+
|
|
17
|
+
RequestMapper = Callable[[Mapping[str, Any]], tuple[str, dict[str, Any]]]
|
|
18
|
+
ResponseMapper = Callable[[Any], dict[str, Any]]
|
|
19
|
+
|
|
20
|
+
_PROVIDER = "openrouter"
|
|
21
|
+
_WRAPPED_ATTR = "_telemetry_dev_openrouter_wrapped"
|
|
22
|
+
_ORIGINAL_ATTR = "_telemetry_dev_openrouter_original"
|
|
23
|
+
_CAPTURE_TRUNCATED_ATTRIBUTE = "telemetry.dev.capture.truncated"
|
|
24
|
+
_ORIGINALS: list[tuple[type[Any], str, Any]] = []
|
|
25
|
+
_installed = False
|
|
26
|
+
_install_lock = threading.Lock()
|
|
27
|
+
_T = TypeVar("_T")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _absent(value: Any) -> bool:
|
|
31
|
+
if value is None:
|
|
32
|
+
return True
|
|
33
|
+
value_type = type(cast(object, value))
|
|
34
|
+
return value_type.__module__ == "openrouter.types.basemodel" and value_type.__name__ == "Unset"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _field(value: Any, name: str) -> Any:
|
|
38
|
+
if _absent(value):
|
|
39
|
+
return None
|
|
40
|
+
if isinstance(value, Mapping):
|
|
41
|
+
return cast(Mapping[str, Any], value).get(name)
|
|
42
|
+
return getattr(value, name, None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sequence_items(value: Any) -> list[Any]:
|
|
46
|
+
if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
|
|
47
|
+
return list(cast(Sequence[Any], value))
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _native(value: Any) -> Any:
|
|
52
|
+
if _absent(value):
|
|
53
|
+
return None
|
|
54
|
+
if hasattr(value, "model_dump"):
|
|
55
|
+
return value.model_dump(mode="json", exclude_none=True)
|
|
56
|
+
if isinstance(value, Mapping):
|
|
57
|
+
mapping = cast(Mapping[Any, Any], value)
|
|
58
|
+
return {str(key): _native(item) for key, item in mapping.items() if item is not None}
|
|
59
|
+
sequence = _sequence_items(value)
|
|
60
|
+
if sequence:
|
|
61
|
+
return [_native(item) for item in sequence]
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _snake_case(name: str) -> str:
|
|
66
|
+
return "".join(
|
|
67
|
+
f"_{character.lower()}" if character.isupper() else character for character in name
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _response_native(value: Any, seen: dict[int, Any] | None = None) -> Any:
|
|
72
|
+
if _absent(value):
|
|
73
|
+
return None
|
|
74
|
+
if seen is None:
|
|
75
|
+
seen = {}
|
|
76
|
+
if isinstance(value, str | bytes | bytearray | bool | int | float):
|
|
77
|
+
return value
|
|
78
|
+
value_id = id(value)
|
|
79
|
+
if value_id in seen:
|
|
80
|
+
return seen[value_id]
|
|
81
|
+
raw = _field(value, "raw")
|
|
82
|
+
if raw is not None and (
|
|
83
|
+
_field(value, "is_unknown") is True
|
|
84
|
+
or _field(value, "isUnknown") is True
|
|
85
|
+
or _field(value, "type") == "UNKNOWN"
|
|
86
|
+
):
|
|
87
|
+
return _response_native(raw, seen)
|
|
88
|
+
if hasattr(value, "model_dump"):
|
|
89
|
+
return _response_native(value.model_dump(mode="json", exclude_none=True), seen)
|
|
90
|
+
if isinstance(value, Mapping):
|
|
91
|
+
result: dict[str, Any] = {}
|
|
92
|
+
seen[value_id] = result
|
|
93
|
+
for key, item in cast(Mapping[Any, Any], value).items():
|
|
94
|
+
result[_snake_case(str(key))] = _response_native(item, seen)
|
|
95
|
+
return result
|
|
96
|
+
sequence = _sequence_items(value)
|
|
97
|
+
if sequence:
|
|
98
|
+
result_list: list[Any] = []
|
|
99
|
+
seen[value_id] = result_list
|
|
100
|
+
result_list.extend(_response_native(item, seen) for item in sequence)
|
|
101
|
+
return result_list
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _response_event(event: Any) -> Any:
|
|
106
|
+
raw = _field(event, "raw")
|
|
107
|
+
unknown = (
|
|
108
|
+
_field(event, "is_unknown") is True
|
|
109
|
+
or _field(event, "isUnknown") is True
|
|
110
|
+
or _field(event, "type") == "UNKNOWN"
|
|
111
|
+
)
|
|
112
|
+
return raw if raw is not None and unknown else event
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _event_field(event: Any, snake_name: str, camel_name: str) -> Any:
|
|
116
|
+
value = _field(event, snake_name)
|
|
117
|
+
return value if value is not None else _field(event, camel_name)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _number(value: Any) -> int | float | None:
|
|
121
|
+
if _absent(value) or isinstance(value, bool):
|
|
122
|
+
return None
|
|
123
|
+
if isinstance(value, int | float):
|
|
124
|
+
return value
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _string(value: Any) -> str | None:
|
|
129
|
+
return value if isinstance(value, str) else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _error_code(value: Any) -> str | None:
|
|
133
|
+
if isinstance(value, str):
|
|
134
|
+
return value
|
|
135
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
136
|
+
return None
|
|
137
|
+
return str(value)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _usage(fields: dict[str, int | float | None]) -> dict[str, int | float] | None:
|
|
141
|
+
usage = {key: value for key, value in fields.items() if value is not None}
|
|
142
|
+
return usage or None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _stop_sequences(value: Any) -> list[str] | None:
|
|
146
|
+
if isinstance(value, str):
|
|
147
|
+
return [value]
|
|
148
|
+
strings = [item for item in _sequence_items(value) if isinstance(item, str)]
|
|
149
|
+
return strings or None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _clean_fields(fields: Mapping[str, Any]) -> dict[str, Any]:
|
|
153
|
+
return {key: value for key, value in fields.items() if value is not None}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _cost_usd(raw_usage: Any) -> float | None:
|
|
157
|
+
cost = _number(_field(raw_usage, "cost"))
|
|
158
|
+
if cost is not None:
|
|
159
|
+
return cost
|
|
160
|
+
return _number(_field(_field(raw_usage, "cost_details"), "upstream_inference_cost"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _chat_request(params: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
164
|
+
model = _string(params.get("model"))
|
|
165
|
+
max_completion_tokens = _number(params.get("max_completion_tokens"))
|
|
166
|
+
return (
|
|
167
|
+
f"chat {model or 'unknown'}",
|
|
168
|
+
{
|
|
169
|
+
"type": "generation",
|
|
170
|
+
"model": model,
|
|
171
|
+
"input": _native(params.get("messages")),
|
|
172
|
+
"temperature": _number(params.get("temperature")),
|
|
173
|
+
"top_p": _number(params.get("top_p")),
|
|
174
|
+
"top_k": _number(params.get("top_k")),
|
|
175
|
+
"max_tokens": max_completion_tokens
|
|
176
|
+
if max_completion_tokens is not None
|
|
177
|
+
else _number(params.get("max_tokens")),
|
|
178
|
+
"stop_sequences": _stop_sequences(params.get("stop")),
|
|
179
|
+
"seed": _number(params.get("seed")),
|
|
180
|
+
"frequency_penalty": _number(params.get("frequency_penalty")),
|
|
181
|
+
"presence_penalty": _number(params.get("presence_penalty")),
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _chat_usage(raw: Any) -> dict[str, int | float] | None:
|
|
187
|
+
prompt_details = _field(raw, "prompt_tokens_details")
|
|
188
|
+
completion_details = _field(raw, "completion_tokens_details")
|
|
189
|
+
return _usage(
|
|
190
|
+
{
|
|
191
|
+
"input_tokens": _number(_field(raw, "prompt_tokens")),
|
|
192
|
+
"output_tokens": _number(_field(raw, "completion_tokens")),
|
|
193
|
+
"total_tokens": _number(_field(raw, "total_tokens")),
|
|
194
|
+
"cache_read_input_tokens": _number(_field(prompt_details, "cached_tokens")),
|
|
195
|
+
"cache_creation_input_tokens": _number(_field(prompt_details, "cache_write_tokens")),
|
|
196
|
+
"reasoning_output_tokens": _number(_field(completion_details, "reasoning_tokens")),
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _chat_output_message(message: Any) -> dict[str, Any]:
|
|
202
|
+
if _absent(message):
|
|
203
|
+
return {}
|
|
204
|
+
native = _native(message)
|
|
205
|
+
if not isinstance(native, dict):
|
|
206
|
+
return cast(dict[str, Any], native)
|
|
207
|
+
if _field(message, "content") is None:
|
|
208
|
+
native["content"] = None
|
|
209
|
+
return cast(dict[str, Any], native)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _chat_response(response: Any) -> dict[str, Any]:
|
|
213
|
+
choices = list(_field(response, "choices") or [])
|
|
214
|
+
finish_reasons = [
|
|
215
|
+
reason
|
|
216
|
+
for choice in choices
|
|
217
|
+
if (reason := _string(_field(choice, "finish_reason"))) is not None
|
|
218
|
+
]
|
|
219
|
+
return {
|
|
220
|
+
"response_model": _string(_field(response, "model")),
|
|
221
|
+
"response_id": _string(_field(response, "id")),
|
|
222
|
+
"finish_reason": finish_reasons[0] if finish_reasons else None,
|
|
223
|
+
"output": [_chat_output_message(_field(choice, "message")) for choice in choices],
|
|
224
|
+
"usage": _chat_usage(_field(response, "usage")),
|
|
225
|
+
"cost_usd": _cost_usd(_field(response, "usage")),
|
|
226
|
+
"attributes": (
|
|
227
|
+
{"gen_ai.response.finish_reasons": finish_reasons} if len(finish_reasons) > 1 else None
|
|
228
|
+
),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _responses_request(params: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
233
|
+
model = _string(params.get("model"))
|
|
234
|
+
return (
|
|
235
|
+
f"chat {model or 'unknown'}",
|
|
236
|
+
{
|
|
237
|
+
"type": "generation",
|
|
238
|
+
"model": model,
|
|
239
|
+
"input": _native(params.get("input")),
|
|
240
|
+
"system_instructions": _native(params.get("instructions")),
|
|
241
|
+
"temperature": _number(params.get("temperature")),
|
|
242
|
+
"top_p": _number(params.get("top_p")),
|
|
243
|
+
"top_k": _number(params.get("top_k")),
|
|
244
|
+
"max_tokens": _number(params.get("max_output_tokens")),
|
|
245
|
+
"frequency_penalty": _number(params.get("frequency_penalty")),
|
|
246
|
+
"presence_penalty": _number(params.get("presence_penalty")),
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _responses_usage(raw: Any) -> dict[str, int | float] | None:
|
|
252
|
+
input_details = _field(raw, "input_tokens_details")
|
|
253
|
+
output_details = _field(raw, "output_tokens_details")
|
|
254
|
+
return _usage(
|
|
255
|
+
{
|
|
256
|
+
"input_tokens": _number(_field(raw, "input_tokens")),
|
|
257
|
+
"output_tokens": _number(_field(raw, "output_tokens")),
|
|
258
|
+
"total_tokens": _number(_field(raw, "total_tokens")),
|
|
259
|
+
"cache_read_input_tokens": _number(_field(input_details, "cached_tokens")),
|
|
260
|
+
"cache_creation_input_tokens": _number(_field(input_details, "cache_write_tokens")),
|
|
261
|
+
"reasoning_output_tokens": _number(_field(output_details, "reasoning_tokens")),
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _response_failed_error(response: Any) -> RuntimeError:
|
|
267
|
+
error = _field(response, "error")
|
|
268
|
+
if error is None:
|
|
269
|
+
return RuntimeError("response.failed")
|
|
270
|
+
code = _error_code(_field(error, "code"))
|
|
271
|
+
message = _string(_field(error, "message"))
|
|
272
|
+
if code and message:
|
|
273
|
+
return RuntimeError(f"response.failed: {code}: {message}")
|
|
274
|
+
if code:
|
|
275
|
+
return RuntimeError(f"response.failed: {code}")
|
|
276
|
+
if message:
|
|
277
|
+
return RuntimeError(f"response.failed: {message}")
|
|
278
|
+
return RuntimeError("response.failed")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _response_stream_error(event: Any) -> RuntimeError:
|
|
282
|
+
code = _error_code(_field(event, "code"))
|
|
283
|
+
message = _string(_field(event, "message"))
|
|
284
|
+
if code and message:
|
|
285
|
+
return RuntimeError(f"response.error: {code}: {message}")
|
|
286
|
+
if code:
|
|
287
|
+
return RuntimeError(f"response.error: {code}")
|
|
288
|
+
if message:
|
|
289
|
+
return RuntimeError(f"response.error: {message}")
|
|
290
|
+
return RuntimeError("response.error")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _chunk_error(chunk: Any) -> RuntimeError | None:
|
|
294
|
+
error = _field(chunk, "error")
|
|
295
|
+
if error is None:
|
|
296
|
+
return None
|
|
297
|
+
code = _error_code(_field(error, "code"))
|
|
298
|
+
message = _string(_field(error, "message"))
|
|
299
|
+
if code and message:
|
|
300
|
+
return RuntimeError(f"stream error {code}: {message}")
|
|
301
|
+
if message:
|
|
302
|
+
return RuntimeError(f"stream error: {message}")
|
|
303
|
+
return RuntimeError(f"stream error {code}" if code else "stream error")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _responses_response(
|
|
307
|
+
response: Any, *, include_error: bool = True, include_output: bool = True
|
|
308
|
+
) -> dict[str, Any]:
|
|
309
|
+
status = _string(_field(response, "status"))
|
|
310
|
+
incomplete_details = _field(response, "incomplete_details")
|
|
311
|
+
fields: dict[str, Any] = {
|
|
312
|
+
"response_model": _string(_field(response, "model")),
|
|
313
|
+
"response_id": _string(_field(response, "id")),
|
|
314
|
+
"usage": _responses_usage(_field(response, "usage")),
|
|
315
|
+
"cost_usd": _cost_usd(_field(response, "usage")),
|
|
316
|
+
"finish_reason": "stop"
|
|
317
|
+
if status == "completed"
|
|
318
|
+
else _string(_field(incomplete_details, "reason")) or status,
|
|
319
|
+
}
|
|
320
|
+
if include_output:
|
|
321
|
+
fields["output"] = _response_native(_field(response, "output"))
|
|
322
|
+
if include_error and status == "failed":
|
|
323
|
+
fields["error"] = _response_failed_error(response)
|
|
324
|
+
return fields
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _embeddings_request(params: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
328
|
+
model = _string(params.get("model"))
|
|
329
|
+
return (
|
|
330
|
+
f"embeddings {model or 'unknown'}",
|
|
331
|
+
{"type": "embedding", "model": model, "input": _native(params.get("input"))},
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _embeddings_response(response: Any) -> dict[str, Any]:
|
|
336
|
+
if isinstance(response, str) or _absent(response):
|
|
337
|
+
return {}
|
|
338
|
+
raw_usage = _field(response, "usage")
|
|
339
|
+
return {
|
|
340
|
+
"response_model": _string(_field(response, "model")),
|
|
341
|
+
"response_id": _string(_field(response, "id")),
|
|
342
|
+
"usage": _usage(
|
|
343
|
+
{
|
|
344
|
+
"input_tokens": _number(_field(raw_usage, "prompt_tokens")),
|
|
345
|
+
"total_tokens": _number(_field(raw_usage, "total_tokens")),
|
|
346
|
+
}
|
|
347
|
+
),
|
|
348
|
+
"cost_usd": _cost_usd(raw_usage),
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _end_once(handle: telemetry_dev.SpanHandle) -> Callable[..., None]:
|
|
353
|
+
ended = False
|
|
354
|
+
|
|
355
|
+
def end(**fields: Any) -> None:
|
|
356
|
+
nonlocal ended
|
|
357
|
+
if ended:
|
|
358
|
+
return
|
|
359
|
+
ended = True
|
|
360
|
+
handle.end(**_clean_fields(fields))
|
|
361
|
+
|
|
362
|
+
return end
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class _ChatChoice:
|
|
366
|
+
def __init__(self) -> None:
|
|
367
|
+
self.role: str | None = None
|
|
368
|
+
self.content = ""
|
|
369
|
+
self.reasoning = ""
|
|
370
|
+
self.reasoning_details: list[Any] = []
|
|
371
|
+
self.refusal = ""
|
|
372
|
+
self.tool_calls: dict[int, dict[str, Any]] = {}
|
|
373
|
+
self.finish_reason: str | None = None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _choice_state(
|
|
377
|
+
states: dict[int, _ChatChoice], index: int, budget: telemetry_dev.CaptureBudget
|
|
378
|
+
) -> _ChatChoice | None:
|
|
379
|
+
if index not in states:
|
|
380
|
+
if len(states) >= budget.max_items:
|
|
381
|
+
budget.truncated = True
|
|
382
|
+
return None
|
|
383
|
+
states[index] = _ChatChoice()
|
|
384
|
+
return states[index]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _merge_tool_call(state: _ChatChoice, delta: Any) -> None:
|
|
388
|
+
index = _field(delta, "index")
|
|
389
|
+
tool_index = index if isinstance(index, int) else len(state.tool_calls)
|
|
390
|
+
current = dict(state.tool_calls.get(tool_index, {}))
|
|
391
|
+
tool_id = _field(delta, "id")
|
|
392
|
+
tool_type = _field(delta, "type")
|
|
393
|
+
if tool_id is not None:
|
|
394
|
+
current["id"] = tool_id
|
|
395
|
+
if tool_type is not None:
|
|
396
|
+
current["type"] = tool_type
|
|
397
|
+
incoming_function = _field(delta, "function")
|
|
398
|
+
if incoming_function is not None:
|
|
399
|
+
current_function = dict(cast(Mapping[str, Any], current.get("function", {})))
|
|
400
|
+
name = _field(incoming_function, "name")
|
|
401
|
+
arguments = _field(incoming_function, "arguments")
|
|
402
|
+
if name is not None:
|
|
403
|
+
current_function["name"] = name
|
|
404
|
+
if isinstance(arguments, str):
|
|
405
|
+
current_function["arguments"] = f"{current_function.get('arguments', '')}{arguments}"
|
|
406
|
+
current["function"] = current_function
|
|
407
|
+
state.tool_calls[tool_index] = current
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _record_chat_chunk(
|
|
411
|
+
chunk: Any, states: dict[int, _ChatChoice], budget: telemetry_dev.CaptureBudget
|
|
412
|
+
) -> dict[str, Any]:
|
|
413
|
+
for choice in _sequence_items(_field(chunk, "choices")):
|
|
414
|
+
index = _field(choice, "index")
|
|
415
|
+
state = _choice_state(states, index if isinstance(index, int) else 0, budget)
|
|
416
|
+
if state is None:
|
|
417
|
+
continue
|
|
418
|
+
delta = _field(choice, "delta")
|
|
419
|
+
role = _field(delta, "role")
|
|
420
|
+
content = _field(delta, "content")
|
|
421
|
+
reasoning = _field(delta, "reasoning")
|
|
422
|
+
refusal = _field(delta, "refusal")
|
|
423
|
+
if isinstance(role, str):
|
|
424
|
+
state.role = role
|
|
425
|
+
if isinstance(content, str) and budget.accept(content):
|
|
426
|
+
state.content += content
|
|
427
|
+
if isinstance(reasoning, str) and budget.accept(reasoning):
|
|
428
|
+
state.reasoning += reasoning
|
|
429
|
+
for detail in _sequence_items(_field(delta, "reasoning_details")):
|
|
430
|
+
native_detail = _native(detail)
|
|
431
|
+
if native_detail is not None and budget.accept(native_detail):
|
|
432
|
+
state.reasoning_details.append(native_detail)
|
|
433
|
+
if isinstance(refusal, str) and budget.accept(refusal):
|
|
434
|
+
state.refusal += refusal
|
|
435
|
+
for tool_call in _sequence_items(_field(delta, "tool_calls")):
|
|
436
|
+
if budget.accept(tool_call):
|
|
437
|
+
_merge_tool_call(state, tool_call)
|
|
438
|
+
finish_reason = _field(choice, "finish_reason")
|
|
439
|
+
if isinstance(finish_reason, str):
|
|
440
|
+
state.finish_reason = finish_reason
|
|
441
|
+
return {
|
|
442
|
+
"response_id": _string(_field(chunk, "id")),
|
|
443
|
+
"response_model": _string(_field(chunk, "model")),
|
|
444
|
+
"usage": _chat_usage(_field(chunk, "usage")),
|
|
445
|
+
"cost_usd": _cost_usd(_field(chunk, "usage")),
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _chat_output(states: Mapping[int, _ChatChoice]) -> list[dict[str, Any]]:
|
|
450
|
+
output: list[dict[str, Any]] = []
|
|
451
|
+
for _, state in sorted(states.items()):
|
|
452
|
+
message: dict[str, Any] = {"role": state.role or "assistant"}
|
|
453
|
+
if state.content:
|
|
454
|
+
message["content"] = state.content
|
|
455
|
+
elif state.tool_calls:
|
|
456
|
+
message["content"] = None
|
|
457
|
+
if state.reasoning:
|
|
458
|
+
message["reasoning"] = state.reasoning
|
|
459
|
+
if state.reasoning_details:
|
|
460
|
+
message["reasoning_details"] = state.reasoning_details
|
|
461
|
+
if state.refusal:
|
|
462
|
+
message["refusal"] = state.refusal
|
|
463
|
+
if state.tool_calls:
|
|
464
|
+
message["tool_calls"] = [call for _, call in sorted(state.tool_calls.items())]
|
|
465
|
+
output.append(message)
|
|
466
|
+
return output
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _chat_partial(
|
|
470
|
+
states: Mapping[int, _ChatChoice],
|
|
471
|
+
usage: dict[str, int | float] | None,
|
|
472
|
+
cost_usd: float | None,
|
|
473
|
+
budget: telemetry_dev.CaptureBudget,
|
|
474
|
+
) -> dict[str, Any]:
|
|
475
|
+
finish_reasons = [
|
|
476
|
+
state.finish_reason
|
|
477
|
+
for _, state in sorted(states.items())
|
|
478
|
+
if state.finish_reason is not None
|
|
479
|
+
]
|
|
480
|
+
return _mark_capture_truncated(
|
|
481
|
+
{
|
|
482
|
+
"output": _chat_output(states) if states else None,
|
|
483
|
+
"usage": usage,
|
|
484
|
+
"cost_usd": cost_usd,
|
|
485
|
+
"finish_reason": finish_reasons[0] if finish_reasons else None,
|
|
486
|
+
"attributes": (
|
|
487
|
+
{"gen_ai.response.finish_reasons": finish_reasons}
|
|
488
|
+
if len(finish_reasons) > 1
|
|
489
|
+
else None
|
|
490
|
+
),
|
|
491
|
+
},
|
|
492
|
+
budget,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _mark_capture_truncated(
|
|
497
|
+
fields: dict[str, Any], budget: telemetry_dev.CaptureBudget
|
|
498
|
+
) -> dict[str, Any]:
|
|
499
|
+
if budget.truncated:
|
|
500
|
+
attributes = dict(fields.get("attributes") or {})
|
|
501
|
+
attributes[_CAPTURE_TRUNCATED_ATTRIBUTE] = True
|
|
502
|
+
fields["attributes"] = attributes
|
|
503
|
+
elif _CAPTURE_TRUNCATED_ATTRIBUTE in (fields.get("attributes") or {}):
|
|
504
|
+
attributes = dict(fields["attributes"])
|
|
505
|
+
del attributes[_CAPTURE_TRUNCATED_ATTRIBUTE]
|
|
506
|
+
fields["attributes"] = attributes or None
|
|
507
|
+
return fields
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _hook_response_close(inner: Any, finish: Callable[[], None]) -> None:
|
|
511
|
+
response = getattr(inner, "response", None)
|
|
512
|
+
if response is None:
|
|
513
|
+
return
|
|
514
|
+
close = getattr(response, "close", None)
|
|
515
|
+
if callable(close):
|
|
516
|
+
|
|
517
|
+
def _close_hook(*args: Any, **kwargs: Any) -> Any:
|
|
518
|
+
try:
|
|
519
|
+
return close(*args, **kwargs)
|
|
520
|
+
finally:
|
|
521
|
+
finish()
|
|
522
|
+
|
|
523
|
+
response.close = _close_hook
|
|
524
|
+
aclose = getattr(response, "aclose", None)
|
|
525
|
+
if callable(aclose):
|
|
526
|
+
aclose_fn = cast(Callable[..., Any], aclose)
|
|
527
|
+
|
|
528
|
+
async def _aclose_hook(*args: Any, **kwargs: Any) -> Any:
|
|
529
|
+
try:
|
|
530
|
+
return await aclose_fn(*args, **kwargs)
|
|
531
|
+
finally:
|
|
532
|
+
finish()
|
|
533
|
+
|
|
534
|
+
response.aclose = _aclose_hook
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
async def _close_async_stream(inner: Any) -> None:
|
|
538
|
+
close = getattr(inner, "close", None)
|
|
539
|
+
if not callable(close):
|
|
540
|
+
return
|
|
541
|
+
result = close()
|
|
542
|
+
if not hasattr(result, "__await__"):
|
|
543
|
+
return
|
|
544
|
+
task = asyncio.ensure_future(cast(Awaitable[Any], result))
|
|
545
|
+
cancellation: asyncio.CancelledError | None = None
|
|
546
|
+
while not task.done():
|
|
547
|
+
try:
|
|
548
|
+
await asyncio.shield(task)
|
|
549
|
+
except asyncio.CancelledError as error:
|
|
550
|
+
cancellation = error
|
|
551
|
+
if cancellation is not None:
|
|
552
|
+
task.result()
|
|
553
|
+
raise cancellation
|
|
554
|
+
task.result()
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class _InstrumentedStream:
|
|
558
|
+
def __init__(self, inner: Any, handle: telemetry_dev.SpanHandle, started_at: float) -> None:
|
|
559
|
+
self._inner = inner
|
|
560
|
+
self._end = _end_once(handle)
|
|
561
|
+
self._handle = handle
|
|
562
|
+
self._started_at = started_at
|
|
563
|
+
self._states: dict[int, _ChatChoice] = {}
|
|
564
|
+
self._usage: dict[str, int | float] | None = None
|
|
565
|
+
self._cost_usd: float | None = None
|
|
566
|
+
self._saw_first = False
|
|
567
|
+
self._consume: Iterator[Any] | None = None
|
|
568
|
+
self._in_next = False
|
|
569
|
+
self._budget = telemetry_dev.CaptureBudget.from_client()
|
|
570
|
+
_hook_response_close(inner, self._on_response_close)
|
|
571
|
+
|
|
572
|
+
def _iterate(self) -> Iterator[Any]:
|
|
573
|
+
try:
|
|
574
|
+
while True:
|
|
575
|
+
self._in_next = True
|
|
576
|
+
try:
|
|
577
|
+
chunk = next(self._inner)
|
|
578
|
+
except StopIteration:
|
|
579
|
+
break
|
|
580
|
+
except BaseException as exc:
|
|
581
|
+
self._end(**self._partial(), error=exc)
|
|
582
|
+
raise
|
|
583
|
+
finally:
|
|
584
|
+
self._in_next = False
|
|
585
|
+
if not self._record(chunk):
|
|
586
|
+
self._end(**self._partial(), error=_chunk_error(chunk))
|
|
587
|
+
yield chunk
|
|
588
|
+
finally:
|
|
589
|
+
self.close()
|
|
590
|
+
|
|
591
|
+
def __iter__(self) -> Iterator[Any]:
|
|
592
|
+
if self._consume is None:
|
|
593
|
+
self._consume = self._iterate()
|
|
594
|
+
return self._consume
|
|
595
|
+
|
|
596
|
+
def __next__(self) -> Any:
|
|
597
|
+
return next(self.__iter__())
|
|
598
|
+
|
|
599
|
+
def __enter__(self) -> _InstrumentedStream:
|
|
600
|
+
enter = getattr(self._inner, "__enter__", None)
|
|
601
|
+
if enter is not None:
|
|
602
|
+
enter()
|
|
603
|
+
return self
|
|
604
|
+
|
|
605
|
+
def __exit__(
|
|
606
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any
|
|
607
|
+
) -> None:
|
|
608
|
+
if exc is not None:
|
|
609
|
+
self._end(**self._partial(), error=exc)
|
|
610
|
+
self.close()
|
|
611
|
+
|
|
612
|
+
def _partial(self) -> dict[str, Any]:
|
|
613
|
+
return _chat_partial(self._states, self._usage, self._cost_usd, self._budget)
|
|
614
|
+
|
|
615
|
+
def _finish(self) -> None:
|
|
616
|
+
self._end(**self._partial())
|
|
617
|
+
|
|
618
|
+
def _on_response_close(self) -> None:
|
|
619
|
+
if not self._in_next:
|
|
620
|
+
self._finish()
|
|
621
|
+
|
|
622
|
+
def close(self) -> None:
|
|
623
|
+
self._finish()
|
|
624
|
+
close = getattr(self._inner, "close", None)
|
|
625
|
+
if close is not None:
|
|
626
|
+
close()
|
|
627
|
+
|
|
628
|
+
def __getattr__(self, name: str) -> Any:
|
|
629
|
+
return getattr(self._inner, name)
|
|
630
|
+
|
|
631
|
+
def _record(self, chunk: Any) -> bool:
|
|
632
|
+
update = _record_chat_chunk(chunk, self._states, self._budget)
|
|
633
|
+
if not self._saw_first:
|
|
634
|
+
self._saw_first = True
|
|
635
|
+
self._handle.update(
|
|
636
|
+
time_to_first_chunk_ms=(time.perf_counter() - self._started_at) * 1000,
|
|
637
|
+
response_id=update.get("response_id"),
|
|
638
|
+
response_model=update.get("response_model"),
|
|
639
|
+
)
|
|
640
|
+
if update.get("usage") is not None:
|
|
641
|
+
self._usage = update["usage"]
|
|
642
|
+
if update.get("cost_usd") is not None:
|
|
643
|
+
self._cost_usd = update["cost_usd"]
|
|
644
|
+
return _chunk_error(chunk) is None
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
class _InstrumentedAsyncStream:
|
|
648
|
+
def __init__(self, inner: Any, handle: telemetry_dev.SpanHandle, started_at: float) -> None:
|
|
649
|
+
self._inner = inner
|
|
650
|
+
self._end = _end_once(handle)
|
|
651
|
+
self._handle = handle
|
|
652
|
+
self._started_at = started_at
|
|
653
|
+
self._states: dict[int, _ChatChoice] = {}
|
|
654
|
+
self._usage: dict[str, int | float] | None = None
|
|
655
|
+
self._cost_usd: float | None = None
|
|
656
|
+
self._saw_first = False
|
|
657
|
+
self._consume: AsyncIterator[Any] | None = None
|
|
658
|
+
self._in_next = False
|
|
659
|
+
self._budget = telemetry_dev.CaptureBudget.from_client()
|
|
660
|
+
_hook_response_close(inner, self._on_response_close)
|
|
661
|
+
|
|
662
|
+
async def _aiterate(self) -> AsyncIterator[Any]:
|
|
663
|
+
try:
|
|
664
|
+
while True:
|
|
665
|
+
self._in_next = True
|
|
666
|
+
try:
|
|
667
|
+
chunk = await self._inner.__anext__()
|
|
668
|
+
except StopAsyncIteration:
|
|
669
|
+
break
|
|
670
|
+
except BaseException as exc:
|
|
671
|
+
self._end(**self._partial(), error=exc)
|
|
672
|
+
raise
|
|
673
|
+
finally:
|
|
674
|
+
self._in_next = False
|
|
675
|
+
if not self._record(chunk):
|
|
676
|
+
self._end(**self._partial(), error=_chunk_error(chunk))
|
|
677
|
+
yield chunk
|
|
678
|
+
finally:
|
|
679
|
+
await self.close()
|
|
680
|
+
|
|
681
|
+
def __aiter__(self) -> AsyncIterator[Any]:
|
|
682
|
+
if self._consume is None:
|
|
683
|
+
self._consume = self._aiterate()
|
|
684
|
+
return self._consume
|
|
685
|
+
|
|
686
|
+
async def __anext__(self) -> Any:
|
|
687
|
+
return await self.__aiter__().__anext__()
|
|
688
|
+
|
|
689
|
+
async def __aenter__(self) -> _InstrumentedAsyncStream:
|
|
690
|
+
enter = getattr(self._inner, "__aenter__", None)
|
|
691
|
+
if enter is not None:
|
|
692
|
+
await enter()
|
|
693
|
+
return self
|
|
694
|
+
|
|
695
|
+
async def __aexit__(
|
|
696
|
+
self,
|
|
697
|
+
exc_type: type[BaseException] | None,
|
|
698
|
+
exc: BaseException | None,
|
|
699
|
+
tb: Any,
|
|
700
|
+
) -> None:
|
|
701
|
+
if exc is not None:
|
|
702
|
+
self._end(**self._partial(), error=exc)
|
|
703
|
+
await self.close()
|
|
704
|
+
|
|
705
|
+
def _partial(self) -> dict[str, Any]:
|
|
706
|
+
return _chat_partial(self._states, self._usage, self._cost_usd, self._budget)
|
|
707
|
+
|
|
708
|
+
def _finish(self) -> None:
|
|
709
|
+
self._end(**self._partial())
|
|
710
|
+
|
|
711
|
+
def _on_response_close(self) -> None:
|
|
712
|
+
if not self._in_next:
|
|
713
|
+
self._finish()
|
|
714
|
+
|
|
715
|
+
async def close(self) -> None:
|
|
716
|
+
try:
|
|
717
|
+
await _close_async_stream(self._inner)
|
|
718
|
+
finally:
|
|
719
|
+
self._finish()
|
|
720
|
+
|
|
721
|
+
def __getattr__(self, name: str) -> Any:
|
|
722
|
+
return getattr(self._inner, name)
|
|
723
|
+
|
|
724
|
+
def _record(self, chunk: Any) -> bool:
|
|
725
|
+
update = _record_chat_chunk(chunk, self._states, self._budget)
|
|
726
|
+
if not self._saw_first:
|
|
727
|
+
self._saw_first = True
|
|
728
|
+
self._handle.update(
|
|
729
|
+
time_to_first_chunk_ms=(time.perf_counter() - self._started_at) * 1000,
|
|
730
|
+
response_id=update.get("response_id"),
|
|
731
|
+
response_model=update.get("response_model"),
|
|
732
|
+
)
|
|
733
|
+
if update.get("usage") is not None:
|
|
734
|
+
self._usage = update["usage"]
|
|
735
|
+
if update.get("cost_usd") is not None:
|
|
736
|
+
self._cost_usd = update["cost_usd"]
|
|
737
|
+
return _chunk_error(chunk) is None
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
class _ResponsesStreamState:
|
|
741
|
+
def __init__(self) -> None:
|
|
742
|
+
self.partial: dict[str, Any] = {}
|
|
743
|
+
self.retained_output: Any | None = None
|
|
744
|
+
self._budget = telemetry_dev.CaptureBudget.from_client()
|
|
745
|
+
self._items: dict[int, dict[str, Any]] = {}
|
|
746
|
+
self._content: dict[tuple[int, int], dict[str, Any]] = {}
|
|
747
|
+
self._summary: dict[tuple[int, int], dict[str, Any]] = {}
|
|
748
|
+
self._synthetic_events: list[dict[str, Any]] = []
|
|
749
|
+
self._provider_events_truncated = False
|
|
750
|
+
|
|
751
|
+
def _output_index(self, event: Any) -> int:
|
|
752
|
+
return self._event_index(event, "output_index", "outputIndex")
|
|
753
|
+
|
|
754
|
+
@staticmethod
|
|
755
|
+
def _event_index(event: Any, snake_name: str, camel_name: str) -> int:
|
|
756
|
+
value = _event_field(event, snake_name, camel_name)
|
|
757
|
+
return value if type(value) is int and value >= 0 else 0
|
|
758
|
+
|
|
759
|
+
def _output(self) -> list[dict[str, Any]]:
|
|
760
|
+
return [
|
|
761
|
+
*[value for _, value in sorted(self._items.items())],
|
|
762
|
+
*self._synthetic_events,
|
|
763
|
+
]
|
|
764
|
+
|
|
765
|
+
def _output_with_item(self, output_index: int, item: dict[str, Any]) -> list[dict[str, Any]]:
|
|
766
|
+
items = {**self._items, output_index: item}
|
|
767
|
+
return [*[value for _, value in sorted(items.items())], *self._synthetic_events]
|
|
768
|
+
|
|
769
|
+
@staticmethod
|
|
770
|
+
def _can_accept(budget: telemetry_dev.CaptureBudget, value: Any) -> bool:
|
|
771
|
+
candidate = telemetry_dev.CaptureBudget(budget.max_bytes, budget.max_items)
|
|
772
|
+
candidate.bytes_used = budget.bytes_used
|
|
773
|
+
candidate.items_used = budget.items_used
|
|
774
|
+
candidate.truncated = budget.truncated
|
|
775
|
+
return candidate.accept(value)
|
|
776
|
+
|
|
777
|
+
def _replace_output(self, output: Any) -> bool:
|
|
778
|
+
replacement = telemetry_dev.CaptureBudget.from_client()
|
|
779
|
+
if not replacement.accept(output):
|
|
780
|
+
self._budget.truncated = True
|
|
781
|
+
return False
|
|
782
|
+
replacement.truncated = self._provider_events_truncated
|
|
783
|
+
self._budget = replacement
|
|
784
|
+
return True
|
|
785
|
+
|
|
786
|
+
def _sync_output(self) -> None:
|
|
787
|
+
self.retained_output = self._output()
|
|
788
|
+
self.partial["output"] = self.retained_output
|
|
789
|
+
|
|
790
|
+
def _hydrate_item(self, output_index: int, item: dict[str, Any]) -> dict[str, Any]:
|
|
791
|
+
self._items[output_index] = item
|
|
792
|
+
self._content = {key: part for key, part in self._content.items() if key[0] != output_index}
|
|
793
|
+
self._summary = {key: part for key, part in self._summary.items() if key[0] != output_index}
|
|
794
|
+
for index, part in enumerate(_sequence_items(item.get("content"))):
|
|
795
|
+
if isinstance(part, dict):
|
|
796
|
+
self._content[(output_index, index)] = cast(dict[str, Any], part)
|
|
797
|
+
for index, part in enumerate(_sequence_items(item.get("summary"))):
|
|
798
|
+
if isinstance(part, dict):
|
|
799
|
+
self._summary[(output_index, index)] = cast(dict[str, Any], part)
|
|
800
|
+
return item
|
|
801
|
+
|
|
802
|
+
def _item(self, event: Any, event_type: str) -> dict[str, Any]:
|
|
803
|
+
output_index = self._output_index(event)
|
|
804
|
+
existing = self._items.get(output_index)
|
|
805
|
+
if existing is not None:
|
|
806
|
+
return existing
|
|
807
|
+
reasoning = event_type.startswith("response.reasoning_")
|
|
808
|
+
item: dict[str, Any] = {
|
|
809
|
+
"id": _string(_event_field(event, "item_id", "itemId")) or f"output_{output_index}",
|
|
810
|
+
"type": "reasoning" if reasoning else "message",
|
|
811
|
+
"status": "in_progress",
|
|
812
|
+
**({"summary": []} if reasoning else {"role": "assistant"}),
|
|
813
|
+
"content": [],
|
|
814
|
+
}
|
|
815
|
+
self._items[output_index] = item
|
|
816
|
+
return item
|
|
817
|
+
|
|
818
|
+
def _sync_item(self, output_index: int, item: dict[str, Any]) -> None:
|
|
819
|
+
content = [
|
|
820
|
+
value
|
|
821
|
+
for (item_index, _), value in sorted(self._content.items())
|
|
822
|
+
if item_index == output_index
|
|
823
|
+
]
|
|
824
|
+
summary = [
|
|
825
|
+
value
|
|
826
|
+
for (item_index, _), value in sorted(self._summary.items())
|
|
827
|
+
if item_index == output_index
|
|
828
|
+
]
|
|
829
|
+
if content:
|
|
830
|
+
item["content"] = content
|
|
831
|
+
if summary:
|
|
832
|
+
item["summary"] = summary
|
|
833
|
+
|
|
834
|
+
def _item_candidate(
|
|
835
|
+
self,
|
|
836
|
+
item: dict[str, Any],
|
|
837
|
+
*,
|
|
838
|
+
output_index: int,
|
|
839
|
+
content_index: int | None = None,
|
|
840
|
+
content: dict[str, Any] | None = None,
|
|
841
|
+
summary_index: int | None = None,
|
|
842
|
+
summary: dict[str, Any] | None = None,
|
|
843
|
+
) -> dict[str, Any]:
|
|
844
|
+
candidate = dict(item)
|
|
845
|
+
if content_index is not None and content is not None:
|
|
846
|
+
values = {
|
|
847
|
+
index: value
|
|
848
|
+
for (item_index, index), value in self._content.items()
|
|
849
|
+
if item_index == output_index
|
|
850
|
+
}
|
|
851
|
+
values[content_index] = content
|
|
852
|
+
candidate["content"] = [value for _, value in sorted(values.items())]
|
|
853
|
+
if summary_index is not None and summary is not None:
|
|
854
|
+
values = {
|
|
855
|
+
index: value
|
|
856
|
+
for (item_index, index), value in self._summary.items()
|
|
857
|
+
if item_index == output_index
|
|
858
|
+
}
|
|
859
|
+
values[summary_index] = summary
|
|
860
|
+
candidate["summary"] = [value for _, value in sorted(values.items())]
|
|
861
|
+
return candidate
|
|
862
|
+
|
|
863
|
+
def _record_event(self, event: Any) -> bool:
|
|
864
|
+
event_type = _string(_field(event, "type"))
|
|
865
|
+
if event_type is None:
|
|
866
|
+
return False
|
|
867
|
+
output_index = self._output_index(event)
|
|
868
|
+
if event_type in {"response.output_item.added", "response.output_item.done"}:
|
|
869
|
+
raw_item = _field(event, "item")
|
|
870
|
+
replacement = event_type.endswith(".done")
|
|
871
|
+
preflight = telemetry_dev.CaptureBudget.from_client() if replacement else self._budget
|
|
872
|
+
if not self._can_accept(preflight, raw_item):
|
|
873
|
+
self._budget.truncated = True
|
|
874
|
+
return False
|
|
875
|
+
item = _response_native(raw_item)
|
|
876
|
+
if not isinstance(item, dict):
|
|
877
|
+
return False
|
|
878
|
+
item = cast(dict[str, Any], item)
|
|
879
|
+
if replacement:
|
|
880
|
+
if not self._replace_output(self._output_with_item(output_index, item)):
|
|
881
|
+
return False
|
|
882
|
+
elif not self._budget.accept(item):
|
|
883
|
+
return False
|
|
884
|
+
self._hydrate_item(output_index, item)
|
|
885
|
+
return True
|
|
886
|
+
if event_type in {"response.content_part.added", "response.content_part.done"}:
|
|
887
|
+
event_part = _field(event, "part")
|
|
888
|
+
replacement = event_type.endswith(".done")
|
|
889
|
+
preflight = telemetry_dev.CaptureBudget.from_client() if replacement else self._budget
|
|
890
|
+
if not self._can_accept(preflight, event_part):
|
|
891
|
+
self._budget.truncated = True
|
|
892
|
+
return False
|
|
893
|
+
raw_part = _response_native(event_part)
|
|
894
|
+
if not isinstance(raw_part, dict):
|
|
895
|
+
return False
|
|
896
|
+
part = cast(dict[str, Any], raw_part)
|
|
897
|
+
existing = self._items.get(output_index)
|
|
898
|
+
item = self._item(event, event_type)
|
|
899
|
+
content_index = self._event_index(event, "content_index", "contentIndex")
|
|
900
|
+
if replacement:
|
|
901
|
+
candidate = self._item_candidate(
|
|
902
|
+
item,
|
|
903
|
+
output_index=output_index,
|
|
904
|
+
content_index=content_index,
|
|
905
|
+
content=part,
|
|
906
|
+
)
|
|
907
|
+
if not self._replace_output(self._output_with_item(output_index, candidate)):
|
|
908
|
+
if existing is None:
|
|
909
|
+
self._items.pop(output_index, None)
|
|
910
|
+
return False
|
|
911
|
+
elif not self._budget.accept(part):
|
|
912
|
+
return False
|
|
913
|
+
self._content[(output_index, content_index)] = part
|
|
914
|
+
self._sync_item(output_index, item)
|
|
915
|
+
return True
|
|
916
|
+
if event_type == "response.output_text.annotation.added":
|
|
917
|
+
content_index = self._event_index(event, "content_index", "contentIndex")
|
|
918
|
+
content_key = (output_index, content_index)
|
|
919
|
+
part = self._content.get(content_key)
|
|
920
|
+
annotations = _sequence_items(part.get("annotations")) if part is not None else []
|
|
921
|
+
raw_annotation_index = _event_field(event, "annotation_index", "annotationIndex")
|
|
922
|
+
if raw_annotation_index is not None and (
|
|
923
|
+
isinstance(raw_annotation_index, bool)
|
|
924
|
+
or not isinstance(raw_annotation_index, int)
|
|
925
|
+
or raw_annotation_index < 0
|
|
926
|
+
or raw_annotation_index > len(annotations)
|
|
927
|
+
):
|
|
928
|
+
self._budget.truncated = True
|
|
929
|
+
return False
|
|
930
|
+
raw_annotation = _field(event, "annotation")
|
|
931
|
+
if not self._can_accept(self._budget, raw_annotation):
|
|
932
|
+
self._budget.truncated = True
|
|
933
|
+
return False
|
|
934
|
+
annotation = _response_native(raw_annotation)
|
|
935
|
+
if annotation is None or not self._budget.accept(annotation):
|
|
936
|
+
return False
|
|
937
|
+
item = self._item(event, event_type)
|
|
938
|
+
if part is None:
|
|
939
|
+
part = cast(dict[str, Any], {"type": "output_text", "text": "", "annotations": []})
|
|
940
|
+
self._content[content_key] = part
|
|
941
|
+
if isinstance(raw_annotation_index, int):
|
|
942
|
+
if raw_annotation_index == len(annotations):
|
|
943
|
+
annotations.append(annotation)
|
|
944
|
+
else:
|
|
945
|
+
annotations[raw_annotation_index] = annotation
|
|
946
|
+
else:
|
|
947
|
+
annotations.append(annotation)
|
|
948
|
+
part["annotations"] = annotations
|
|
949
|
+
self._sync_item(output_index, item)
|
|
950
|
+
return True
|
|
951
|
+
if event_type in {
|
|
952
|
+
"response.function_call_arguments.delta",
|
|
953
|
+
"response.function_call_arguments.done",
|
|
954
|
+
"response.custom_tool_call_input.delta",
|
|
955
|
+
"response.custom_tool_call_input.done",
|
|
956
|
+
}:
|
|
957
|
+
function_call = event_type.startswith("response.function_call_arguments")
|
|
958
|
+
existing = self._items.get(output_index)
|
|
959
|
+
item = existing
|
|
960
|
+
if item is None:
|
|
961
|
+
item = {
|
|
962
|
+
"id": _string(_event_field(event, "item_id", "itemId"))
|
|
963
|
+
or f"output_{output_index}",
|
|
964
|
+
"type": "function_call" if function_call else "custom_tool_call",
|
|
965
|
+
"status": "in_progress",
|
|
966
|
+
"arguments" if function_call else "input": "",
|
|
967
|
+
}
|
|
968
|
+
self._items[output_index] = item
|
|
969
|
+
field = "arguments" if function_call else "input"
|
|
970
|
+
done = event_type.endswith(".done")
|
|
971
|
+
value = _string(_field(event, field) if done else _field(event, "delta"))
|
|
972
|
+
if value is None:
|
|
973
|
+
return False
|
|
974
|
+
if done:
|
|
975
|
+
candidate = {
|
|
976
|
+
**item,
|
|
977
|
+
field: value,
|
|
978
|
+
**(
|
|
979
|
+
{"name": _field(event, "name")} if _field(event, "name") is not None else {}
|
|
980
|
+
),
|
|
981
|
+
"status": "completed",
|
|
982
|
+
}
|
|
983
|
+
if not self._replace_output(self._output_with_item(output_index, candidate)):
|
|
984
|
+
if existing is None:
|
|
985
|
+
self._items.pop(output_index, None)
|
|
986
|
+
return False
|
|
987
|
+
self._items[output_index] = candidate
|
|
988
|
+
else:
|
|
989
|
+
if not self._budget.accept(value):
|
|
990
|
+
return False
|
|
991
|
+
item[field] = f"{item.get(field, '')}{value}"
|
|
992
|
+
name = _field(event, "name")
|
|
993
|
+
if name is not None:
|
|
994
|
+
item["name"] = name
|
|
995
|
+
return True
|
|
996
|
+
if event_type in {
|
|
997
|
+
"response.reasoning_summary_part.added",
|
|
998
|
+
"response.reasoning_summary_part.done",
|
|
999
|
+
}:
|
|
1000
|
+
event_part = _field(event, "part")
|
|
1001
|
+
replacement = event_type.endswith(".done")
|
|
1002
|
+
preflight = telemetry_dev.CaptureBudget.from_client() if replacement else self._budget
|
|
1003
|
+
if not self._can_accept(preflight, event_part):
|
|
1004
|
+
self._budget.truncated = True
|
|
1005
|
+
return False
|
|
1006
|
+
raw_part = _response_native(event_part)
|
|
1007
|
+
if not isinstance(raw_part, dict):
|
|
1008
|
+
return False
|
|
1009
|
+
part = cast(dict[str, Any], raw_part)
|
|
1010
|
+
existing = self._items.get(output_index)
|
|
1011
|
+
item = self._item(event, event_type)
|
|
1012
|
+
summary_index = self._event_index(event, "summary_index", "summaryIndex")
|
|
1013
|
+
if replacement:
|
|
1014
|
+
candidate = self._item_candidate(
|
|
1015
|
+
item,
|
|
1016
|
+
output_index=output_index,
|
|
1017
|
+
summary_index=summary_index,
|
|
1018
|
+
summary=part,
|
|
1019
|
+
)
|
|
1020
|
+
if not self._replace_output(self._output_with_item(output_index, candidate)):
|
|
1021
|
+
if existing is None:
|
|
1022
|
+
self._items.pop(output_index, None)
|
|
1023
|
+
return False
|
|
1024
|
+
elif not self._budget.accept(part):
|
|
1025
|
+
return False
|
|
1026
|
+
self._summary[(output_index, summary_index)] = part
|
|
1027
|
+
self._sync_item(output_index, item)
|
|
1028
|
+
return True
|
|
1029
|
+
if event_type in {
|
|
1030
|
+
"response.image_generation_call.partial_image",
|
|
1031
|
+
"response.image_generation_call.in_progress",
|
|
1032
|
+
"response.image_generation_call.generating",
|
|
1033
|
+
"response.image_generation_call.completed",
|
|
1034
|
+
"response.apply_patch_call_operation_diff.delta",
|
|
1035
|
+
"response.apply_patch_call_operation_diff.done",
|
|
1036
|
+
"response.fusion_call.in_progress",
|
|
1037
|
+
"response.fusion_call.completed",
|
|
1038
|
+
"response.fusion_call.analysis.in_progress",
|
|
1039
|
+
"response.fusion_call.analysis.completed",
|
|
1040
|
+
"response.fusion_call.panel.added",
|
|
1041
|
+
"response.fusion_call.panel.delta",
|
|
1042
|
+
"response.fusion_call.panel.reasoning.delta",
|
|
1043
|
+
"response.fusion_call.panel.completed",
|
|
1044
|
+
"response.fusion_call.panel.failed",
|
|
1045
|
+
"response.web_search_call.in_progress",
|
|
1046
|
+
"response.web_search_call.searching",
|
|
1047
|
+
"response.web_search_call.completed",
|
|
1048
|
+
"response.debug",
|
|
1049
|
+
}:
|
|
1050
|
+
if event_type == "response.debug":
|
|
1051
|
+
debug = _field(event, "debug")
|
|
1052
|
+
timings = _field(debug, "timings")
|
|
1053
|
+
sequence_number = _event_field(event, "sequence_number", "sequenceNumber")
|
|
1054
|
+
payload = {
|
|
1055
|
+
"type": event_type,
|
|
1056
|
+
**({"sequence_number": sequence_number} if sequence_number is not None else {}),
|
|
1057
|
+
"debug": {} if timings is None else {"timings": _response_native(timings)},
|
|
1058
|
+
}
|
|
1059
|
+
else:
|
|
1060
|
+
payload = _response_native(event)
|
|
1061
|
+
synthetic = {
|
|
1062
|
+
"type": "telemetry.dev.response_stream_event",
|
|
1063
|
+
"event_type": event_type,
|
|
1064
|
+
"payload": payload,
|
|
1065
|
+
}
|
|
1066
|
+
if not self._budget.accept(synthetic):
|
|
1067
|
+
self._budget.truncated = True
|
|
1068
|
+
self._provider_events_truncated = True
|
|
1069
|
+
return False
|
|
1070
|
+
self._synthetic_events.append(synthetic)
|
|
1071
|
+
return True
|
|
1072
|
+
if event_type not in {
|
|
1073
|
+
"response.output_text.delta",
|
|
1074
|
+
"response.output_text.done",
|
|
1075
|
+
"response.reasoning_text.delta",
|
|
1076
|
+
"response.reasoning_text.done",
|
|
1077
|
+
"response.reasoning_summary_text.delta",
|
|
1078
|
+
"response.reasoning_summary_text.done",
|
|
1079
|
+
"response.refusal.delta",
|
|
1080
|
+
"response.refusal.done",
|
|
1081
|
+
}:
|
|
1082
|
+
return False
|
|
1083
|
+
done = event_type.endswith(".done")
|
|
1084
|
+
refusal = event_type.startswith("response.refusal.")
|
|
1085
|
+
value = _string(
|
|
1086
|
+
_field(event, "refusal" if refusal else "text") if done else _field(event, "delta")
|
|
1087
|
+
)
|
|
1088
|
+
if value is None or (not done and not self._budget.accept(value)):
|
|
1089
|
+
return False
|
|
1090
|
+
summary_text = event_type.startswith("response.reasoning_summary_text.")
|
|
1091
|
+
existing = self._items.get(output_index)
|
|
1092
|
+
item = self._item(event, event_type)
|
|
1093
|
+
if summary_text:
|
|
1094
|
+
summary_index = self._event_index(event, "summary_index", "summaryIndex")
|
|
1095
|
+
summary_key = (output_index, summary_index)
|
|
1096
|
+
summary = self._summary.get(summary_key)
|
|
1097
|
+
if summary is None:
|
|
1098
|
+
summary = {"type": "summary_text", "text": ""}
|
|
1099
|
+
if not done:
|
|
1100
|
+
self._summary[summary_key] = summary
|
|
1101
|
+
self._sync_item(output_index, item)
|
|
1102
|
+
if done:
|
|
1103
|
+
part = {**summary, "text": value}
|
|
1104
|
+
candidate = self._item_candidate(
|
|
1105
|
+
item,
|
|
1106
|
+
output_index=output_index,
|
|
1107
|
+
summary_index=summary_index,
|
|
1108
|
+
summary=part,
|
|
1109
|
+
)
|
|
1110
|
+
if not self._replace_output(self._output_with_item(output_index, candidate)):
|
|
1111
|
+
if existing is None:
|
|
1112
|
+
self._items.pop(output_index, None)
|
|
1113
|
+
return False
|
|
1114
|
+
self._summary[summary_key] = part
|
|
1115
|
+
self._sync_item(output_index, item)
|
|
1116
|
+
else:
|
|
1117
|
+
summary["text"] = f"{summary.get('text', '')}{value}"
|
|
1118
|
+
return True
|
|
1119
|
+
content_index = self._event_index(event, "content_index", "contentIndex")
|
|
1120
|
+
content_key = (output_index, content_index)
|
|
1121
|
+
part = self._content.get(content_key)
|
|
1122
|
+
if part is None:
|
|
1123
|
+
if refusal:
|
|
1124
|
+
new_part: dict[str, Any] = {"type": "refusal", "refusal": ""}
|
|
1125
|
+
else:
|
|
1126
|
+
new_part = {
|
|
1127
|
+
"type": "reasoning_text"
|
|
1128
|
+
if event_type.startswith("response.reasoning_text.")
|
|
1129
|
+
else "output_text",
|
|
1130
|
+
"text": "",
|
|
1131
|
+
**(
|
|
1132
|
+
{"annotations": []}
|
|
1133
|
+
if event_type.startswith("response.output_text.")
|
|
1134
|
+
else {}
|
|
1135
|
+
),
|
|
1136
|
+
}
|
|
1137
|
+
part = new_part
|
|
1138
|
+
if not done:
|
|
1139
|
+
self._content[content_key] = part
|
|
1140
|
+
self._sync_item(output_index, item)
|
|
1141
|
+
if done:
|
|
1142
|
+
replacement = {**part, "refusal" if refusal else "text": value}
|
|
1143
|
+
candidate = self._item_candidate(
|
|
1144
|
+
item,
|
|
1145
|
+
output_index=output_index,
|
|
1146
|
+
content_index=content_index,
|
|
1147
|
+
content=replacement,
|
|
1148
|
+
)
|
|
1149
|
+
if not self._replace_output(self._output_with_item(output_index, candidate)):
|
|
1150
|
+
if existing is None:
|
|
1151
|
+
self._items.pop(output_index, None)
|
|
1152
|
+
return False
|
|
1153
|
+
self._content[content_key] = replacement
|
|
1154
|
+
self._sync_item(output_index, item)
|
|
1155
|
+
elif refusal:
|
|
1156
|
+
part["refusal"] = f"{part.get('refusal', '')}{value}"
|
|
1157
|
+
else:
|
|
1158
|
+
part["text"] = f"{part.get('text', '')}{value}"
|
|
1159
|
+
return True
|
|
1160
|
+
|
|
1161
|
+
def record(self, event: Any) -> None:
|
|
1162
|
+
event = _response_event(event)
|
|
1163
|
+
response = _field(event, "response")
|
|
1164
|
+
if response is not None:
|
|
1165
|
+
fields = _responses_response(response, include_error=False, include_output=False)
|
|
1166
|
+
response_budget = telemetry_dev.CaptureBudget.from_client()
|
|
1167
|
+
raw_output = _field(response, "output")
|
|
1168
|
+
if raw_output is not None:
|
|
1169
|
+
if not self._can_accept(response_budget, raw_output):
|
|
1170
|
+
self._budget.truncated = True
|
|
1171
|
+
else:
|
|
1172
|
+
output = _response_native(raw_output)
|
|
1173
|
+
response_items = cast(list[Any], output) if isinstance(output, list) else None
|
|
1174
|
+
if response_items is None:
|
|
1175
|
+
self._budget.truncated = True
|
|
1176
|
+
else:
|
|
1177
|
+
response_artifact = [*response_items, *self._synthetic_events]
|
|
1178
|
+
if response_budget.accept(response_artifact):
|
|
1179
|
+
self._items.clear()
|
|
1180
|
+
self._content.clear()
|
|
1181
|
+
self._summary.clear()
|
|
1182
|
+
for index, item in enumerate(response_items):
|
|
1183
|
+
if isinstance(item, dict):
|
|
1184
|
+
self._hydrate_item(index, cast(dict[str, Any], item))
|
|
1185
|
+
self.retained_output = response_artifact
|
|
1186
|
+
response_budget.truncated = self._provider_events_truncated
|
|
1187
|
+
self._budget = response_budget
|
|
1188
|
+
else:
|
|
1189
|
+
self._budget.truncated = True
|
|
1190
|
+
self.partial = fields
|
|
1191
|
+
if self.retained_output is not None:
|
|
1192
|
+
self.partial["output"] = self.retained_output
|
|
1193
|
+
elif self._record_event(event):
|
|
1194
|
+
self._sync_output()
|
|
1195
|
+
_mark_capture_truncated(self.partial, self._budget)
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
class _InstrumentedResponsesStream:
|
|
1199
|
+
def __init__(self, inner: Any, handle: telemetry_dev.SpanHandle, started_at: float) -> None:
|
|
1200
|
+
self._inner = inner
|
|
1201
|
+
self._handle = handle
|
|
1202
|
+
self._end = _end_once(handle)
|
|
1203
|
+
self._started_at = started_at
|
|
1204
|
+
self._saw_first = False
|
|
1205
|
+
self._state = _ResponsesStreamState()
|
|
1206
|
+
self._consume: Iterator[Any] | None = None
|
|
1207
|
+
self._in_next = False
|
|
1208
|
+
_hook_response_close(inner, self._on_response_close)
|
|
1209
|
+
|
|
1210
|
+
def _iterate(self) -> Iterator[Any]:
|
|
1211
|
+
try:
|
|
1212
|
+
while True:
|
|
1213
|
+
self._in_next = True
|
|
1214
|
+
try:
|
|
1215
|
+
event = next(self._inner)
|
|
1216
|
+
except StopIteration:
|
|
1217
|
+
break
|
|
1218
|
+
except BaseException as exc:
|
|
1219
|
+
self._end(**self._state.partial, error=exc)
|
|
1220
|
+
raise
|
|
1221
|
+
finally:
|
|
1222
|
+
self._in_next = False
|
|
1223
|
+
self._record(event)
|
|
1224
|
+
yield event
|
|
1225
|
+
finally:
|
|
1226
|
+
self.close()
|
|
1227
|
+
|
|
1228
|
+
def __iter__(self) -> Iterator[Any]:
|
|
1229
|
+
if self._consume is None:
|
|
1230
|
+
self._consume = self._iterate()
|
|
1231
|
+
return self._consume
|
|
1232
|
+
|
|
1233
|
+
def __next__(self) -> Any:
|
|
1234
|
+
return next(self.__iter__())
|
|
1235
|
+
|
|
1236
|
+
def __enter__(self) -> _InstrumentedResponsesStream:
|
|
1237
|
+
enter = getattr(self._inner, "__enter__", None)
|
|
1238
|
+
if enter is not None:
|
|
1239
|
+
enter()
|
|
1240
|
+
return self
|
|
1241
|
+
|
|
1242
|
+
def __exit__(
|
|
1243
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any
|
|
1244
|
+
) -> None:
|
|
1245
|
+
if exc is not None:
|
|
1246
|
+
self._end(**self._state.partial, error=exc)
|
|
1247
|
+
self.close()
|
|
1248
|
+
|
|
1249
|
+
def _finish(self) -> None:
|
|
1250
|
+
self._end(**self._state.partial)
|
|
1251
|
+
|
|
1252
|
+
def _on_response_close(self) -> None:
|
|
1253
|
+
if not self._in_next:
|
|
1254
|
+
self._finish()
|
|
1255
|
+
|
|
1256
|
+
def close(self) -> None:
|
|
1257
|
+
self._finish()
|
|
1258
|
+
close = getattr(self._inner, "close", None)
|
|
1259
|
+
if close is not None:
|
|
1260
|
+
close()
|
|
1261
|
+
|
|
1262
|
+
def __getattr__(self, name: str) -> Any:
|
|
1263
|
+
return getattr(self._inner, name)
|
|
1264
|
+
|
|
1265
|
+
def _record(self, event: Any) -> None:
|
|
1266
|
+
if not self._saw_first:
|
|
1267
|
+
self._saw_first = True
|
|
1268
|
+
self._handle.update(
|
|
1269
|
+
time_to_first_chunk_ms=(time.perf_counter() - self._started_at) * 1000
|
|
1270
|
+
)
|
|
1271
|
+
self._state.record(event)
|
|
1272
|
+
event = _response_event(event)
|
|
1273
|
+
event_type = _field(event, "type")
|
|
1274
|
+
if event_type == "response.completed":
|
|
1275
|
+
self._end(**self._state.partial)
|
|
1276
|
+
elif event_type == "response.failed":
|
|
1277
|
+
self._end(
|
|
1278
|
+
**self._state.partial, error=_response_failed_error(_field(event, "response"))
|
|
1279
|
+
)
|
|
1280
|
+
elif event_type == "response.incomplete":
|
|
1281
|
+
self._end(**self._state.partial)
|
|
1282
|
+
elif event_type == "error":
|
|
1283
|
+
self._end(**self._state.partial, error=_response_stream_error(event))
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
class _InstrumentedAsyncResponsesStream:
|
|
1287
|
+
def __init__(self, inner: Any, handle: telemetry_dev.SpanHandle, started_at: float) -> None:
|
|
1288
|
+
self._inner = inner
|
|
1289
|
+
self._handle = handle
|
|
1290
|
+
self._end = _end_once(handle)
|
|
1291
|
+
self._started_at = started_at
|
|
1292
|
+
self._saw_first = False
|
|
1293
|
+
self._state = _ResponsesStreamState()
|
|
1294
|
+
self._consume: AsyncIterator[Any] | None = None
|
|
1295
|
+
self._in_next = False
|
|
1296
|
+
_hook_response_close(inner, self._on_response_close)
|
|
1297
|
+
|
|
1298
|
+
async def _aiterate(self) -> AsyncIterator[Any]:
|
|
1299
|
+
try:
|
|
1300
|
+
while True:
|
|
1301
|
+
self._in_next = True
|
|
1302
|
+
try:
|
|
1303
|
+
event = await self._inner.__anext__()
|
|
1304
|
+
except StopAsyncIteration:
|
|
1305
|
+
break
|
|
1306
|
+
except BaseException as exc:
|
|
1307
|
+
self._end(**self._state.partial, error=exc)
|
|
1308
|
+
raise
|
|
1309
|
+
finally:
|
|
1310
|
+
self._in_next = False
|
|
1311
|
+
self._record(event)
|
|
1312
|
+
yield event
|
|
1313
|
+
finally:
|
|
1314
|
+
await self.close()
|
|
1315
|
+
|
|
1316
|
+
def __aiter__(self) -> AsyncIterator[Any]:
|
|
1317
|
+
if self._consume is None:
|
|
1318
|
+
self._consume = self._aiterate()
|
|
1319
|
+
return self._consume
|
|
1320
|
+
|
|
1321
|
+
async def __anext__(self) -> Any:
|
|
1322
|
+
return await self.__aiter__().__anext__()
|
|
1323
|
+
|
|
1324
|
+
async def __aenter__(self) -> _InstrumentedAsyncResponsesStream:
|
|
1325
|
+
enter = getattr(self._inner, "__aenter__", None)
|
|
1326
|
+
if enter is not None:
|
|
1327
|
+
await enter()
|
|
1328
|
+
return self
|
|
1329
|
+
|
|
1330
|
+
async def __aexit__(
|
|
1331
|
+
self,
|
|
1332
|
+
exc_type: type[BaseException] | None,
|
|
1333
|
+
exc: BaseException | None,
|
|
1334
|
+
tb: Any,
|
|
1335
|
+
) -> None:
|
|
1336
|
+
if exc is not None:
|
|
1337
|
+
self._end(**self._state.partial, error=exc)
|
|
1338
|
+
await self.close()
|
|
1339
|
+
|
|
1340
|
+
def _finish(self) -> None:
|
|
1341
|
+
self._end(**self._state.partial)
|
|
1342
|
+
|
|
1343
|
+
def _on_response_close(self) -> None:
|
|
1344
|
+
if not self._in_next:
|
|
1345
|
+
self._finish()
|
|
1346
|
+
|
|
1347
|
+
async def close(self) -> None:
|
|
1348
|
+
try:
|
|
1349
|
+
await _close_async_stream(self._inner)
|
|
1350
|
+
finally:
|
|
1351
|
+
self._finish()
|
|
1352
|
+
|
|
1353
|
+
def __getattr__(self, name: str) -> Any:
|
|
1354
|
+
return getattr(self._inner, name)
|
|
1355
|
+
|
|
1356
|
+
def _record(self, event: Any) -> None:
|
|
1357
|
+
if not self._saw_first:
|
|
1358
|
+
self._saw_first = True
|
|
1359
|
+
self._handle.update(
|
|
1360
|
+
time_to_first_chunk_ms=(time.perf_counter() - self._started_at) * 1000
|
|
1361
|
+
)
|
|
1362
|
+
self._state.record(event)
|
|
1363
|
+
event = _response_event(event)
|
|
1364
|
+
event_type = _field(event, "type")
|
|
1365
|
+
if event_type == "response.completed":
|
|
1366
|
+
self._end(**self._state.partial)
|
|
1367
|
+
elif event_type == "response.failed":
|
|
1368
|
+
self._end(
|
|
1369
|
+
**self._state.partial, error=_response_failed_error(_field(event, "response"))
|
|
1370
|
+
)
|
|
1371
|
+
elif event_type == "response.incomplete":
|
|
1372
|
+
self._end(**self._state.partial)
|
|
1373
|
+
elif event_type == "error":
|
|
1374
|
+
self._end(**self._state.partial, error=_response_stream_error(event))
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def _start_span(
|
|
1378
|
+
params: Mapping[str, Any], mapper: RequestMapper
|
|
1379
|
+
) -> tuple[telemetry_dev.SpanHandle, Callable[..., None], float]:
|
|
1380
|
+
name, fields = mapper(params)
|
|
1381
|
+
handle = telemetry_dev.start_span(name, provider=_PROVIDER, **_clean_fields(fields))
|
|
1382
|
+
return handle, _end_once(handle), time.perf_counter()
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def _wrap_sync(
|
|
1386
|
+
original: Callable[..., Any],
|
|
1387
|
+
operation: str,
|
|
1388
|
+
request_mapper: RequestMapper,
|
|
1389
|
+
response_mapper: ResponseMapper,
|
|
1390
|
+
) -> Callable[..., Any]:
|
|
1391
|
+
@wraps(original)
|
|
1392
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
1393
|
+
streaming = kwargs.get("stream") is True
|
|
1394
|
+
handle, end, started_at = _start_span(kwargs, request_mapper)
|
|
1395
|
+
try:
|
|
1396
|
+
result = original(*args, **kwargs)
|
|
1397
|
+
except BaseException as exc:
|
|
1398
|
+
end(error=exc)
|
|
1399
|
+
raise
|
|
1400
|
+
if streaming and operation == "chat":
|
|
1401
|
+
return _InstrumentedStream(result, handle, started_at)
|
|
1402
|
+
if streaming and operation == "responses":
|
|
1403
|
+
return _InstrumentedResponsesStream(result, handle, started_at)
|
|
1404
|
+
end(**response_mapper(result))
|
|
1405
|
+
return result
|
|
1406
|
+
|
|
1407
|
+
setattr(wrapper, _WRAPPED_ATTR, True)
|
|
1408
|
+
setattr(wrapper, _ORIGINAL_ATTR, original)
|
|
1409
|
+
return wrapper
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def _wrap_async(
|
|
1413
|
+
original: Callable[..., Any],
|
|
1414
|
+
operation: str,
|
|
1415
|
+
request_mapper: RequestMapper,
|
|
1416
|
+
response_mapper: ResponseMapper,
|
|
1417
|
+
) -> Callable[..., Any]:
|
|
1418
|
+
@wraps(original)
|
|
1419
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
1420
|
+
streaming = kwargs.get("stream") is True
|
|
1421
|
+
handle, end, started_at = _start_span(kwargs, request_mapper)
|
|
1422
|
+
try:
|
|
1423
|
+
result = await original(*args, **kwargs)
|
|
1424
|
+
except BaseException as exc:
|
|
1425
|
+
end(error=exc)
|
|
1426
|
+
raise
|
|
1427
|
+
if streaming and operation == "chat":
|
|
1428
|
+
return _InstrumentedAsyncStream(result, handle, started_at)
|
|
1429
|
+
if streaming and operation == "responses":
|
|
1430
|
+
return _InstrumentedAsyncResponsesStream(result, handle, started_at)
|
|
1431
|
+
end(**response_mapper(result))
|
|
1432
|
+
return result
|
|
1433
|
+
|
|
1434
|
+
setattr(wrapper, _WRAPPED_ATTR, True)
|
|
1435
|
+
setattr(wrapper, _ORIGINAL_ATTR, original)
|
|
1436
|
+
return wrapper
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _patch_instance(
|
|
1440
|
+
resource: object,
|
|
1441
|
+
method: str,
|
|
1442
|
+
wrapper_factory: Callable[
|
|
1443
|
+
[Callable[..., Any], str, RequestMapper, ResponseMapper], Callable[..., Any]
|
|
1444
|
+
],
|
|
1445
|
+
operation: str,
|
|
1446
|
+
request_mapper: RequestMapper,
|
|
1447
|
+
response_mapper: ResponseMapper,
|
|
1448
|
+
) -> None:
|
|
1449
|
+
current = getattr(resource, method)
|
|
1450
|
+
if getattr(current, _WRAPPED_ATTR, False):
|
|
1451
|
+
if method in vars(resource):
|
|
1452
|
+
return
|
|
1453
|
+
original = getattr(current, _ORIGINAL_ATTR, None)
|
|
1454
|
+
if original is None:
|
|
1455
|
+
return
|
|
1456
|
+
current = original.__get__(resource, type(resource))
|
|
1457
|
+
setattr(resource, method, wrapper_factory(current, operation, request_mapper, response_mapper))
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def _patch_class(
|
|
1461
|
+
cls: type[Any],
|
|
1462
|
+
method: str,
|
|
1463
|
+
wrapper_factory: Callable[
|
|
1464
|
+
[Callable[..., Any], str, RequestMapper, ResponseMapper], Callable[..., Any]
|
|
1465
|
+
],
|
|
1466
|
+
operation: str,
|
|
1467
|
+
request_mapper: RequestMapper,
|
|
1468
|
+
response_mapper: ResponseMapper,
|
|
1469
|
+
) -> None:
|
|
1470
|
+
original = getattr(cls, method)
|
|
1471
|
+
if getattr(original, _WRAPPED_ATTR, False):
|
|
1472
|
+
return
|
|
1473
|
+
_ORIGINALS.append((cls, method, original))
|
|
1474
|
+
setattr(cls, method, wrapper_factory(original, operation, request_mapper, response_mapper))
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def wrap_open_router(client: _T) -> _T:
|
|
1478
|
+
if getattr(client, _WRAPPED_ATTR, False):
|
|
1479
|
+
return client
|
|
1480
|
+
_patch_instance(
|
|
1481
|
+
cast(Any, client).chat,
|
|
1482
|
+
"send",
|
|
1483
|
+
_wrap_sync,
|
|
1484
|
+
"chat",
|
|
1485
|
+
_chat_request,
|
|
1486
|
+
_chat_response,
|
|
1487
|
+
)
|
|
1488
|
+
_patch_instance(
|
|
1489
|
+
cast(Any, client).chat,
|
|
1490
|
+
"send_async",
|
|
1491
|
+
_wrap_async,
|
|
1492
|
+
"chat",
|
|
1493
|
+
_chat_request,
|
|
1494
|
+
_chat_response,
|
|
1495
|
+
)
|
|
1496
|
+
_patch_instance(
|
|
1497
|
+
cast(Any, client).responses,
|
|
1498
|
+
"send",
|
|
1499
|
+
_wrap_sync,
|
|
1500
|
+
"responses",
|
|
1501
|
+
_responses_request,
|
|
1502
|
+
_responses_response,
|
|
1503
|
+
)
|
|
1504
|
+
_patch_instance(
|
|
1505
|
+
cast(Any, client).responses,
|
|
1506
|
+
"send_async",
|
|
1507
|
+
_wrap_async,
|
|
1508
|
+
"responses",
|
|
1509
|
+
_responses_request,
|
|
1510
|
+
_responses_response,
|
|
1511
|
+
)
|
|
1512
|
+
_patch_instance(
|
|
1513
|
+
cast(Any, client).embeddings,
|
|
1514
|
+
"generate",
|
|
1515
|
+
_wrap_sync,
|
|
1516
|
+
"embeddings",
|
|
1517
|
+
_embeddings_request,
|
|
1518
|
+
_embeddings_response,
|
|
1519
|
+
)
|
|
1520
|
+
_patch_instance(
|
|
1521
|
+
cast(Any, client).embeddings,
|
|
1522
|
+
"generate_async",
|
|
1523
|
+
_wrap_async,
|
|
1524
|
+
"embeddings",
|
|
1525
|
+
_embeddings_request,
|
|
1526
|
+
_embeddings_response,
|
|
1527
|
+
)
|
|
1528
|
+
setattr(client, _WRAPPED_ATTR, True)
|
|
1529
|
+
return client
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def instrument_openrouter() -> None:
|
|
1533
|
+
global _installed
|
|
1534
|
+
with _install_lock:
|
|
1535
|
+
if _installed:
|
|
1536
|
+
return
|
|
1537
|
+
_patch_class(Chat, "send", _wrap_sync, "chat", _chat_request, _chat_response)
|
|
1538
|
+
_patch_class(Chat, "send_async", _wrap_async, "chat", _chat_request, _chat_response)
|
|
1539
|
+
_patch_class(
|
|
1540
|
+
Responses, "send", _wrap_sync, "responses", _responses_request, _responses_response
|
|
1541
|
+
)
|
|
1542
|
+
_patch_class(
|
|
1543
|
+
Responses,
|
|
1544
|
+
"send_async",
|
|
1545
|
+
_wrap_async,
|
|
1546
|
+
"responses",
|
|
1547
|
+
_responses_request,
|
|
1548
|
+
_responses_response,
|
|
1549
|
+
)
|
|
1550
|
+
_patch_class(
|
|
1551
|
+
Embeddings,
|
|
1552
|
+
"generate",
|
|
1553
|
+
_wrap_sync,
|
|
1554
|
+
"embeddings",
|
|
1555
|
+
_embeddings_request,
|
|
1556
|
+
_embeddings_response,
|
|
1557
|
+
)
|
|
1558
|
+
_patch_class(
|
|
1559
|
+
Embeddings,
|
|
1560
|
+
"generate_async",
|
|
1561
|
+
_wrap_async,
|
|
1562
|
+
"embeddings",
|
|
1563
|
+
_embeddings_request,
|
|
1564
|
+
_embeddings_response,
|
|
1565
|
+
)
|
|
1566
|
+
_installed = True
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def uninstrument_openrouter() -> None:
|
|
1570
|
+
global _installed
|
|
1571
|
+
with _install_lock:
|
|
1572
|
+
while _ORIGINALS:
|
|
1573
|
+
cls, method, original = _ORIGINALS.pop()
|
|
1574
|
+
setattr(cls, method, original)
|
|
1575
|
+
_installed = False
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
__all__ = [
|
|
1579
|
+
"__version__",
|
|
1580
|
+
"instrument_openrouter",
|
|
1581
|
+
"uninstrument_openrouter",
|
|
1582
|
+
"wrap_open_router",
|
|
1583
|
+
]
|