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
+ ]