telemetry-dev-litellm 0.2.1__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.1
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.1"
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.1"
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.1"
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:
@@ -455,6 +505,12 @@ def _output_type(response_format: Any) -> str | None:
455
505
  return kind
456
506
 
457
507
 
508
+ def _prompt_attributes(messages: object) -> dict[str, Any]:
509
+ # Older cores have no prompt_attributes_for and record a prompt only inside with_prompt.
510
+ read = getattr(telemetry_dev, "prompt_attributes_for", None)
511
+ return cast("dict[str, Any]", read(messages)) if callable(read) else {}
512
+
513
+
458
514
  def _completion_request(
459
515
  args: tuple[Any, ...], kwargs: Mapping[str, Any]
460
516
  ) -> tuple[str, dict[str, Any]]:
@@ -478,6 +534,7 @@ def _completion_request(
478
534
  "presence_penalty": _number(kwargs.get("presence_penalty")),
479
535
  "output_type": _output_type(kwargs.get("response_format")),
480
536
  "metadata": _request_metadata(kwargs),
537
+ "attributes": _prompt_attributes(_arg(args, kwargs, "messages", 1)) or None,
481
538
  },
482
539
  )
483
540
 
@@ -545,7 +602,10 @@ def _responses_request(
545
602
  input_budget: telemetry_dev.CaptureBudget | None = None
546
603
  if capture_input:
547
604
  input_value, input_budget = _bounded_responses_native(_arg(args, kwargs, "input", 0))
548
- attributes: dict[str, Any] = {"gen_ai.operation.name": "chat"}
605
+ attributes: dict[str, Any] = {
606
+ "gen_ai.operation.name": "chat",
607
+ **_prompt_attributes(_arg(args, kwargs, "input", 0)),
608
+ }
549
609
  if input_budget is not None and input_budget.truncated:
550
610
  attributes["telemetry.dev.capture.truncated"] = True
551
611
  return (
@@ -786,13 +846,6 @@ def _end_once(handle: telemetry_dev.SpanHandle) -> Callable[..., None]:
786
846
  return end
787
847
 
788
848
 
789
- def _safe_response_fields(mapper: ResponseMapper, response: Any) -> dict[str, Any]:
790
- try:
791
- return mapper(response)
792
- except Exception:
793
- return {}
794
-
795
-
796
849
  def _safe_start_span(
797
850
  op: str, mapper: RequestMapper, args: tuple[Any, ...], kwargs: Mapping[str, Any]
798
851
  ) -> tuple[telemetry_dev.SpanHandle, Callable[..., None], float, str | None]:
@@ -864,6 +917,8 @@ class _InstrumentedStream:
864
917
  self._finish_reasons: dict[int, str] = {}
865
918
  self._choice_indices: set[int] = set()
866
919
  self._finish_reasons_truncated = False
920
+ self._reported = False
921
+ self._output_recorded = False
867
922
 
868
923
  def __getattr__(self, name: str) -> Any:
869
924
  return getattr(self._inner, name)
@@ -886,7 +941,7 @@ class _InstrumentedStream:
886
941
  except BaseException:
887
942
  pass
888
943
  raise
889
- self._record(chunk, received_at)
944
+ self._record_guarded(chunk, received_at)
890
945
  return chunk
891
946
 
892
947
  def __aiter__(self) -> AsyncIterator[Any]:
@@ -907,7 +962,7 @@ class _InstrumentedStream:
907
962
  except BaseException:
908
963
  pass
909
964
  raise
910
- self._record(chunk, received_at)
965
+ self._record_guarded(chunk, received_at)
911
966
  return chunk
912
967
 
913
968
  def __enter__(self) -> _InstrumentedStream:
@@ -940,7 +995,85 @@ class _InstrumentedStream:
940
995
  return bool(await _maybe_await(exit_method(exc_type, exc, tb)))
941
996
  return False
942
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
+
943
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
944
1077
  response_id = _string(_field(chunk, "id"))
945
1078
  if self._response_id is None and response_id is not None:
946
1079
  self._response_id = response_id
@@ -950,32 +1083,7 @@ class _InstrumentedStream:
950
1083
  usage = _usage_from(_field(chunk, "usage"))
951
1084
  if usage is not None and (self._usage is None or _reports_tokens(usage)):
952
1085
  self._usage = usage
953
- has_output = False
954
1086
  for fallback_index, choice in enumerate(_sequence_items(_field(chunk, "choices"))):
955
- delta = _field(choice, "delta")
956
- has_output = (
957
- has_output
958
- or any(
959
- isinstance(value, str) and bool(value)
960
- for value in (
961
- _field(delta, "content"),
962
- _field(delta, "reasoning_content"),
963
- _field(delta, "refusal"),
964
- _field(_field(delta, "audio"), "data"),
965
- )
966
- )
967
- or (
968
- isinstance(_field(_field(delta, "function_call"), "arguments"), str)
969
- and bool(_field(_field(delta, "function_call"), "arguments"))
970
- )
971
- or any(
972
- isinstance(arguments, str) and bool(arguments)
973
- for arguments in (
974
- _field(_field(tool, "function"), "arguments")
975
- for tool in _sequence_items(_field(delta, "tool_calls"))
976
- )
977
- )
978
- )
979
1087
  choice_index = _number(_field(choice, "index"))
980
1088
  key = (
981
1089
  int(choice_index)
@@ -994,20 +1102,6 @@ class _InstrumentedStream:
994
1102
  finish_reason = _string(_field(choice, "finish_reason"))
995
1103
  if finish_reason is not None:
996
1104
  self._finish_reasons[key] = finish_reason[:_FINISH_REASON_MAX_LENGTH]
997
- if has_output:
998
- self._saw_output = True
999
- record_output_chunk = getattr(self._handle, "record_output_chunk", None)
1000
- if callable(record_output_chunk):
1001
- record_output_chunk(received_at * 1000)
1002
- if not self._saw_first:
1003
- self._saw_first = True
1004
- self._handle.update(
1005
- time_to_first_chunk_ms=(time.perf_counter() - self._started_at) * 1000,
1006
- response_id=response_id,
1007
- response_model=response_model,
1008
- )
1009
- if self._budget.accept(chunk):
1010
- self._chunks.append(chunk)
1011
1105
 
1012
1106
  def _finish(self, error: BaseException | None = None) -> None:
1013
1107
  if self._finished:
@@ -1018,10 +1112,12 @@ class _InstrumentedStream:
1018
1112
  try:
1019
1113
  stream_chunk_builder = cast(Callable[..., Any], cast(Any, litellm).stream_chunk_builder)
1020
1114
  rebuilt = stream_chunk_builder(self._chunks, messages=self._messages)
1021
- except Exception:
1022
- rebuilt = None
1023
- if rebuilt is not None:
1024
- 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)
1025
1121
  rebuilt_usage = fields.get("usage")
1026
1122
  if self._usage is not None and (
1027
1123
  rebuilt_usage is None
@@ -1069,7 +1165,10 @@ class _InstrumentedStream:
1069
1165
  fields["error"] = error
1070
1166
  if self._request_provider is None and fields.get("provider") is None:
1071
1167
  fields["provider"] = _provider_from_error(error)
1072
- self._end(**fields)
1168
+ try:
1169
+ self._end(**fields)
1170
+ finally:
1171
+ _release_budget(self._budget)
1073
1172
 
1074
1173
  def close(self) -> None:
1075
1174
  self._finish()
@@ -1092,6 +1191,77 @@ class _InstrumentedStream:
1092
1191
  await _maybe_await(close())
1093
1192
 
1094
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
+
1095
1265
  class _InstrumentedResponsesStream:
1096
1266
  def __init__(
1097
1267
  self,
@@ -1122,6 +1292,8 @@ class _InstrumentedResponsesStream:
1122
1292
  self._budget = _stream_capture_budget()
1123
1293
  self._charges: dict[object, tuple[int, int]] = {}
1124
1294
  self._annotation_charge_keys: dict[object, dict[int, object]] = {}
1295
+ self._reported = False
1296
+ self._record_failed = False
1125
1297
 
1126
1298
  def __getattr__(self, name: str) -> Any:
1127
1299
  return getattr(self._inner, name)
@@ -1143,7 +1315,7 @@ class _InstrumentedResponsesStream:
1143
1315
  except BaseException:
1144
1316
  pass
1145
1317
  raise
1146
- self._record(event, received_at)
1318
+ self._record_guarded(event, received_at)
1147
1319
  return event
1148
1320
 
1149
1321
  def __aiter__(self) -> AsyncIterator[Any]:
@@ -1163,7 +1335,7 @@ class _InstrumentedResponsesStream:
1163
1335
  except BaseException:
1164
1336
  pass
1165
1337
  raise
1166
- self._record(event, received_at)
1338
+ self._record_guarded(event, received_at)
1167
1339
  return event
1168
1340
 
1169
1341
  def __enter__(self) -> _InstrumentedResponsesStream:
@@ -1258,10 +1430,12 @@ class _InstrumentedResponsesStream:
1258
1430
  def _materialize_partial(self, value: dict[str, Any]) -> dict[str, Any]:
1259
1431
  if "_field" not in value:
1260
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.
1261
1435
  field = cast(str, value["_field"])
1262
- fragments = cast(list[str], value["_fragments"])
1436
+ fragments = cast(_DeltaBuffer | StringIO, value["_fragments"])
1263
1437
  return {key: item for key, item in value.items() if key not in {"_field", "_fragments"}} | {
1264
- field: "".join(fragments)
1438
+ field: fragments.getvalue()
1265
1439
  }
1266
1440
 
1267
1441
  def _set_completed_item(self, key: object, value: Any) -> None:
@@ -1306,7 +1480,7 @@ class _InstrumentedResponsesStream:
1306
1480
  budget.bytes_used = max(0, self._budget.bytes_used - removed_bytes)
1307
1481
  budget.items_used = max(0, self._budget.items_used - removed_items)
1308
1482
  before = (budget.bytes_used, budget.items_used)
1309
- if not budget.accept(value):
1483
+ if not budget.accept(value) or not _adopt_budget(self._budget, budget):
1310
1484
  return False
1311
1485
  for key in keys:
1312
1486
  self._charges.pop(key, None)
@@ -1314,7 +1488,6 @@ class _InstrumentedResponsesStream:
1314
1488
  budget.bytes_used - before[0],
1315
1489
  budget.items_used - before[1],
1316
1490
  )
1317
- self._budget = budget
1318
1491
  self._budget.truncated = bool(self._truncated_items) or self._truncation_overflow
1319
1492
  return True
1320
1493
 
@@ -1337,7 +1510,7 @@ class _InstrumentedResponsesStream:
1337
1510
  budget.items_used = max(0, self._budget.items_used - removed_items)
1338
1511
  before = (budget.bytes_used, budget.items_used)
1339
1512
  converted, budget = _bounded_responses_native(value, budget=budget)
1340
- if converted is None or budget.truncated:
1513
+ if converted is None or budget.truncated or not _adopt_budget(self._budget, budget):
1341
1514
  return False, None
1342
1515
  for key in keys:
1343
1516
  self._charges.pop(key, None)
@@ -1345,7 +1518,6 @@ class _InstrumentedResponsesStream:
1345
1518
  budget.bytes_used - before[0],
1346
1519
  budget.items_used - before[1],
1347
1520
  )
1348
- self._budget = budget
1349
1521
  self._budget.truncated = bool(self._truncated_items) or self._truncation_overflow
1350
1522
  return True, converted
1351
1523
 
@@ -1356,12 +1528,14 @@ class _InstrumentedResponsesStream:
1356
1528
  self._budget.truncated = True
1357
1529
  return False
1358
1530
  if storage_key not in self._charges:
1359
- candidate = self._materialize_partial({**target, "_fragments": [delta]})
1531
+ candidate = self._materialize_partial({**target, "_fragments": StringIO(delta)})
1360
1532
  return self._replace_charge([], candidate, storage_key)
1361
1533
  if self._budget.items_used >= self._budget.max_items:
1362
1534
  self._budget.truncated = True
1363
1535
  return False
1364
- 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
1365
1539
  self._budget.items_used += 1
1366
1540
  charged_bytes, charged_items = self._charges[storage_key]
1367
1541
  self._charges[storage_key] = (charged_bytes + delta_bytes, charged_items + 1)
@@ -1389,16 +1563,16 @@ class _InstrumentedResponsesStream:
1389
1563
  keys = self._truncated_item_keys[owner]
1390
1564
  if key in keys:
1391
1565
  return
1392
- if self._truncated_key_count < self._budget.max_items:
1566
+ if self._truncated_key_count < _STREAM_CAPTURE_MAX_ITEMS:
1393
1567
  keys.add(key)
1394
1568
  self._truncated_key_count += 1
1395
1569
  else:
1396
1570
  self._truncation_overflow = True
1397
1571
  return
1398
- if self._retained_item_count < self._budget.max_items:
1572
+ if self._retained_item_count < _STREAM_CAPTURE_MAX_ITEMS:
1399
1573
  self._truncated_items.add(owner)
1400
1574
  self._retained_item_count += 1
1401
- if self._truncated_key_count < self._budget.max_items:
1575
+ if self._truncated_key_count < _STREAM_CAPTURE_MAX_ITEMS:
1402
1576
  self._truncated_item_keys[owner] = {key}
1403
1577
  self._truncated_key_count += 1
1404
1578
  else:
@@ -1423,16 +1597,53 @@ class _InstrumentedResponsesStream:
1423
1597
  if not keys and not self._truncation_overflow:
1424
1598
  self._resolve_truncated(item_key)
1425
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
+
1426
1633
  def _record(self, event: Any, received_at: float) -> None:
1427
1634
  event_type = _string(_field(event, "type"))
1428
1635
  response = _field(event, "response")
1429
1636
  if not self._saw_first:
1430
1637
  self._saw_first = True
1431
- self._handle.update(
1432
- time_to_first_chunk_ms=(received_at - self._started_at) * 1000,
1433
- response_id=_string(_field(response, "id")),
1434
- response_model=_string(_field(response, "model")),
1435
- )
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)
1436
1647
  if event_type in {
1437
1648
  "response.output_item.added",
1438
1649
  "response.content_part.added",
@@ -1484,7 +1695,7 @@ class _InstrumentedResponsesStream:
1484
1695
  **(target or {}),
1485
1696
  "type": "output_text" if field == "text" else "refusal",
1486
1697
  "_field": field,
1487
- "_fragments": [],
1698
+ "_fragments": _DeltaBuffer(),
1488
1699
  }
1489
1700
  elif event_type in {
1490
1701
  "response.reasoning_summary_text.delta",
@@ -1513,7 +1724,7 @@ class _InstrumentedResponsesStream:
1513
1724
  **(target or {}),
1514
1725
  "type": kind,
1515
1726
  "_field": field,
1516
- "_fragments": [],
1727
+ "_fragments": _DeltaBuffer(),
1517
1728
  }
1518
1729
  elif event_type in {
1519
1730
  "response.function_call_arguments.delta",
@@ -1535,7 +1746,7 @@ class _InstrumentedResponsesStream:
1535
1746
  **(seeded if isinstance(seeded, dict) else {}),
1536
1747
  "type": event_kind[0],
1537
1748
  "_field": event_kind[1],
1538
- "_fragments": [],
1749
+ "_fragments": _DeltaBuffer(),
1539
1750
  }
1540
1751
  item_id = _string(_field(event, "item_id"))
1541
1752
  if item_id is not None:
@@ -1545,12 +1756,13 @@ class _InstrumentedResponsesStream:
1545
1756
  if value is not None:
1546
1757
  target[field] = value
1547
1758
  if target is not None:
1548
- fragments = cast(list[str], target["_fragments"])
1759
+ # Every target above is created with a _DeltaBuffer under "_fragments".
1760
+ fragments = cast(_DeltaBuffer, target["_fragments"])
1549
1761
  accepted = not self._delta_rejected_before(storage_key) and self._append_delta(
1550
1762
  storage_key, target, delta
1551
1763
  )
1552
1764
  if accepted:
1553
- fragments.append(delta)
1765
+ fragments.write(delta)
1554
1766
  self._remember_item_index(event, item_key)
1555
1767
  if event_type in {
1556
1768
  "response.function_call_arguments.delta",
@@ -1714,38 +1926,83 @@ class _InstrumentedResponsesStream:
1714
1926
  if self._finished:
1715
1927
  return
1716
1928
  self._finished = True
1717
- fields = (
1718
- _safe_response_fields(_responses_response_without_output, response)
1719
- if response is not None
1720
- else {}
1721
- )
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
1722
1944
  if response is not None and self._capture_output:
1723
- terminal_output, terminal_budget = _bounded_responses_native(
1724
- _field(response, "output"), budget=_stream_capture_budget()
1725
- )
1726
- if terminal_output is not None and not terminal_budget.truncated:
1727
- fields["output"] = terminal_output
1728
- self._budget = terminal_budget
1729
- self._retained_item_count -= len(self._truncated_items)
1730
- self._truncated_items.clear()
1731
- self._truncated_item_keys.clear()
1732
- self._truncated_key_count = 0
1733
- self._truncation_overflow = False
1734
- 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)
1735
1979
  self._budget.truncated = True
1980
+ self._report(exc)
1736
1981
  if "output" not in fields:
1737
- 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)
1738
1990
  if partial_output:
