spanlight 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ """Instrumentation for the official ``anthropic`` Python SDK.
2
+
3
+ :func:`wrap_anthropic` patches ``messages.create`` (including
4
+ ``stream=True``) and the ``messages.stream(...)`` helper on one client
5
+ instance, sync or async.
6
+
7
+ Token accounting: Anthropic reports ``input_tokens`` *excluding* prompt-cache
8
+ reads and writes. To keep one meaning across providers, spans record
9
+ ``input_tokens`` as the total prompt size (uncached + cache reads + cache
10
+ writes) and ``cached_tokens`` as the cache reads. The raw cache-write count is
11
+ kept in the ``usage.cache_creation_input_tokens`` attribute.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import functools
17
+ import json
18
+ import logging
19
+ from collections.abc import Callable
20
+ from types import TracebackType
21
+ from typing import TYPE_CHECKING, Any, TypeVar
22
+
23
+ from .._span import Span
24
+ from ._common import (
25
+ WRAPPED_MARKER,
26
+ AsyncStreamProxy,
27
+ ClientGetter,
28
+ Endpoint,
29
+ StreamAccumulator,
30
+ SyncStreamProxy,
31
+ as_int,
32
+ client_getter,
33
+ finish_with_error,
34
+ get_field,
35
+ patch_method,
36
+ pop_prompt,
37
+ start_llm_span,
38
+ wrap_async_method,
39
+ wrap_sync_method,
40
+ )
41
+
42
+ if TYPE_CHECKING:
43
+ from .._client import Spanlight
44
+
45
+ logger = logging.getLogger("spanlight")
46
+
47
+ ClientT = TypeVar("ClientT")
48
+
49
+ PROVIDER = "anthropic"
50
+
51
+
52
+ def wrap_anthropic(client: ClientT, *, spanlight: Spanlight | None = None) -> ClientT:
53
+ """Instrument an ``Anthropic``/``AsyncAnthropic`` client in place.
54
+
55
+ ``messages.create`` (plain and ``stream=True``) and ``messages.stream``
56
+ become ``llm`` spans with model, token usage, time-to-first-token for
57
+ streams, sanitized request parameters and the assistant message. Errors
58
+ are recorded and re-raised unchanged. Wrapping twice is harmless.
59
+
60
+ Args:
61
+ client: The Anthropic client to instrument.
62
+ spanlight: Spanlight client to report to; defaults to the global client at call time.
63
+
64
+ Returns:
65
+ The same client object, for convenient chaining.
66
+ """
67
+ try:
68
+ _instrument(client, spanlight)
69
+ except Exception:
70
+ logger.warning("Spanlight: could not instrument Anthropic client", exc_info=True)
71
+ return client
72
+
73
+
74
+ def _instrument(client: Any, spanlight: Spanlight | None) -> None:
75
+ from anthropic import AsyncStream, Stream
76
+ from anthropic.resources.messages import AsyncMessages
77
+
78
+ get_client = client_getter(spanlight)
79
+ endpoint = MessagesEndpoint(PROVIDER, (Stream, AsyncStream))
80
+ messages = client.messages
81
+ is_async = isinstance(messages, AsyncMessages)
82
+
83
+ wrap = wrap_async_method if is_async else wrap_sync_method
84
+ patch_method(messages, "create", lambda original: wrap(original, endpoint, get_client))
85
+
86
+ manager_cls = _AsyncStreamManager if is_async else _SyncStreamManager
87
+
88
+ def wrap_stream_helper(original: Callable[..., Any]) -> Callable[..., Any]:
89
+ @functools.wraps(original)
90
+ def stream(*args: Any, **kwargs: Any) -> Any:
91
+ prompt = pop_prompt(kwargs)
92
+ manager = original(*args, **kwargs)
93
+ return manager_cls(manager, endpoint, get_client, kwargs, prompt)
94
+
95
+ setattr(stream, WRAPPED_MARKER, True)
96
+ return stream
97
+
98
+ patch_method(messages, "stream", wrap_stream_helper)
99
+
100
+
101
+ class MessagesEndpoint(Endpoint):
102
+ """``messages.create``."""
103
+
104
+ operation = "chat"
105
+
106
+ def __init__(self, provider: str, stream_types: tuple[type, ...]) -> None:
107
+ super().__init__(provider)
108
+ self._stream_types = stream_types
109
+
110
+ def is_stream(self, result: Any) -> bool:
111
+ return isinstance(result, self._stream_types)
112
+
113
+ def new_accumulator(self) -> StreamAccumulator:
114
+ return _MessagesStreamAccumulator()
115
+
116
+ def record_response(self, span: Span, response: Any) -> None:
117
+ span.set_model(get_field(response, "model"), provider=PROVIDER)
118
+ _record_usage(span, get_field(response, "usage"))
119
+ _record_message_metadata(
120
+ span, get_field(response, "id"), get_field(response, "stop_reason")
121
+ )
122
+ span.set_output({"role": "assistant", "content": get_field(response, "content") or []})
123
+
124
+
125
+ def _record_usage(span: Span, usage: Any) -> None:
126
+ if usage is None:
127
+ return
128
+ uncached = as_int(get_field(usage, "input_tokens"))
129
+ cache_read = as_int(get_field(usage, "cache_read_input_tokens"))
130
+ cache_write = as_int(get_field(usage, "cache_creation_input_tokens"))
131
+ total_input = None
132
+ if uncached is not None:
133
+ total_input = uncached + (cache_read or 0) + (cache_write or 0)
134
+ span.set_usage(
135
+ input_tokens=total_input,
136
+ output_tokens=as_int(get_field(usage, "output_tokens")),
137
+ cached_tokens=cache_read,
138
+ )
139
+ if cache_write:
140
+ span.set_attribute("usage.cache_creation_input_tokens", cache_write)
141
+
142
+
143
+ def _record_message_metadata(span: Span, message_id: Any, stop_reason: Any) -> None:
144
+ if isinstance(message_id, str):
145
+ span.set_attribute("response.id", message_id)
146
+ if isinstance(stop_reason, str):
147
+ span.set_attribute("response.stop_reason", stop_reason)
148
+ span.set_finish_reason(stop_reason)
149
+
150
+
151
+ class _MessagesStreamAccumulator(StreamAccumulator):
152
+ """Rebuilds a ``Message`` from raw ``RawMessageStreamEvent`` events."""
153
+
154
+ def __init__(self) -> None:
155
+ self._model: str | None = None
156
+ self._message_id: str | None = None
157
+ self._stop_reason: str | None = None
158
+ self._usage: dict[str, Any] = {}
159
+ self._blocks: dict[int, dict[str, Any]] = {}
160
+ self._partial_json: dict[int, list[str]] = {}
161
+
162
+ def on_event(self, event: Any, span: Span) -> None:
163
+ event_type = get_field(event, "type")
164
+ if event_type == "message_start":
165
+ self._on_message_start(get_field(event, "message"))
166
+ elif event_type == "content_block_start":
167
+ self._on_block_start(event)
168
+ elif event_type == "content_block_delta":
169
+ span.mark_first_token()
170
+ self._on_block_delta(event)
171
+ elif event_type == "message_delta":
172
+ stop_reason = get_field(event, "delta", "stop_reason")
173
+ if isinstance(stop_reason, str):
174
+ self._stop_reason = stop_reason
175
+ self._merge_usage(get_field(event, "usage"))
176
+
177
+ def _on_message_start(self, message: Any) -> None:
178
+ model = get_field(message, "model")
179
+ if isinstance(model, str):
180
+ self._model = model
181
+ message_id = get_field(message, "id")
182
+ if isinstance(message_id, str):
183
+ self._message_id = message_id
184
+ self._merge_usage(get_field(message, "usage"))
185
+
186
+ def _merge_usage(self, usage: Any) -> None:
187
+ """Merge usage counters; later events carry cumulative values."""
188
+ for key in (
189
+ "input_tokens",
190
+ "output_tokens",
191
+ "cache_read_input_tokens",
192
+ "cache_creation_input_tokens",
193
+ ):
194
+ value = as_int(get_field(usage, key))
195
+ if value is not None:
196
+ self._usage[key] = value
197
+
198
+ def _on_block_start(self, event: Any) -> None:
199
+ index = as_int(get_field(event, "index")) or 0
200
+ block = get_field(event, "content_block")
201
+ dumped = getattr(block, "model_dump", None)
202
+ if callable(dumped):
203
+ self._blocks[index] = dumped(mode="json", exclude_none=True)
204
+ elif isinstance(block, dict):
205
+ self._blocks[index] = dict(block)
206
+ else:
207
+ self._blocks[index] = {"type": get_field(block, "type")}
208
+
209
+ def _on_block_delta(self, event: Any) -> None:
210
+ index = as_int(get_field(event, "index")) or 0
211
+ block = self._blocks.setdefault(index, {"type": "text", "text": ""})
212
+ delta = get_field(event, "delta")
213
+ delta_type = get_field(delta, "type")
214
+ if delta_type == "text_delta":
215
+ block["text"] = block.get("text", "") + (get_field(delta, "text") or "")
216
+ elif delta_type == "thinking_delta":
217
+ block["thinking"] = block.get("thinking", "") + (get_field(delta, "thinking") or "")
218
+ elif delta_type == "input_json_delta":
219
+ self._partial_json.setdefault(index, []).append(get_field(delta, "partial_json") or "")
220
+
221
+ def finish(self, span: Span) -> None:
222
+ span.set_model(self._model, provider=PROVIDER)
223
+ if self._usage:
224
+ _record_usage(span, self._usage)
225
+ _record_message_metadata(span, self._message_id, self._stop_reason)
226
+ for index, fragments in self._partial_json.items():
227
+ self._blocks[index]["input"] = _parse_json("".join(fragments))
228
+ content = [self._blocks[index] for index in sorted(self._blocks)]
229
+ span.set_output({"role": "assistant", "content": content})
230
+
231
+
232
+ def _parse_json(raw: str) -> Any:
233
+ if not raw:
234
+ return {}
235
+ try:
236
+ return json.loads(raw)
237
+ except ValueError:
238
+ return raw
239
+
240
+
241
+ class _StreamManagerBase:
242
+ """Shared state for the ``messages.stream(...)`` manager wrappers.
243
+
244
+ The Anthropic helper builds a ``MessageStream`` around a raw event stream
245
+ stored in its ``_raw_stream`` attribute. We swap that raw stream for our
246
+ observing proxy, so the helper's own event processing (``text_stream``,
247
+ ``get_final_message()``...) keeps working untouched while every event
248
+ passes through the accumulator. If a future SDK renames the attribute,
249
+ the span still records request data and timing, just not the response.
250
+ """
251
+
252
+ def __init__(
253
+ self,
254
+ manager: Any,
255
+ endpoint: MessagesEndpoint,
256
+ get_client: ClientGetter,
257
+ request: dict[str, Any],
258
+ prompt: Any = None,
259
+ ) -> None:
260
+ self._manager = manager
261
+ self._endpoint = endpoint
262
+ self._get_client = get_client
263
+ self._request = {**request, "stream": True}
264
+ self._prompt = prompt
265
+ self._span: Span | None = None
266
+ self._proxy: SyncStreamProxy | AsyncStreamProxy | None = None
267
+
268
+ def __getattr__(self, name: str) -> Any:
269
+ if name == "_manager":
270
+ raise AttributeError(name)
271
+ return getattr(self._manager, name)
272
+
273
+ def _attach(self, message_stream: Any, *, is_async: bool) -> None:
274
+ span = self._span
275
+ if span is None:
276
+ return
277
+ try:
278
+ raw_stream = getattr(message_stream, "_raw_stream", None)
279
+ if raw_stream is None:
280
+ return
281
+ proxy_cls = AsyncStreamProxy if is_async else SyncStreamProxy
282
+ self._proxy = proxy_cls(raw_stream, self._endpoint.new_accumulator(), span)
283
+ message_stream._raw_stream = self._proxy
284
+ except Exception:
285
+ logger.debug("Could not attach to Anthropic message stream", exc_info=True)
286
+
287
+ def _finish(self, error: BaseException | None) -> None:
288
+ span = self._span
289
+ if span is None:
290
+ return
291
+ if self._proxy is not None:
292
+ self._proxy.finalize(error)
293
+ elif error is not None:
294
+ finish_with_error(span, error)
295
+ else:
296
+ span.end()
297
+
298
+
299
+ class _SyncStreamManager(_StreamManagerBase):
300
+ """Traced replacement for ``anthropic.lib.streaming.MessageStreamManager``."""
301
+
302
+ def __enter__(self) -> Any:
303
+ self._span = start_llm_span(self._get_client, self._endpoint, self._request, self._prompt)
304
+ try:
305
+ message_stream = self._manager.__enter__()
306
+ except BaseException as exc:
307
+ self._finish(exc)
308
+ raise
309
+ self._attach(message_stream, is_async=False)
310
+ return message_stream
311
+
312
+ def __exit__(
313
+ self,
314
+ exc_type: type[BaseException] | None,
315
+ exc: BaseException | None,
316
+ traceback: TracebackType | None,
317
+ ) -> None:
318
+ try:
319
+ self._manager.__exit__(exc_type, exc, traceback)
320
+ finally:
321
+ self._finish(None)
322
+
323
+
324
+ class _AsyncStreamManager(_StreamManagerBase):
325
+ """Traced replacement for ``anthropic.lib.streaming.AsyncMessageStreamManager``."""
326
+
327
+ async def __aenter__(self) -> Any:
328
+ self._span = start_llm_span(self._get_client, self._endpoint, self._request, self._prompt)
329
+ try:
330
+ message_stream = await self._manager.__aenter__()
331
+ except BaseException as exc:
332
+ self._finish(exc)
333
+ raise
334
+ self._attach(message_stream, is_async=True)
335
+ return message_stream
336
+
337
+ async def __aexit__(
338
+ self,
339
+ exc_type: type[BaseException] | None,
340
+ exc: BaseException | None,
341
+ traceback: TracebackType | None,
342
+ ) -> None:
343
+ try:
344
+ await self._manager.__aexit__(exc_type, exc, traceback)
345
+ finally:
346
+ self._finish(None)
347
+
348
+
349
+ __all__ = ["wrap_anthropic"]
@@ -0,0 +1,356 @@
1
+ """Instrumentation for the official ``openai`` Python SDK.
2
+
3
+ :func:`wrap_openai` patches ``chat.completions.create`` and
4
+ ``responses.create`` on one client instance (sync or async). Streaming calls
5
+ are supported; the providers' ``.stream()`` helpers go through ``create`` and
6
+ are therefore traced too.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import TYPE_CHECKING, Any, TypeVar
13
+
14
+ from .._span import Span
15
+ from ._common import (
16
+ ClientGetter,
17
+ Endpoint,
18
+ StreamAccumulator,
19
+ as_int,
20
+ client_getter,
21
+ get_field,
22
+ patch_method,
23
+ wrap_async_method,
24
+ wrap_sync_method,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from .._client import Spanlight
29
+
30
+ logger = logging.getLogger("spanlight")
31
+
32
+ ClientT = TypeVar("ClientT")
33
+
34
+
35
+ def wrap_openai(
36
+ client: ClientT, *, spanlight: Spanlight | None = None, provider: str = "openai"
37
+ ) -> ClientT:
38
+ """Instrument an ``OpenAI``/``AsyncOpenAI`` client (or Azure variant) in place.
39
+
40
+ Every ``chat.completions.create`` and ``responses.create`` call becomes an
41
+ ``llm`` span with the model, token usage, time-to-first-token (streams),
42
+ sanitized request parameters and the response. Errors are recorded and
43
+ re-raised unchanged. Wrapping the same client twice is harmless.
44
+
45
+ Args:
46
+ client: The OpenAI client to instrument.
47
+ spanlight: Spanlight client to report to; defaults to the global client at call time.
48
+ provider: Provider name recorded on spans. Override it when pointing the
49
+ OpenAI SDK at a compatible API (e.g. ``"openrouter"``).
50
+
51
+ Returns:
52
+ The same client object, for convenient chaining.
53
+ """
54
+ try:
55
+ _instrument(client, spanlight, provider)
56
+ except Exception:
57
+ logger.warning("Spanlight: could not instrument OpenAI client", exc_info=True)
58
+ return client
59
+
60
+
61
+ def _instrument(client: Any, spanlight: Spanlight | None, provider: str) -> None:
62
+ from openai import AsyncStream, Stream
63
+ from openai.resources.chat.completions import AsyncCompletions
64
+ from openai.resources.responses import AsyncResponses
65
+
66
+ get_client = client_getter(spanlight)
67
+ stream_types = (Stream, AsyncStream)
68
+ endpoints: list[tuple[Any, Endpoint]] = [
69
+ (client.chat.completions, ChatCompletionsEndpoint(provider, stream_types)),
70
+ (client.responses, ResponsesEndpoint(provider, stream_types)),
71
+ ]
72
+ async_resources = (AsyncCompletions, AsyncResponses)
73
+ for resource, endpoint in endpoints:
74
+ _patch_create(
75
+ resource, endpoint, get_client, is_async=isinstance(resource, async_resources)
76
+ )
77
+
78
+
79
+ def _patch_create(
80
+ resource: Any,
81
+ endpoint: Endpoint,
82
+ get_client: ClientGetter,
83
+ *,
84
+ is_async: bool,
85
+ ) -> None:
86
+ wrap = wrap_async_method if is_async else wrap_sync_method
87
+ patch_method(resource, "create", lambda original: wrap(original, endpoint, get_client))
88
+
89
+
90
+ # ---------------------------------------------------------------------- #
91
+ # Chat Completions
92
+ # ---------------------------------------------------------------------- #
93
+
94
+
95
+ class ChatCompletionsEndpoint(Endpoint):
96
+ """``chat.completions.create``."""
97
+
98
+ operation = "chat"
99
+
100
+ def __init__(self, provider: str, stream_types: tuple[type, ...]) -> None:
101
+ super().__init__(provider)
102
+ self._stream_types = stream_types
103
+
104
+ def is_stream(self, result: Any) -> bool:
105
+ return isinstance(result, self._stream_types)
106
+
107
+ def new_accumulator(self) -> StreamAccumulator:
108
+ return _ChatStreamAccumulator(self.provider)
109
+
110
+ def record_response(self, span: Span, response: Any) -> None:
111
+ span.set_model(get_field(response, "model"), provider=self.provider)
112
+ _record_chat_usage(span, get_field(response, "usage"))
113
+ _record_response_id(span, response)
114
+
115
+ choices = get_field(response, "choices") or []
116
+ messages = [get_field(choice, "message") for choice in choices]
117
+ finish_reasons = [get_field(choice, "finish_reason") for choice in choices]
118
+ if finish_reasons:
119
+ span.set_attribute("response.finish_reasons", finish_reasons)
120
+ span.set_finish_reason(_first_str(finish_reasons))
121
+ span.set_output(messages[0] if len(messages) == 1 else messages)
122
+
123
+
124
+ def _first_str(values: list[Any]) -> str | None:
125
+ """Return the first value if it is a string (the first choice's reason)."""
126
+ return values[0] if values and isinstance(values[0], str) else None
127
+
128
+
129
+ def _record_chat_usage(span: Span, usage: Any) -> None:
130
+ if usage is None:
131
+ return
132
+ span.set_usage(
133
+ input_tokens=as_int(get_field(usage, "prompt_tokens")),
134
+ output_tokens=as_int(get_field(usage, "completion_tokens")),
135
+ cached_tokens=as_int(get_field(usage, "prompt_tokens_details", "cached_tokens")),
136
+ )
137
+ reasoning = as_int(get_field(usage, "completion_tokens_details", "reasoning_tokens"))
138
+ if reasoning:
139
+ span.set_attribute("usage.reasoning_tokens", reasoning)
140
+
141
+
142
+ def _record_response_id(span: Span, response: Any) -> None:
143
+ response_id = get_field(response, "id")
144
+ if isinstance(response_id, str):
145
+ span.set_attribute("response.id", response_id)
146
+
147
+
148
+ class _ChoiceState:
149
+ """Accumulated content of one streamed choice."""
150
+
151
+ def __init__(self) -> None:
152
+ self.role = "assistant"
153
+ self.content: list[str] = []
154
+ self.refusal: list[str] = []
155
+ self.tool_calls: dict[int, dict[str, Any]] = {}
156
+ self.finish_reason: str | None = None
157
+
158
+ def apply(self, delta: Any) -> bool:
159
+ """Merge one delta; return whether it carried generated output."""
160
+ produced = False
161
+ role = get_field(delta, "role")
162
+ if isinstance(role, str):
163
+ self.role = role
164
+ content = get_field(delta, "content")
165
+ if isinstance(content, str) and content:
166
+ self.content.append(content)
167
+ produced = True
168
+ refusal = get_field(delta, "refusal")
169
+ if isinstance(refusal, str) and refusal:
170
+ self.refusal.append(refusal)
171
+ produced = True
172
+ for tool_call in get_field(delta, "tool_calls") or []:
173
+ self._apply_tool_call(tool_call)
174
+ produced = True
175
+ return produced
176
+
177
+ def _apply_tool_call(self, tool_call: Any) -> None:
178
+ index = as_int(get_field(tool_call, "index")) or 0
179
+ state = self.tool_calls.setdefault(
180
+ index,
181
+ {"id": None, "type": "function", "function": {"name": "", "arguments": ""}},
182
+ )
183
+ call_id = get_field(tool_call, "id")
184
+ if isinstance(call_id, str):
185
+ state["id"] = call_id
186
+ name = get_field(tool_call, "function", "name")
187
+ if isinstance(name, str):
188
+ state["function"]["name"] += name
189
+ arguments = get_field(tool_call, "function", "arguments")
190
+ if isinstance(arguments, str):
191
+ state["function"]["arguments"] += arguments
192
+
193
+ def to_message(self) -> dict[str, Any]:
194
+ message: dict[str, Any] = {"role": self.role, "content": "".join(self.content) or None}
195
+ if self.refusal:
196
+ message["refusal"] = "".join(self.refusal)
197
+ if self.tool_calls:
198
+ message["tool_calls"] = [self.tool_calls[index] for index in sorted(self.tool_calls)]
199
+ return message
200
+
201
+
202
+ class _ChatStreamAccumulator(StreamAccumulator):
203
+ """Rebuilds a chat completion from ``ChatCompletionChunk`` events."""
204
+
205
+ def __init__(self, provider: str) -> None:
206
+ self._provider = provider
207
+ self._model: str | None = None
208
+ self._response_id: str | None = None
209
+ self._usage: Any = None
210
+ self._choices: dict[int, _ChoiceState] = {}
211
+
212
+ def on_event(self, event: Any, span: Span) -> None:
213
+ model = get_field(event, "model")
214
+ if isinstance(model, str) and model:
215
+ self._model = model
216
+ response_id = get_field(event, "id")
217
+ if isinstance(response_id, str):
218
+ self._response_id = response_id
219
+ usage = get_field(event, "usage")
220
+ if usage is not None:
221
+ self._usage = usage
222
+
223
+ for choice in get_field(event, "choices") or []:
224
+ index = as_int(get_field(choice, "index")) or 0
225
+ state = self._choices.setdefault(index, _ChoiceState())
226
+ if state.apply(get_field(choice, "delta")):
227
+ span.mark_first_token()
228
+ finish_reason = get_field(choice, "finish_reason")
229
+ if isinstance(finish_reason, str):
230
+ state.finish_reason = finish_reason
231
+
232
+ def finish(self, span: Span) -> None:
233
+ span.set_model(self._model, provider=self._provider)
234
+ _record_chat_usage(span, self._usage)
235
+ if self._usage is None:
236
+ span.set_attribute("usage.missing_reason", "stream_options.include_usage not set")
237
+ if self._response_id:
238
+ span.set_attribute("response.id", self._response_id)
239
+
240
+ states = [self._choices[index] for index in sorted(self._choices)]
241
+ if not states:
242
+ return
243
+ finish_reasons = [state.finish_reason for state in states]
244
+ span.set_attribute("response.finish_reasons", finish_reasons)
245
+ span.set_finish_reason(_first_str(finish_reasons[:1]))
246
+ messages = [state.to_message() for state in states]
247
+ span.set_output(messages[0] if len(messages) == 1 else messages)
248
+
249
+
250
+ # ---------------------------------------------------------------------- #
251
+ # Responses API
252
+ # ---------------------------------------------------------------------- #
253
+
254
+
255
+ class ResponsesEndpoint(Endpoint):
256
+ """``responses.create``."""
257
+
258
+ operation = "responses"
259
+
260
+ def __init__(self, provider: str, stream_types: tuple[type, ...]) -> None:
261
+ super().__init__(provider)
262
+ self._stream_types = stream_types
263
+
264
+ def is_stream(self, result: Any) -> bool:
265
+ return isinstance(result, self._stream_types)
266
+
267
+ def new_accumulator(self) -> StreamAccumulator:
268
+ return _ResponsesStreamAccumulator(self)
269
+
270
+ def record_response(self, span: Span, response: Any) -> None:
271
+ span.set_model(get_field(response, "model"), provider=self.provider)
272
+ _record_response_id(span, response)
273
+
274
+ usage = get_field(response, "usage")
275
+ if usage is not None:
276
+ span.set_usage(
277
+ input_tokens=as_int(get_field(usage, "input_tokens")),
278
+ output_tokens=as_int(get_field(usage, "output_tokens")),
279
+ cached_tokens=as_int(get_field(usage, "input_tokens_details", "cached_tokens")),
280
+ )
281
+ reasoning = as_int(get_field(usage, "output_tokens_details", "reasoning_tokens"))
282
+ if reasoning:
283
+ span.set_attribute("usage.reasoning_tokens", reasoning)
284
+
285
+ status = get_field(response, "status")
286
+ if isinstance(status, str):
287
+ span.set_attribute("response.status", status)
288
+ if status == "failed":
289
+ message = get_field(response, "error", "message")
290
+ span.record_error(message if isinstance(message, str) else "response failed")
291
+
292
+ span.set_output(_responses_output(response))
293
+
294
+
295
+ def _responses_output(response: Any) -> dict[str, Any]:
296
+ """Summarize a ``Response`` as an assistant message plus any non-text items."""
297
+ text_parts: list[str] = []
298
+ other_items: list[Any] = []
299
+ for item in get_field(response, "output") or []:
300
+ if get_field(item, "type") != "message":
301
+ other_items.append(item)
302
+ continue
303
+ for part in get_field(item, "content") or []:
304
+ text = get_field(part, "text")
305
+ if isinstance(text, str):
306
+ text_parts.append(text)
307
+
308
+ output: dict[str, Any] = {"role": "assistant", "content": "".join(text_parts) or None}
309
+ if other_items:
310
+ output["items"] = other_items
311
+ return output
312
+
313
+
314
+ class _ResponsesStreamAccumulator(StreamAccumulator):
315
+ """Tracks Responses API stream events; the final event carries the full response."""
316
+
317
+ def __init__(self, endpoint: ResponsesEndpoint) -> None:
318
+ self._endpoint = endpoint
319
+ self._final_response: Any = None
320
+ self._text: list[str] = []
321
+ self._model: str | None = None
322
+ self._error: str | None = None
323
+
324
+ def on_event(self, event: Any, span: Span) -> None:
325
+ event_type = get_field(event, "type")
326
+ if not isinstance(event_type, str):
327
+ return
328
+ if event_type.endswith(".delta"):
329
+ span.mark_first_token()
330
+ if event_type == "response.output_text.delta":
331
+ delta = get_field(event, "delta")
332
+ if isinstance(delta, str):
333
+ self._text.append(delta)
334
+
335
+ response = get_field(event, "response")
336
+ if response is not None:
337
+ model = get_field(response, "model")
338
+ if isinstance(model, str):
339
+ self._model = model
340
+ if event_type in {"response.completed", "response.incomplete", "response.failed"}:
341
+ self._final_response = response
342
+ if event_type == "error":
343
+ message = get_field(event, "message")
344
+ self._error = message if isinstance(message, str) else "stream error"
345
+
346
+ def finish(self, span: Span) -> None:
347
+ if self._final_response is not None:
348
+ self._endpoint.record_response(span, self._final_response)
349
+ else:
350
+ span.set_model(self._model, provider=self._endpoint.provider)
351
+ span.set_output({"role": "assistant", "content": "".join(self._text) or None})
352
+ if self._error is not None:
353
+ span.record_error(self._error)
354
+
355
+
356
+ __all__ = ["wrap_openai"]