telemetry-dev-litellm 0.2.2__tar.gz → 0.2.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telemetry-dev-litellm
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: LiteLLM integration for telemetry.dev Python SDK
5
5
  Keywords: telemetry,opentelemetry,llm,genai,litellm,tracing
6
6
  Author: telemetry.dev
@@ -146,7 +146,7 @@ Rerank spans emit `gen_ai.operation.name="rerank"`:
146
146
 
147
147
  Responses spans emit `gen_ai.operation.name="chat"` and capture request input, instructions, sampling options, metadata, response output/status/id, usage, cost, and provider. Streaming Responses events are handled separately from chat chunks; terminal failure and error events mark the span as failed, and early close records the partial output collected so far.
148
148
 
149
- Streamed chat chunks and Responses reconstruction are retained up to 48 KiB and 1,000 items. Non-streamed Responses input and output and rerank input and results are retained up to 64 KiB and 1,024 items. The configured `max_attribute_length` does not shrink these retention bounds; the core applies it to the exported attribute after the mask runs. A chat stream that is cut at its bound, closed early, or fails, and a Responses stream that is cut at its bound, ends without a terminal response, or ends with an `error` event set `telemetry.dev.capture.truncated`. With a mask configured, the incomplete reconstructed output is omitted because the mask cannot inspect the complete value.
149
+ Streamed chat chunks and Responses reconstruction are kept up to 16 MiB, and all streams in flight share the `max_stream_bytes` bound of the core (64 MiB by default). These bounds need a `telemetry-dev` core with `max_stream_bytes`; with an older core, each stream keeps up to 64 KiB and 1,024 items. Non-streamed Responses input and output and rerank input and results are retained up to 16 MiB each. The configured `max_attribute_length` does not shrink these retention bounds; the core applies it to the exported attribute after the mask runs. A chat stream that is cut at its bound, closed early, or fails, or whose response fails to rebuild or map when it ends or is closed (reported to `on_error`; the span then has no output), and a Responses stream that is cut at its bound, ends without a terminal response, or ends with an `error` event set `telemetry.dev.capture.truncated`. A chat chunk or Responses event whose output fails to record also sets the attribute, and no later chunk or event is kept: a chat stream keeps the chunks before it, and a Responses stream reads only its terminal event after it. A complete output in that terminal response still replaces what was recorded, and then the attribute is not set. An empty terminal output does not replace output already recorded from the deltas, or lost with an event. A failure in a chat chunk's ID, model, usage, or finish reasons, or in the time to first chunk and IDs of a stream's first event, loses only that metadata and does not set the attribute. A Responses stream whose terminal output fails to map keeps its response fields (status, error, usage) and sets the attribute, and one whose output fails to rebuild from its events when it ends or is closed also sets the attribute and has no output. With a mask configured, the incomplete reconstructed output is omitted because the mask cannot inspect the complete value. A Responses stream whose terminal response fields (ID, model, usage, cost, status, error) fail to map also sets the attribute; its output is mapped apart, so it is kept, also with a mask configured, and a failed response is still recorded as failed. Each of these stream failures is reported to `on_error`, once per stream. When the response of a successful non-streamed completion, embedding, Responses, or rerank call fails to map, the failure is reported to `on_error` and the span ends without output and with `telemetry.dev.capture.truncated`; it is marked failed only when it is a failed Responses response.
150
150
 
151
151
  Aggregate usage keys are `input_tokens`, `output_tokens`, `total_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, and `reasoning_output_tokens`. The core contract also includes modality fields; Responses usage emits text, image, and audio input/output token details when LiteLLM exposes them.
152
152
 
@@ -154,7 +154,7 @@ Aggregate usage keys are `input_tokens`, `output_tokens`, `total_tokens`, `cache
154
154
 
155
155
  On a LiteLLM or Router call made outside the LiteLLM proxy, each key of the call's `litellm_metadata` argument, or of `metadata` when `litellm_metadata` is absent or empty, is exported as `td.metadata.<key>`, except the keys listed below. That includes keys LiteLLM's Router adds to the argument, such as `model_group`, `deployment`, `model_info` and `api_base`. Rerank spans also add `result_count` and `search_units`.
156
156
 
157
- Strings, numbers, booleans and non-empty sequences of only strings, only booleans or only numbers are exported as they are; other values, including empty sequences, are JSON-encoded. Each string, including each string in a sequence, and each JSON value is truncated to `max_attribute_length`. Metadata values are not passed to the `mask` callback, and metadata is exported whatever `capture_input` and `capture_output` are set to.
157
+ Strings, numbers, booleans and non-empty sequences of only strings, only booleans or only numbers are exported as they are; other values, including empty sequences, are JSON-encoded. When `max_attribute_length` is set, each string, including each string in a sequence, and each JSON value is truncated to it; by default they are exported whole, or cut at 65,536 characters with `telemetry-dev` 0.3.2 or earlier. Metadata values are not passed to the `mask` callback, and metadata is exported whatever `capture_input` and `capture_output` are set to.
158
158
 
159
159
  These top-level keys are always dropped:
160
160
 
@@ -122,7 +122,7 @@ Rerank spans emit `gen_ai.operation.name="rerank"`:
122
122
 
123
123
  Responses spans emit `gen_ai.operation.name="chat"` and capture request input, instructions, sampling options, metadata, response output/status/id, usage, cost, and provider. Streaming Responses events are handled separately from chat chunks; terminal failure and error events mark the span as failed, and early close records the partial output collected so far.
124
124
 