1739
1991
  fields["output"] = partial_output
1740
1992
  if self._budget.truncated or response is None or error is not None:
1741
1993
  _mark_capture_incomplete(fields, self._mask_output_when_incomplete)
1994
+ elif fields_lost:
1995
+ _mark_capture_incomplete(fields, False)
1742
1996
  if self._request_provider is not None:
1743
1997
  fields.pop("provider", None)
1744
1998
  if error is not None:
1745
1999
  fields["error"] = error
1746
2000
  if self._request_provider is None:
1747
2001
  fields["provider"] = _provider_from_error(error)
1748
- self._end(**fields)
2002
+ try:
2003
+ self._end(**fields)
2004
+ finally:
2005
+ _release_budget(self._budget)
1749
2006
 
1750
2007
  def close(self) -> None:
1751
2008
  self._finish()
@@ -1849,7 +2106,20 @@ def _wrap_sync(
1849
2106
  elif op == "rerank":
1850
2107
  active_mapper = _rerank_response_without_output
1851
2108
  try:
1852
- 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
1853
2123
  except BaseException as exc:
1854
2124
  end(
1855
2125
  error=exc,
@@ -1909,7 +2179,20 @@ def _wrap_async(
1909
2179
  elif op == "rerank":
1910
2180
  active_mapper = _rerank_response_without_output
1911
2181
  try:
1912
- 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
1913
2196
  except BaseException as exc:
1914
2197
  end(
1915
2198
  error=exc,