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.
- spanlight/__init__.py +59 -0
- spanlight/_client.py +459 -0
- spanlight/_config.py +138 -0
- spanlight/_context.py +79 -0
- spanlight/_decorator.py +345 -0
- spanlight/_experiments.py +256 -0
- spanlight/_experiments_api.py +182 -0
- spanlight/_experiments_parse.py +68 -0
- spanlight/_experiments_report.py +100 -0
- spanlight/_experiments_runs.py +122 -0
- spanlight/_experiments_setup.py +85 -0
- spanlight/_exporter.py +462 -0
- spanlight/_globals.py +198 -0
- spanlight/_ids.py +45 -0
- spanlight/_prompt_client.py +241 -0
- spanlight/_prompts.py +247 -0
- spanlight/_request_hash.py +58 -0
- spanlight/_scores.py +57 -0
- spanlight/_serialize.py +126 -0
- spanlight/_span.py +337 -0
- spanlight/_templates.py +238 -0
- spanlight/_version.py +3 -0
- spanlight/integrations/__init__.py +5 -0
- spanlight/integrations/_common.py +422 -0
- spanlight/integrations/anthropic.py +349 -0
- spanlight/integrations/openai.py +356 -0
- spanlight/py.typed +0 -0
- spanlight-0.1.0.dist-info/METADATA +447 -0
- spanlight-0.1.0.dist-info/RECORD +31 -0
- spanlight-0.1.0.dist-info/WHEEL +4 -0
- spanlight-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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"]
|