125
- Streamed chat chunks and Responses reconstruction are retained up to 48 KiB and 1,000 items. Non-streamed Responses input and output and rerank input and results are retained up to 64 KiB and 1,024 items. The configured `max_attribute_length` does not shrink these retention bounds; the core applies it to the exported attribute after the mask runs. A chat stream that is cut at its bound, closed early, or fails, and a Responses stream that is cut at its bound, ends without a terminal response, or ends with an `error` event set `telemetry.dev.capture.truncated`. With a mask configured, the incomplete reconstructed output is omitted because the mask cannot inspect the complete value.
125
+ Streamed chat chunks and Responses reconstruction are kept up to 16 MiB, and all streams in flight share the `max_stream_bytes` bound of the core (64 MiB by default). These bounds need a `telemetry-dev` core with `max_stream_bytes`; with an older core, each stream keeps up to 64 KiB and 1,024 items. Non-streamed Responses input and output and rerank input and results are retained up to 16 MiB each. The configured `max_attribute_length` does not shrink these retention bounds; the core applies it to the exported attribute after the mask runs. A chat stream that is cut at its bound, closed early, or fails, or whose response fails to rebuild or map when it ends or is closed (reported to `on_error`; the span then has no output), and a Responses stream that is cut at its bound, ends without a terminal response, or ends with an `error` event set `telemetry.dev.capture.truncated`. A chat chunk or Responses event whose output fails to record also sets the attribute, and no later chunk or event is kept: a chat stream keeps the chunks before it, and a Responses stream reads only its terminal event after it. A complete output in that terminal response still replaces what was recorded, and then the attribute is not set. An empty terminal output does not replace output already recorded from the deltas, or lost with an event. A failure in a chat chunk's ID, model, usage, or finish reasons, or in the time to first chunk and IDs of a stream's first event, loses only that metadata and does not set the attribute. A Responses stream whose terminal output fails to map keeps its response fields (status, error, usage) and sets the attribute, and one whose output fails to rebuild from its events when it ends or is closed also sets the attribute and has no output. With a mask configured, the incomplete reconstructed output is omitted because the mask cannot inspect the complete value. A Responses stream whose terminal response fields (ID, model, usage, cost, status, error) fail to map also sets the attribute; its output is mapped apart, so it is kept, also with a mask configured, and a failed response is still recorded as failed. Each of these stream failures is reported to `on_error`, once per stream. When the response of a successful non-streamed completion, embedding, Responses, or rerank call fails to map, the failure is reported to `on_error` and the span ends without output and with `telemetry.dev.capture.truncated`; it is marked failed only when it is a failed Responses response.
126
126
 
127
127
  Aggregate usage keys are `input_tokens`, `output_tokens`, `total_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, and `reasoning_output_tokens`. The core contract also includes modality fields; Responses usage emits text, image, and audio input/output token details when LiteLLM exposes them.
128
128
 
@@ -130,7 +130,7 @@ Aggregate usage keys are `input_tokens`, `output_tokens`, `total_tokens`, `cache
130
130
 
131
131
  On a LiteLLM or Router call made outside the LiteLLM proxy, each key of the call's `litellm_metadata` argument, or of `metadata` when `litellm_metadata` is absent or empty, is exported as `td.metadata.<key>`, except the keys listed below. That includes keys LiteLLM's Router adds to the argument, such as `model_group`, `deployment`, `model_info` and `api_base`. Rerank spans also add `result_count` and `search_units`.
132
132
 
133
- Strings, numbers, booleans and non-empty sequences of only strings, only booleans or only numbers are exported as they are; other values, including empty sequences, are JSON-encoded. Each string, including each string in a sequence, and each JSON value is truncated to `max_attribute_length`. Metadata values are not passed to the `mask` callback, and metadata is exported whatever `capture_input` and `capture_output` are set to.
133
+ Strings, numbers, booleans and non-empty sequences of only strings, only booleans or only numbers are exported as they are; other values, including empty sequences, are JSON-encoded. When `max_attribute_length` is set, each string, including each string in a sequence, and each JSON value is truncated to it; by default they are exported whole, or cut at 65,536 characters with `telemetry-dev` 0.3.2 or earlier. Metadata values are not passed to the `mask` callback, and metadata is exported whatever `capture_input` and `capture_output` are set to.
134
134
 
135
135
  These top-level keys are always dropped:
136
136
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev-litellm"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "LiteLLM integration for telemetry.dev Python SDK"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev-litellm"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "LiteLLM integration for telemetry.dev Python SDK"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -3,19 +3,21 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import inspect
5
5
  import math
6
+ import os
6
7
  import re
7
8
  import threading
8
9
  import time
9
10
  from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping, Sequence
10
11
  from functools import wraps
11
12
  from inspect import Parameter
13
+ from io import StringIO
12
14
  from typing import Any, TypeVar, cast
13
15
 
14
16
  import litellm
15
17
  import telemetry_dev
16
18
  from opentelemetry import trace
17
19
 
18
- __version__ = "0.2.2"
20
+ __version__ = "0.2.3"
19
21
 
20
22
  RequestMapper = Callable[[tuple[Any, ...], Mapping[str, Any]], tuple[str, dict[str, Any]]]
21
23
  ResponseMapper = Callable[[Any], dict[str, Any]]
@@ -29,9 +31,25 @@ _DROPIN_WRAPPERS: dict[str, tuple[Any, Callable[..., Any]]] = {}
29
31
  _PENDING_CLOSE_TASKS: set[asyncio.Future[Any]] = set()
30
32
  _installed = False
31
33
  _install_lock = threading.Lock()
34
+
35
+
36
+ def _reset_install_lock_in_child() -> None:
37
+ """A thread of the parent can hold the lock at a fork, and only this thread exists here."""
38
+ global _install_lock
39
+ _install_lock = threading.Lock()
40
+
41
+
42
+ # A hook is enough: a fork that runs no hooks, as uWSGI's fork of its workers by default,
43
+ # starts from its master, which serves no requests. os.register_at_fork cannot remove a hook,
44
+ # and importlib.reload runs this module again with the same globals, so the hook of the first
45
+ # run stays. Windows has no fork.
46
+ if hasattr(os, "register_at_fork") and "_install_fork_hook" not in globals():
47
+ _install_fork_hook = True
48
+ os.register_at_fork(after_in_child=_reset_install_lock_in_child)
49
+
32
50
  _OMIT = object()
33
- # Retention uses fixed bounds; the core applies max_attribute_length after the mask.
34
- _STREAM_CAPTURE_MAX_BYTES = 48 * 1024
51
+ # The core applies max_attribute_length after the mask. More choices, or more responses items
52
+ # and rejected parts tracked for truncation, than this are not kept.
35
53
  _STREAM_CAPTURE_MAX_ITEMS = 1000
36
54
  # Retained finish reasons stay outside the output budget so a terminal one is still exported,
37
55
  # so each is capped instead.
@@ -109,7 +127,37 @@ def _native(value: Any) -> Any:
109
127
 
110
128
 
111
129
  def _stream_capture_budget() -> telemetry_dev.CaptureBudget:
112
- return telemetry_dev.CaptureBudget(_STREAM_CAPTURE_MAX_BYTES, _STREAM_CAPTURE_MAX_ITEMS)
130
+ # Older cores have no for_stream and bound each budget at 64 KiB on its own.
131
+ for_stream = getattr(telemetry_dev.CaptureBudget, "for_stream", None)
132
+ budget = for_stream() if callable(for_stream) else telemetry_dev.CaptureBudget()
133
+ return cast(telemetry_dev.CaptureBudget, budget)
134
+
135
+
136
+ def _release_budget(budget: telemetry_dev.CaptureBudget) -> None:
137
+ release = getattr(budget, "release", None)
138
+ if callable(release):
139
+ release()
140
+
141
+
142
+ def _reserve_bytes(budget: telemetry_dev.CaptureBudget, total: int) -> bool:
143
+ # Older cores have no reserve_bytes; there the caller's check against max_bytes stands.
144
+ reserve = getattr(budget, "reserve_bytes", None)
145
+ if callable(reserve):
146
+ return bool(reserve(total))
147
+ budget.bytes_used = total
148
+ return True
149
+
150
+
151
+ def _adopt_budget(
152
+ stream: telemetry_dev.CaptureBudget, measured: telemetry_dev.CaptureBudget
153
+ ) -> bool:
154
+ """Takes the counts of a budget that measured a replacement into the stream's own budget,
155
+ which holds the bytes in the stream budget. Returns False, and takes nothing, when another
156
+ stream took the room after the measure."""
157
+ if not _reserve_bytes(stream, measured.bytes_used):
158
+ return False
159
+ stream.items_used = measured.items_used
160
+ return True
113
161
 
114
162
 
115
163
  def _capture_masked(handle: telemetry_dev.SpanHandle) -> bool:
@@ -146,7 +194,9 @@ def _bounded_responses_native(
146
194
  ):
147
195
  active_budget.truncated = True
148
196
  raise _CaptureLimit
149
- active_budget.bytes_used += byte_count
197
+ if not _reserve_bytes(active_budget, active_budget.bytes_used + byte_count):
198
+ active_budget.truncated = True
199
+ raise _CaptureLimit
150
200
  active_budget.items_used += item_count
151
201
 
152
202
  def convert(item: Any, item_type: str | None, depth: int) -> Any:
@@ -796,13 +846,6 @@ def _end_once(handle: telemetry_dev.SpanHandle) -> Callable[..., None]:
796
846
  return end
797
847
 
798
848
 
799
- def _safe_response_fields(mapper: ResponseMapper, response: Any) -> dict[str, Any]:
800
- try:
801
- return mapper(response)
802
- except Exception:
803
- return {}
804
-
805
-
806
849
  def _safe_start_span(
807
850
  op: str, mapper: RequestMapper, args: tuple[Any, ...], kwargs: Mapping[str, Any]
808
851
  ) -> tuple[telemetry_dev.SpanHandle, Callable[..., None], float, str | None]:
@@ -874,6 +917,8 @@ class _InstrumentedStream:
874
917
  self._finish_reasons: dict[int, str] = {}
875
918
  self._choice_indices: set[int] = set()
876
919
  self._finish_reasons_truncated = False
920
+ self._reported = False
921
+ self._output_recorded = False
877
922
 
878
923
  def __getattr__(self, name: str) -> Any:
879
924
  return getattr(self._inner, name)
@@ -896,7 +941,7 @@ class _InstrumentedStream:
896
941
  except BaseException:
897
942
  pass
898
943
  raise
899
- self._record(chunk, received_at)
944
+ self._record_guarded(chunk, received_at)
900
945
  return chunk
901
946
 
902
947
  def __aiter__(self) -> AsyncIterator[Any]:
@@ -917,7 +962,7 @@ class _InstrumentedStream:
917
962
  except BaseException:
918
963
  pass
919
964
  raise
920
- self._record(chunk, received_at)
965
+ self._record_guarded(chunk, received_at)
921
966
  return chunk
922
967
 
923
968
  def __enter__(self) -> _InstrumentedStream:
@@ -950,7 +995,85 @@ class _InstrumentedStream:
950
995
  return bool(await _maybe_await(exit_method(exc_type, exc, tb)))
951
996
  return False
952
997
 
998
+ def _report(self, exc: Exception) -> None:
999
+ # A stream reports only its first failure; a broken mapper fails on every chunk.
1000
+ if self._reported:
1001
+ return
1002
+ self._reported = True
1003
+ try:
1004
+ self._handle.report_error(exc)
1005
+ except Exception:
1006
+ pass
1007
+
1008
+ def _record_guarded(self, chunk: Any, received_at: float) -> None:
1009
+ self._output_recorded = False
1010
+ try:
1011
+ self._record(chunk, received_at)
1012
+ except Exception as exc:
1013
+ if not self._output_recorded:
1014
+ # The chunk may be lost. The truncated budget keeps no later chunk, so the
1015
+ # output stays a prefix, flagged and withheld from a mask.
1016
+ self._budget.truncated = True
1017
+ self._report(exc)
1018
+
953
1019
  def _record(self, chunk: Any, received_at: float) -> None:
1020
+ # Timings are recorded before the chunk is mapped, so a failure in its mapping neither
1021
+ # moves the first-chunk time to a later chunk nor drops the chunk from the chunk timings;
1022
+ # like a chunk past the bound, a lost chunk was still delivered. Each timing step loses
1023
+ # only its own value when it fails.
1024
+ if not self._saw_first:
1025
+ self._saw_first = True
1026
+ try:
1027
+ self._handle.update(time_to_first_chunk_ms=(received_at - self._started_at) * 1000)
1028
+ except Exception as exc:
1029
+ self._report(exc)
1030
+ try:
1031
+ self._handle.update(
1032
+ response_id=_string(_field(chunk, "id")),
1033
+ response_model=_string(_field(chunk, "model")),
1034
+ )
1035
+ except Exception as exc:
1036
+ self._report(exc)
1037
+ try:
1038
+ has_output = False
1039
+ for choice in _sequence_items(_field(chunk, "choices")):
1040
+ delta = _field(choice, "delta")
1041
+ has_output = (
1042
+ has_output
1043
+ or any(
1044
+ isinstance(value, str) and bool(value)
1045
+ for value in (
1046
+ _field(delta, "content"),
1047
+ _field(delta, "reasoning_content"),
1048
+ _field(delta, "refusal"),
1049
+ _field(_field(delta, "audio"), "data"),
1050
+ )
1051
+ )
1052
+ or (
1053
+ isinstance(_field(_field(delta, "function_call"), "arguments"), str)
1054
+ and bool(_field(_field(delta, "function_call"), "arguments"))
1055
+ )
1056
+ or any(
1057
+ isinstance(arguments, str) and bool(arguments)
1058
+ for arguments in (
1059
+ _field(_field(tool, "function"), "arguments")
1060
+ for tool in _sequence_items(_field(delta, "tool_calls"))
1061
+ )
1062
+ )
1063
+ )
1064
+ if has_output:
1065
+ self._saw_output = True
1066
+ record_output_chunk = getattr(self._handle, "record_output_chunk", None)
1067
+ if callable(record_output_chunk):
1068
+ record_output_chunk(received_at * 1000)
1069
+ except Exception as exc:
1070
+ self._report(exc)
1071
+ # The chunk is kept before its metadata is read, so a failure in the metadata (ID,
1072
+ # model, usage, finish reasons) loses only that metadata. A chunk object keeps its delta
1073
+ # fields where the budget does not walk, so the budget measures its JSON copy.
1074
+ if not self._budget.truncated and self._budget.accept(_native(chunk)):
1075
+ self._chunks.append(chunk)
1076
+ self._output_recorded = True
954
1077
  response_id = _string(_field(chunk, "id"))
955
1078
  if self._response_id is None and response_id is not None:
956
1079
  self._response_id = response_id
@@ -960,32 +1083,7 @@ class _InstrumentedStream:
960
1083
  usage = _usage_from(_field(chunk, "usage"))
961
1084
  if usage is not None and (self._usage is None or _reports_tokens(usage)):
962
1085
  self._usage = usage
963
- has_output = False
964
1086
  for fallback_index, choice in enumerate(_sequence_items(_field(chunk, "choices"))):
965
- delta = _field(choice, "delta")
966
- has_output = (
967
- has_output
968
- or any(
969
- isinstance(value, str) and bool(value)
970
- for value in (
971
- _field(delta, "content"),
972
- _field(delta, "reasoning_content"),
973
- _field(delta, "refusal"),
974
- _field(_field(delta, "audio"), "data"),
975
- )
976
- )
977
- or (
978
- isinstance(_field(_field(delta, "function_call"), "arguments"), str)
979
- and bool(_field(_field(delta, "function_call"), "arguments"))
980
- )
981
- or any(
982
- isinstance(arguments, str) and bool(arguments)
983
- for arguments in (
984
- _field(_field(tool, "function"), "arguments")
985
- for tool in _sequence_items(_field(delta, "tool_calls"))
986
- )
987
- )
988
- )
989
1087
  choice_index = _number(_field(choice, "index"))
990
1088
  key = (
991
1089
  int(choice_index)
@@ -1004,20 +1102,6 @@ class _InstrumentedStream:
1004
1102
  finish_reason = _string(_field(choice, "finish_reason"))
1005
1103
  if finish_reason is not None:
1006
1104
  self._finish_reasons[key] = finish_reason[:_FINISH_REASON_MAX_LENGTH]
1007
- if has_output:
1008
- self._saw_output = True
1009
- record_output_chunk = getattr(self._handle, "record_output_chunk", None)
1010
- if callable(record_output_chunk):
1011
- record_output_chunk(received_at * 1000)
1012
- if not self._saw_first:
1013
- self._saw_first = True
1014
- self._handle.update(
1015
- time_to_first_chunk_ms=(time.perf_counter() - self._started_at) * 1000,
1016
- response_id=response_id,
1017
- response_model=response_model,
1018
- )
1019
- if self._budget.accept(chunk):
1020
- self._chunks.append(chunk)
1021
1105
 
1022
1106
  def _finish(self, error: BaseException | None = None) -> None:
1023
1107
  if self._finished:
@@ -1028,10 +1112,12 @@ class _InstrumentedStream:
1028
1112
  try:
1029
1113
  stream_chunk_builder = cast(Callable[..., Any], cast(Any, litellm).stream_chunk_builder)
1030
1114
  rebuilt = stream_chunk_builder(self._chunks, messages=self._messages)
1031
- except Exception:
1032
- rebuilt = None
1033
- if rebuilt is not None:
1034
- fields = _safe_response_fields(_completion_response, rebuilt)
1115
+ if rebuilt is not None:
1116
+ fields = _completion_response(rebuilt)
1117
+ except Exception as exc:
1118
+ # The output is lost; the truncated budget flags it and keeps it from a mask.
1119
+ self._budget.truncated = True
1120
+ self._report(exc)
1035
1121
  rebuilt_usage = fields.get("usage")
1036
1122
  if self._usage is not None and (
1037
1123
  rebuilt_usage is None
@@ -1079,7 +1165,10 @@ class _InstrumentedStream:
1079
1165
  fields["error"] = error
1080
1166
  if self._request_provider is None and fields.get("provider") is None:
1081
1167
  fields["provider"] = _provider_from_error(error)
1082
- self._end(**fields)
1168
+ try:
1169
+ self._end(**fields)
1170
+ finally:
1171
+ _release_budget(self._budget)
1083
1172
 
1084
1173
  def close(self) -> None:
1085
1174
  self._finish()
@@ -1102,6 +1191,77 @@ class _InstrumentedStream:
1102
1191
  await _maybe_await(close())
1103
1192
 
1104
1193
 
1194
+ # On Python 3.10 and 3.11, StringIO keeps up to 100,000 written strings in a list before it
1195
+ # joins them, about 60 bytes each that the stream bound does not charge: 15 bytes a character
1196
+ # for 4-character deltas. Deltas are joined 256 at a time, so that those waiting hold under
1197
+ # about 22 KB, onto a string that is written once it has 4,096 characters, so that what is
1198
+ # written costs about 1.5% more than its text. Python 3.12 dropped the list.
1199
+ _DELTA_BATCH = 256
1200
+ _WRITE_CHARS = 4096
1201
+
1202
+
1203
+ class _DeltaBuffer:
1204
+ # A write takes several steps, unlike one StringIO call, so without the lock a read on
1205
+ # another thread, such as one that closes the stream, could copy a batch twice or drop it.
1206
+ # A fork copies the lock held if another thread was writing, and that thread does not run
1207
+ # in the child to release it. Every fork, also one that runs no at-fork hooks, changes the
1208
+ # process ID, so a child takes a new lock. The check is inline because it runs per delta.
1209
+ # A signal handler runs on the thread it interrupts, so one that reads the stream, such as
1210
+ # one that closes it, can read inside a write. So the lock is reentrant, and each step takes
1211
+ # the deltas and the tail out of the buffer before it joins or writes them, so that a read
1212
+ # inside the step does not write them as well; that read returns a prefix, which can leave
1213
+ # out the deltas being joined. A handler that writes inside a step is not supported. An
1214
+ # exception inside a step, such as KeyboardInterrupt from a handler, puts back the text the
1215
+ # step took unless the StringIO has it, so that a later read still has every delta.
1216
+ __slots__ = ("_buffer", "_lock", "_pending", "_pid", "_tail")
1217
+
1218
+ def __init__(self) -> None:
1219
+ self._buffer = StringIO()
1220
+ self._lock = threading.RLock()
1221
+ self._pid = os.getpid()
1222
+ self._pending: list[str] = []
1223
+ self._tail = ""
1224
+
1225
+ def write(self, text: str) -> None:
1226
+ pid = os.getpid()
1227
+ if self._pid != pid:
1228
+ self._lock = threading.RLock()
1229
+ self._pid = pid
1230
+ with self._lock:
1231
+ self._pending.append(text)
1232
+ if len(self._pending) >= _DELTA_BATCH:
1233
+ self._join(_WRITE_CHARS)
1234
+
1235
+ def getvalue(self) -> str:
1236
+ pid = os.getpid()
1237
+ if self._pid != pid:
1238
+ self._lock = threading.RLock()
1239
+ self._pid = pid
1240
+ with self._lock:
1241
+ self._join(1)
1242
+ return self._buffer.getvalue()
1243
+
1244
+ def _join(self, write_at: int) -> None:
1245
+ if not self._pending and len(self._tail) < write_at:
1246
+ return
1247
+ written = self._buffer.tell()
1248
+ pending: list[str] = []
1249
+ tail = ""
1250
+ try:
1251
+ pending, self._pending = self._pending, []
1252
+ tail, self._tail = self._tail, ""
1253
+ joined = "".join([tail, *pending])
1254
+ if len(joined) >= write_at:
1255
+ self._buffer.write(joined)
1256
+ else:
1257
+ self._tail = joined
1258
+ except BaseException:
1259
+ if self._buffer.tell() == written:
1260
+ self._pending[:0] = pending
1261
+ self._tail = tail + self._tail
1262
+ raise
1263
+
1264
+
1105
1265
  class _InstrumentedResponsesStream:
1106
1266
  def __init__(
1107
1267
  self,
@@ -1132,6 +1292,8 @@ class _InstrumentedResponsesStream:
1132
1292
  self._budget = _stream_capture_budget()
1133
1293
  self._charges: dict[object, tuple[int, int]] = {}
1134
1294
  self._annotation_charge_keys: dict[object, dict[int, object]] = {}
1295
+ self._reported = False
1296
+ self._record_failed = False
1135
1297
 
1136
1298
  def __getattr__(self, name: str) -> Any:
1137
1299
  return getattr(self._inner, name)
@@ -1153,7 +1315,7 @@ class _InstrumentedResponsesStream:
1153
1315
  except BaseException:
1154
1316
  pass
1155
1317
  raise
1156
- self._record(event, received_at)
1318
+ self._record_guarded(event, received_at)
1157
1319
  return event
1158
1320
 
1159
1321
  def __aiter__(self) -> AsyncIterator[Any]:
@@ -1173,7 +1335,7 @@ class _InstrumentedResponsesStream:
1173
1335
  except BaseException:
1174
1336
  pass
1175
1337
  raise
1176
- self._record(event, received_at)
1338
+ self._record_guarded(event, received_at)
1177
1339
  return event
1178
1340
 
1179
1341
  def __enter__(self) -> _InstrumentedResponsesStream:
@@ -1268,10 +1430,12 @@ class _InstrumentedResponsesStream:
1268
1430
  def _materialize_partial(self, value: dict[str, Any]) -> dict[str, Any]:
1269
1431
  if "_field" not in value:
1270
1432
  return value
1433
+ # Each partial value with a "_field" has a _DeltaBuffer under "_fragments", or a StringIO
1434
+ # when _append_delta measures its first delta.
1271
1435
  field = cast(str, value["_field"])
1272
- fragments = cast(list[str], value["_fragments"])
1436
+ fragments = cast(_DeltaBuffer | StringIO, value["_fragments"])
1273
1437
  return {key: item for key, item in value.items() if key not in {"_field", "_fragments"}} | {
1274
- field: "".join(fragments)
1438
+ field: fragments.getvalue()
1275
1439
  }
1276
1440
 
1277
1441
  def _set_completed_item(self, key: object, value: Any) -> None:
@@ -1316,7 +1480,7 @@ class _InstrumentedResponsesStream:
1316
1480
  budget.bytes_used = max(0, self._budget.bytes_used - removed_bytes)
1317
1481
  budget.items_used = max(0, self._budget.items_used - removed_items)
1318
1482
  before = (budget.bytes_used, budget.items_used)
1319
- if not budget.accept(value):
1483
+ if not budget.accept(value) or not _adopt_budget(self._budget, budget):
1320
1484
  return False
1321
1485
  for key in keys:
1322
1486
  self._charges.pop(key, None)
@@ -1324,7 +1488,6 @@ class _InstrumentedResponsesStream:
1324
1488
  budget.bytes_used - before[0],
1325
1489
  budget.items_used - before[1],
1326
1490
  )
1327
- self._budget = budget
1328
1491
  self._budget.truncated = bool(self._truncated_items) or self._truncation_overflow
1329
1492
  return True
1330
1493
 
@@ -1347,7 +1510,7 @@ class _InstrumentedResponsesStream:
1347
1510
  budget.items_used = max(0, self._budget.items_used - removed_items)
1348
1511
  before = (budget.bytes_used, budget.items_used)
1349
1512
  converted, budget = _bounded_responses_native(value, budget=budget)
1350
- if converted is None or budget.truncated:
1513
+ if converted is None or budget.truncated or not _adopt_budget(self._budget, budget):
1351
1514
  return False, None
1352
1515
  for key in keys:
1353
1516
  self._charges.pop(key, None)
@@ -1355,7 +1518,6 @@ class _InstrumentedResponsesStream:
1355
1518
  budget.bytes_used - before[0],
1356
1519
  budget.items_used - before[1],
1357
1520
  )
1358
- self._budget = budget
1359
1521
  self._budget.truncated = bool(self._truncated_items) or self._truncation_overflow
1360
1522
  return True, converted
1361
1523
 
@@ -1366,12 +1528,14 @@ class _InstrumentedResponsesStream:
1366
1528
  self._budget.truncated = True
1367
1529
  return False
1368
1530
  if storage_key not in self._charges:
1369
- candidate = self._materialize_partial({**target, "_fragments": [delta]})
1531
+ candidate = self._materialize_partial({**target, "_fragments": StringIO(delta)})
1370
1532
  return self._replace_charge([], candidate, storage_key)
1371
1533
  if self._budget.items_used >= self._budget.max_items:
1372
1534
  self._budget.truncated = True
1373
1535
  return False
1374
- self._budget.bytes_used += delta_bytes
1536
+ if not _reserve_bytes(self._budget, self._budget.bytes_used + delta_bytes):
1537
+ self._budget.truncated = True
1538
+ return False
1375
1539
  self._budget.items_used += 1
1376
1540
  charged_bytes, charged_items = self._charges[storage_key]
1377
1541
  self._charges[storage_key] = (charged_bytes + delta_bytes, charged_items + 1)
@@ -1399,16 +1563,16 @@ class _InstrumentedResponsesStream:
1399
1563
  keys = self._truncated_item_keys[owner]
1400
1564
  if key in keys:
1401
1565
  return
1402
- if self._truncated_key_count < self._budget.max_items:
1566
+ if self._truncated_key_count < _STREAM_CAPTURE_MAX_ITEMS:
1403
1567
  keys.add(key)
1404
1568
  self._truncated_key_count += 1
1405
1569
  else:
1406
1570
  self._truncation_overflow = True
1407
1571
  return
1408
- if self._retained_item_count < self._budget.max_items:
1572
+ if self._retained_item_count < _STREAM_CAPTURE_MAX_ITEMS:
1409
1573
  self._truncated_items.add(owner)
1410
1574
  self._retained_item_count += 1
1411
- if self._truncated_key_count < self._budget.max_items:
1575
+ if self._truncated_key_count < _STREAM_CAPTURE_MAX_ITEMS:
1412
1576
  self._truncated_item_keys[owner] = {key}
1413
1577
  self._truncated_key_count += 1
1414
1578
  else:
@@ -1433,16 +1597,53 @@ class _InstrumentedResponsesStream:
1433
1597
  if not keys and not self._truncation_overflow:
1434
1598
  self._resolve_truncated(item_key)
1435
1599
 
1600
+ def _report(self, exc: Exception) -> None:
1601
+ # A stream reports only its first failure; a broken mapper fails on every event.
1602
+ if self._reported:
1603
+ return
1604
+ self._reported = True
1605
+ try:
1606
+ self._handle.report_error(exc)
1607
+ except Exception:
1608
+ pass
1609
+
1610
+ def _record_guarded(self, event: Any, received_at: float) -> None:
1611
+ try:
1612
+ # After a lost event only the terminal events are read, so the output rebuilt from
1613
+ # deltas stays a prefix; a complete terminal snapshot still replaces it.
1614
+ if self._record_failed and _string(_field(event, "type")) not in {
1615
+ "response.completed",
1616
+ "response.incomplete",
1617
+ "response.failed",
1618
+ "error",
1619
+ }:
1620
+ return
1621
+ self._record(event, received_at)
1622
+ except Exception as exc:
1623
+ self._record_failed = True
1624
+ self._budget.truncated = True
1625
+ self._report(exc)
1626
+ if self._finished:
1627
+ # The end failed after it began, so the span ends here, flagged, with no output.
1628
+ try:
1629
+ self._end(attributes={_CAPTURE_TRUNCATED_ATTRIBUTE: True})
1630
+ finally:
1631
+ _release_budget(self._budget)
1632
+
1436
1633
  def _record(self, event: Any, received_at: float) -> None:
1437
1634
  event_type = _string(_field(event, "type"))
1438
1635
  response = _field(event, "response")
1439
1636
  if not self._saw_first:
1440
1637
  self._saw_first = True
1441
- self._handle.update(
1442
- time_to_first_chunk_ms=(received_at - self._started_at) * 1000,
1443
- response_id=_string(_field(response, "id")),
1444
- response_model=_string(_field(response, "model")),
1445
- )
1638
+ try:
1639
+ self._handle.update(
1640
+ time_to_first_chunk_ms=(received_at - self._started_at) * 1000,
1641
+ response_id=_string(_field(response, "id")),
1642
+ response_model=_string(_field(response, "model")),
1643
+ )
1644
+ except Exception as exc:
1645
+ # Only the first-event timing and IDs are lost; the event's output is recorded.
1646
+ self._report(exc)
1446
1647
  if event_type in {
1447
1648
  "response.output_item.added",
1448
1649
  "response.content_part.added",
@@ -1494,7 +1695,7 @@ class _InstrumentedResponsesStream:
1494
1695
  **(target or {}),
1495
1696
  "type": "output_text" if field == "text" else "refusal",
1496
1697
  "_field": field,
1497
- "_fragments": [],
1698
+ "_fragments": _DeltaBuffer(),
1498
1699
  }
1499
1700
  elif event_type in {
1500
1701
  "response.reasoning_summary_text.delta",
@@ -1523,7 +1724,7 @@ class _InstrumentedResponsesStream:
1523
1724
  **(target or {}),
1524
1725
  "type": kind,
1525
1726
  "_field": field,
1526
- "_fragments": [],
1727
+ "_fragments": _DeltaBuffer(),
1527
1728
  }
1528
1729
  elif event_type in {
1529
1730
  "response.function_call_arguments.delta",
@@ -1545,7 +1746,7 @@ class _InstrumentedResponsesStream:
1545
1746
  **(seeded if isinstance(seeded, dict) else {}),
1546
1747
  "type": event_kind[0],
1547
1748
  "_field": event_kind[1],
1548
- "_fragments": [],
1749
+ "_fragments": _DeltaBuffer(),
1549
1750
  }
1550
1751
  item_id = _string(_field(event, "item_id"))
1551
1752
  if item_id is not None:
@@ -1555,12 +1756,13 @@ class _InstrumentedResponsesStream:
1555
1756
  if value is not None:
1556
1757
  target[field] = value
1557
1758
  if target is not None:
1558
- fragments = cast(list[str], target["_fragments"])
1759
+ # Every target above is created with a _DeltaBuffer under "_fragments".
1760
+ fragments = cast(_DeltaBuffer, target["_fragments"])
1559
1761
  accepted = not self._delta_rejected_before(storage_key) and self._append_delta(
1560
1762
  storage_key, target, delta
1561
1763
  )
1562
1764
  if accepted:
1563
- fragments.append(delta)
1765
+ fragments.write(delta)
1564
1766
  self._remember_item_index(event, item_key)
1565
1767
  if event_type in {
1566
1768
  "response.function_call_arguments.delta",
@@ -1724,38 +1926,83 @@ class _InstrumentedResponsesStream:
1724
1926
  if self._finished:
1725
1927
  return
1726
1928
  self._finished = True
1727
- fields = (
1728
- _safe_response_fields(_responses_response_without_output, response)
1729
- if response is not None
1730
- else {}
1731
- )
1929
+ fields: dict[str, Any] = {}
1930
+ fields_lost = False
1931
+ if response is not None:
1932
+ try:
1933
+ fields = _responses_response_without_output(response)
1934
+ except Exception as exc:
1935
+ # Only the response's own fields are lost: the output is mapped apart, so it is
1936
+ # flagged but not cut or withheld from a mask. A failed response is still
1937
+ # recorded as failed.
1938
+ fields_lost = True
1939
+ self._report(exc)
1940
+ try:
1941
+ fields = {"error": _response_error(response)}
1942
+ except Exception:
1943
+ pass
1732
1944
  if response is not None and self._capture_output:
1733
- terminal_output, terminal_budget = _bounded_responses_native(
1734
- _field(response, "output"), budget=_stream_capture_budget()
1735
- )
1736
- if terminal_output is not None and not terminal_budget.truncated:
1737
- fields["output"] = terminal_output
1738
- self._budget = terminal_budget
1739
- self._retained_item_count -= len(self._truncated_items)
1740
- self._truncated_items.clear()
1741
- self._truncated_item_keys.clear()
1742
- self._truncated_key_count = 0
1743
- self._truncation_overflow = False
1744
- elif _field(response, "output") is not None or terminal_budget.truncated:
1945
+ try:
1946
+ terminal_output, terminal_budget = _bounded_responses_native(
1947
+ _field(response, "output"),
1948
+ budget=telemetry_dev.CaptureBudget(
1949
+ self._budget.max_bytes, self._budget.max_items
1950
+ ),
1951
+ )
1952
+ # An empty terminal output does not replace output recorded from the deltas, or
1953
+ # lost with an event, so it is read as no terminal output. A lost event leaves the
1954
+ # budget truncated until this replacement, so the truncated budget covers it.
1955
+ replaces_recorded = terminal_output != [] or not (
1956
+ self._charges or self._budget.truncated
1957
+ )
1958
+ if (
1959
+ terminal_output is not None
1960
+ and replaces_recorded
1961
+ and not terminal_budget.truncated
1962
+ and _adopt_budget(self._budget, terminal_budget)
1963
+ ):
1964
+ fields["output"] = terminal_output
1965
+ self._budget.truncated = False
1966
+ self._retained_item_count -= len(self._truncated_items)
1967
+ self._truncated_items.clear()
1968
+ self._truncated_item_keys.clear()
1969
+ self._truncated_key_count = 0
1970
+ self._truncation_overflow = False
1971
+ elif replaces_recorded and (
1972
+ _field(response, "output") is not None or terminal_budget.truncated
1973
+ ):
1974
+ self._budget.truncated = True
1975
+ except Exception as exc:
1976
+ # The terminal output is lost; the output rebuilt from the deltas, if any, is
1977
+ # flagged and withheld from a mask, and the mapped fields are kept.
1978
+ fields.pop("output", None)
1745
1979
  self._budget.truncated = True
1980
+ self._report(exc)
1746
1981
  if "output" not in fields:
1747
- partial_output = self._partial_output()
1982
+ try:
1983
+ partial_output = self._partial_output()
1984
+ except Exception as exc:
1985
+ # The output cannot be rebuilt whole, so none is exported, and the stream is
1986
+ # flagged.
1987
+ partial_output = None
1988
+ self._budget.truncated = True
1989
+ self._report(exc)
1748
1990
  if partial_output:
1749
1991
  fields["output"] = partial_output
1750
1992
  if self._budget.truncated or response is None or error is not None:
1751
1993
  _mark_capture_incomplete(fields, self._mask_output_when_incomplete)
1994
+ elif fields_lost:
1995
+ _mark_capture_incomplete(fields, False)
1752
1996
  if self._request_provider is not None:
1753
1997
  fields.pop("provider", None)
1754
1998
  if error is not None:
1755
1999
  fields["error"] = error
1756
2000
  if self._request_provider is None:
1757
2001
  fields["provider"] = _provider_from_error(error)
1758
- self._end(**fields)
2002
+ try:
2003
+ self._end(**fields)
2004
+ finally:
2005
+ _release_budget(self._budget)
1759
2006
 
1760
2007
  def close(self) -> None:
1761
2008
  self._finish()
@@ -1859,7 +2106,20 @@ def _wrap_sync(
1859
2106
  elif op == "rerank":
1860
2107
  active_mapper = _rerank_response_without_output
1861
2108
  try:
1862
- fields = _safe_response_fields(active_mapper, result)
2109
+ fields = active_mapper(result)
2110
+ except Exception as exc:
2111
+ # The output and response fields are lost; the call is failed only if the response
2112
+ # says so.
2113
+ try:
2114
+ handle.report_error(exc)
2115
+ except Exception:
2116
+ pass
2117
+ fields: dict[str, Any] = {"attributes": {_CAPTURE_TRUNCATED_ATTRIBUTE: True}}
2118
+ if op == "responses":
2119
+ try:
2120
+ fields["error"] = _response_error(result)
2121
+ except Exception:
2122
+ pass
1863
2123
  except BaseException as exc:
1864
2124
  end(
1865
2125
  error=exc,
@@ -1919,7 +2179,20 @@ def _wrap_async(
1919
2179
  elif op == "rerank":
1920
2180
  active_mapper = _rerank_response_without_output
1921
2181
  try:
1922
- fields = _safe_response_fields(active_mapper, result)
2182
+ fields = active_mapper(result)
2183
+ except Exception as exc:
2184
+ # The output and response fields are lost; the call is failed only if the response
2185
+ # says so.
2186
+ try:
2187
+ handle.report_error(exc)
2188
+ except Exception:
2189
+ pass
2190
+ fields: dict[str, Any] = {"attributes": {_CAPTURE_TRUNCATED_ATTRIBUTE: True}}
2191
+ if op == "responses":
2192
+ try:
2193
+ fields["error"] = _response_error(result)
2194
+ except Exception:
2195
+ pass
1923
2196
  except BaseException as exc:
1924
2197
  end(
1925
2198
  error=exc,