coding-sh 0.1.3__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.
- coding_sh/__init__.py +33 -0
- coding_sh/_client.py +418 -0
- coding_sh/_gateway.py +156 -0
- coding_sh/_gateway_table.py +117 -0
- coding_sh/_llm.py +323 -0
- coding_sh/_providers.py +549 -0
- coding_sh/_trace.py +641 -0
- coding_sh/otel.py +144 -0
- coding_sh/py.typed +0 -0
- coding_sh-0.1.3.dist-info/METADATA +138 -0
- coding_sh-0.1.3.dist-info/RECORD +14 -0
- coding_sh-0.1.3.dist-info/WHEEL +5 -0
- coding_sh-0.1.3.dist-info/licenses/LICENSE +21 -0
- coding_sh-0.1.3.dist-info/top_level.txt +1 -0
coding_sh/_providers.py
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
"""observe(client): wrap OpenAI / AsyncOpenAI / Anthropic / AsyncAnthropic clients in place.
|
|
2
|
+
|
|
3
|
+
Wrapped methods:
|
|
4
|
+
OpenAI chat.completions.create (+ parse), responses.create sync and stream
|
|
5
|
+
Anthropic messages.create (sync and stream=True), messages.stream (the helper manager)
|
|
6
|
+
|
|
7
|
+
Wrapping is idempotent and patches the client's resource instances, so the client object, its
|
|
8
|
+
types and every other method stay exactly as they were. Clients made later with
|
|
9
|
+
`client.with_options(...)` are new objects: observe() them too.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import functools
|
|
15
|
+
import inspect
|
|
16
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from ._gateway import capturing, hook_responses
|
|
19
|
+
from ._llm import AsyncStream, LLMCall, SyncStream, content_text, g, given, last_user_text, _num
|
|
20
|
+
from ._trace import enabled
|
|
21
|
+
|
|
22
|
+
_MARK = "__coding_sh_wrapped__"
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------- usage
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def openai_usage(u: Any) -> Optional[Dict[str, float]]:
|
|
28
|
+
if u is None:
|
|
29
|
+
return None
|
|
30
|
+
total = _num(g(u, "prompt_tokens"))
|
|
31
|
+
if total is None:
|
|
32
|
+
total = _num(g(u, "input_tokens"))
|
|
33
|
+
out = _num(g(u, "completion_tokens"))
|
|
34
|
+
if out is None:
|
|
35
|
+
out = _num(g(u, "output_tokens"))
|
|
36
|
+
details = g(u, "prompt_tokens_details") or g(u, "input_tokens_details")
|
|
37
|
+
cached = _num(g(details, "cached_tokens")) or 0
|
|
38
|
+
usage: Dict[str, float] = {}
|
|
39
|
+
if total is not None:
|
|
40
|
+
usage["inputTokens"] = max(0, total - cached) # uncached input, the coding.sh convention
|
|
41
|
+
if out is not None:
|
|
42
|
+
usage["outputTokens"] = out
|
|
43
|
+
if cached:
|
|
44
|
+
usage["cacheReadTokens"] = cached
|
|
45
|
+
return usage or None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def anthropic_usage(u: Any, into: Optional[Dict[str, float]] = None) -> Optional[Dict[str, float]]:
|
|
49
|
+
if u is None:
|
|
50
|
+
return into
|
|
51
|
+
usage: Dict[str, float] = dict(into or {})
|
|
52
|
+
for src, dst in (
|
|
53
|
+
("input_tokens", "inputTokens"),
|
|
54
|
+
("output_tokens", "outputTokens"),
|
|
55
|
+
("cache_read_input_tokens", "cacheReadTokens"),
|
|
56
|
+
("cache_creation_input_tokens", "cacheWriteTokens"),
|
|
57
|
+
):
|
|
58
|
+
v = _num(g(u, src))
|
|
59
|
+
if v is None:
|
|
60
|
+
continue
|
|
61
|
+
if dst.startswith("cache") and not v:
|
|
62
|
+
usage.pop(dst, None)
|
|
63
|
+
continue
|
|
64
|
+
usage[dst] = v
|
|
65
|
+
return usage or None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------- OpenAI chat
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _chat_response(call: LLMCall, resp: Any) -> None:
|
|
72
|
+
m = g(resp, "model")
|
|
73
|
+
if isinstance(m, str):
|
|
74
|
+
call.response_model = m
|
|
75
|
+
call.set_response_id(g(resp, "id"))
|
|
76
|
+
call.usage = openai_usage(g(resp, "usage"))
|
|
77
|
+
choices = g(resp, "choices") or []
|
|
78
|
+
if choices:
|
|
79
|
+
ch = choices[0]
|
|
80
|
+
msg = g(ch, "message")
|
|
81
|
+
text = content_text(g(msg, "content")) or content_text(g(msg, "refusal"))
|
|
82
|
+
if text:
|
|
83
|
+
call.text_parts = [text]
|
|
84
|
+
fr = g(ch, "finish_reason")
|
|
85
|
+
if isinstance(fr, str):
|
|
86
|
+
call.stop_reason = fr
|
|
87
|
+
for i, tc in enumerate(g(msg, "tool_calls") or []):
|
|
88
|
+
fn = g(tc, "function")
|
|
89
|
+
call.add_tool_call(i, g(tc, "id"), g(fn, "name"), g(fn, "arguments"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _make_chat_chunk_observer(swallow_usage: bool) -> Callable[[LLMCall, Any], bool]:
|
|
93
|
+
def observe(call: LLMCall, chunk: Any) -> bool:
|
|
94
|
+
m = g(chunk, "model")
|
|
95
|
+
if isinstance(m, str) and m:
|
|
96
|
+
call.response_model = m
|
|
97
|
+
call.set_response_id(g(chunk, "id"))
|
|
98
|
+
usage = g(chunk, "usage")
|
|
99
|
+
if usage is not None:
|
|
100
|
+
call.usage = openai_usage(usage)
|
|
101
|
+
choices = g(chunk, "choices") or []
|
|
102
|
+
for ch in choices:
|
|
103
|
+
if (g(ch, "index", 0) or 0) != 0:
|
|
104
|
+
continue
|
|
105
|
+
delta = g(ch, "delta")
|
|
106
|
+
c = g(delta, "content")
|
|
107
|
+
if isinstance(c, str) and c:
|
|
108
|
+
call.mark_first_token()
|
|
109
|
+
call.text_parts.append(c)
|
|
110
|
+
for tc in g(delta, "tool_calls") or []:
|
|
111
|
+
call.mark_first_token()
|
|
112
|
+
fn = g(tc, "function")
|
|
113
|
+
call.add_tool_call(g(tc, "index", 0), g(tc, "id"), g(fn, "name"), args_delta=g(fn, "arguments"))
|
|
114
|
+
fr = g(ch, "finish_reason")
|
|
115
|
+
if isinstance(fr, str) and fr:
|
|
116
|
+
call.stop_reason = fr
|
|
117
|
+
# The usage-only final chunk exists only because we asked for it: hide it.
|
|
118
|
+
return swallow_usage and not choices and usage is not None
|
|
119
|
+
|
|
120
|
+
return observe
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------- OpenAI responses
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _responses_response(call: LLMCall, resp: Any) -> None:
|
|
127
|
+
m = g(resp, "model")
|
|
128
|
+
if isinstance(m, str):
|
|
129
|
+
call.response_model = m
|
|
130
|
+
call.set_response_id(g(resp, "id"))
|
|
131
|
+
u = openai_usage(g(resp, "usage"))
|
|
132
|
+
if u:
|
|
133
|
+
call.usage = u
|
|
134
|
+
text = None
|
|
135
|
+
try:
|
|
136
|
+
t = getattr(resp, "output_text", None) # SDK convenience property
|
|
137
|
+
text = t if isinstance(t, str) and t else None
|
|
138
|
+
except Exception:
|
|
139
|
+
text = None
|
|
140
|
+
parts = []
|
|
141
|
+
for i, item in enumerate(g(resp, "output") or []):
|
|
142
|
+
typ = g(item, "type")
|
|
143
|
+
if typ == "message" and text is None:
|
|
144
|
+
t = content_text(g(item, "content"))
|
|
145
|
+
if t:
|
|
146
|
+
parts.append(t)
|
|
147
|
+
elif typ == "function_call":
|
|
148
|
+
call.add_tool_call(i, g(item, "call_id") or g(item, "id"), g(item, "name"), g(item, "arguments"))
|
|
149
|
+
if text is None and parts:
|
|
150
|
+
text = "".join(parts)
|
|
151
|
+
if text:
|
|
152
|
+
call.text_parts = [text]
|
|
153
|
+
reason = g(g(resp, "incomplete_details"), "reason")
|
|
154
|
+
status = g(resp, "status")
|
|
155
|
+
if isinstance(reason, str) and reason:
|
|
156
|
+
call.stop_reason = reason
|
|
157
|
+
elif isinstance(status, str) and status:
|
|
158
|
+
call.stop_reason = status
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _responses_event(call: LLMCall, ev: Any) -> bool:
|
|
162
|
+
typ = g(ev, "type")
|
|
163
|
+
if typ == "response.output_text.delta":
|
|
164
|
+
d = g(ev, "delta")
|
|
165
|
+
if isinstance(d, str) and d:
|
|
166
|
+
call.mark_first_token()
|
|
167
|
+
call.text_parts.append(d)
|
|
168
|
+
elif typ in ("response.function_call_arguments.delta", "response.output_item.added"):
|
|
169
|
+
call.mark_first_token()
|
|
170
|
+
elif typ in ("response.created", "response.in_progress"):
|
|
171
|
+
m = g(g(ev, "response"), "model")
|
|
172
|
+
if isinstance(m, str):
|
|
173
|
+
call.response_model = m
|
|
174
|
+
call.set_response_id(g(g(ev, "response"), "id"))
|
|
175
|
+
elif typ in ("response.completed", "response.incomplete", "response.failed"):
|
|
176
|
+
streamed = list(call.text_parts)
|
|
177
|
+
_responses_response(call, g(ev, "response"))
|
|
178
|
+
if not call.text_parts and streamed:
|
|
179
|
+
call.text_parts = streamed
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------- Anthropic
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _anthropic_message(call: LLMCall, msg: Any) -> None:
|
|
187
|
+
if msg is None:
|
|
188
|
+
return
|
|
189
|
+
m = g(msg, "model")
|
|
190
|
+
if isinstance(m, str):
|
|
191
|
+
call.response_model = m
|
|
192
|
+
call.set_response_id(g(msg, "id"))
|
|
193
|
+
u = anthropic_usage(g(msg, "usage"))
|
|
194
|
+
if u:
|
|
195
|
+
call.usage = u
|
|
196
|
+
parts = []
|
|
197
|
+
for i, block in enumerate(g(msg, "content") or []):
|
|
198
|
+
typ = g(block, "type")
|
|
199
|
+
if typ == "text":
|
|
200
|
+
t = g(block, "text")
|
|
201
|
+
if isinstance(t, str) and t:
|
|
202
|
+
parts.append(t)
|
|
203
|
+
elif typ in ("tool_use", "server_tool_use"):
|
|
204
|
+
call.add_tool_call(i, g(block, "id"), g(block, "name"), g(block, "input"))
|
|
205
|
+
if parts:
|
|
206
|
+
call.text_parts = ["".join(parts)]
|
|
207
|
+
sr = g(msg, "stop_reason")
|
|
208
|
+
if isinstance(sr, str):
|
|
209
|
+
call.stop_reason = sr
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _anthropic_event(call: LLMCall, ev: Any) -> bool:
|
|
213
|
+
typ = g(ev, "type")
|
|
214
|
+
if typ == "message_start":
|
|
215
|
+
msg = g(ev, "message")
|
|
216
|
+
m = g(msg, "model")
|
|
217
|
+
if isinstance(m, str):
|
|
218
|
+
call.response_model = m
|
|
219
|
+
call.set_response_id(g(msg, "id"))
|
|
220
|
+
call.usage = anthropic_usage(g(msg, "usage"), call.usage)
|
|
221
|
+
elif typ == "content_block_start":
|
|
222
|
+
block = g(ev, "content_block")
|
|
223
|
+
if g(block, "type") in ("tool_use", "server_tool_use"):
|
|
224
|
+
call.mark_first_token()
|
|
225
|
+
call.add_tool_call(g(ev, "index", 0), g(block, "id"), g(block, "name"))
|
|
226
|
+
t = g(block, "text")
|
|
227
|
+
if isinstance(t, str) and t:
|
|
228
|
+
call.mark_first_token()
|
|
229
|
+
call.text_parts.append(t)
|
|
230
|
+
elif typ == "content_block_delta":
|
|
231
|
+
d = g(ev, "delta")
|
|
232
|
+
call.mark_first_token()
|
|
233
|
+
if g(d, "type") == "text_delta":
|
|
234
|
+
t = g(d, "text")
|
|
235
|
+
if isinstance(t, str):
|
|
236
|
+
call.text_parts.append(t)
|
|
237
|
+
elif g(d, "type") == "input_json_delta" and g(ev, "index", 0) in call.tool_calls:
|
|
238
|
+
call.add_tool_call(g(ev, "index", 0), args_delta=g(d, "partial_json"))
|
|
239
|
+
elif typ == "message_delta":
|
|
240
|
+
sr = g(g(ev, "delta"), "stop_reason")
|
|
241
|
+
if isinstance(sr, str):
|
|
242
|
+
call.stop_reason = sr
|
|
243
|
+
call.usage = anthropic_usage(g(ev, "usage"), call.usage)
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class _AnthropicStreamView:
|
|
248
|
+
"""What `with client.messages.stream(...) as s` yields: the real MessageStream, observed."""
|
|
249
|
+
|
|
250
|
+
def __init__(self, stream: Any, call: LLMCall) -> None:
|
|
251
|
+
self._cs_stream = stream
|
|
252
|
+
self._cs_call = call
|
|
253
|
+
|
|
254
|
+
def __iter__(self) -> Any:
|
|
255
|
+
for ev in self._cs_stream:
|
|
256
|
+
if g(ev, "type") in ("text", "content_block_delta", "input_json"):
|
|
257
|
+
self._cs_call.mark_first_token()
|
|
258
|
+
yield ev
|
|
259
|
+
|
|
260
|
+
async def __aiter__(self) -> Any:
|
|
261
|
+
async for ev in self._cs_stream:
|
|
262
|
+
if g(ev, "type") in ("text", "content_block_delta", "input_json"):
|
|
263
|
+
self._cs_call.mark_first_token()
|
|
264
|
+
yield ev
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def text_stream(self) -> Any:
|
|
268
|
+
inner = self._cs_stream.text_stream
|
|
269
|
+
call = self._cs_call
|
|
270
|
+
if hasattr(inner, "__aiter__"):
|
|
271
|
+
|
|
272
|
+
async def agen() -> Any:
|
|
273
|
+
async for t in inner:
|
|
274
|
+
call.mark_first_token()
|
|
275
|
+
yield t
|
|
276
|
+
|
|
277
|
+
return agen()
|
|
278
|
+
|
|
279
|
+
def gen() -> Any:
|
|
280
|
+
for t in inner:
|
|
281
|
+
call.mark_first_token()
|
|
282
|
+
yield t
|
|
283
|
+
|
|
284
|
+
return gen()
|
|
285
|
+
|
|
286
|
+
def __getattr__(self, name: str) -> Any:
|
|
287
|
+
if name.startswith("_cs_"):
|
|
288
|
+
raise AttributeError(name)
|
|
289
|
+
return getattr(self._cs_stream, name)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class _AnthropicStreamManager:
|
|
293
|
+
"""Proxy over MessageStreamManager / AsyncMessageStreamManager."""
|
|
294
|
+
|
|
295
|
+
def __init__(self, manager: Any, make_call: Callable[[], LLMCall]) -> None:
|
|
296
|
+
self._cs_manager = manager
|
|
297
|
+
self._cs_make_call = make_call
|
|
298
|
+
self._cs_call: Optional[LLMCall] = None
|
|
299
|
+
self._cs_view: Optional[_AnthropicStreamView] = None
|
|
300
|
+
|
|
301
|
+
def _cs_finish(self, exc: Any) -> None:
|
|
302
|
+
call = self._cs_call
|
|
303
|
+
if call is None:
|
|
304
|
+
return
|
|
305
|
+
try:
|
|
306
|
+
view = self._cs_view
|
|
307
|
+
if view is not None:
|
|
308
|
+
snap = None
|
|
309
|
+
try:
|
|
310
|
+
snap = view._cs_stream.current_message_snapshot
|
|
311
|
+
except Exception:
|
|
312
|
+
snap = None
|
|
313
|
+
_anthropic_message(call, snap)
|
|
314
|
+
call.end(exc if isinstance(exc, Exception) else None)
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
def __enter__(self) -> Any:
|
|
319
|
+
call = self._cs_call = self._cs_make_call().start()
|
|
320
|
+
try:
|
|
321
|
+
with capturing(call):
|
|
322
|
+
stream = self._cs_manager.__enter__()
|
|
323
|
+
except Exception as e:
|
|
324
|
+
call.end(e)
|
|
325
|
+
raise
|
|
326
|
+
self._cs_view = _AnthropicStreamView(stream, call)
|
|
327
|
+
return self._cs_view
|
|
328
|
+
|
|
329
|
+
def __exit__(self, *exc: Any) -> Any:
|
|
330
|
+
try:
|
|
331
|
+
return self._cs_manager.__exit__(*exc)
|
|
332
|
+
finally:
|
|
333
|
+
self._cs_finish(exc[1] if len(exc) > 1 else None)
|
|
334
|
+
|
|
335
|
+
async def __aenter__(self) -> Any:
|
|
336
|
+
call = self._cs_call = self._cs_make_call().start()
|
|
337
|
+
try:
|
|
338
|
+
with capturing(call):
|
|
339
|
+
stream = await self._cs_manager.__aenter__()
|
|
340
|
+
except Exception as e:
|
|
341
|
+
call.end(e)
|
|
342
|
+
raise
|
|
343
|
+
self._cs_view = _AnthropicStreamView(stream, call)
|
|
344
|
+
return self._cs_view
|
|
345
|
+
|
|
346
|
+
async def __aexit__(self, *exc: Any) -> Any:
|
|
347
|
+
try:
|
|
348
|
+
return await self._cs_manager.__aexit__(*exc)
|
|
349
|
+
finally:
|
|
350
|
+
self._cs_finish(exc[1] if len(exc) > 1 else None)
|
|
351
|
+
|
|
352
|
+
def __getattr__(self, name: str) -> Any:
|
|
353
|
+
if name.startswith("_cs_"):
|
|
354
|
+
raise AttributeError(name)
|
|
355
|
+
return getattr(self._cs_manager, name)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------- wrapping
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
Prepared = Tuple[LLMCall, Dict[str, Any], Callable[[LLMCall, Any], bool], Callable[[LLMCall, Any], None]]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def base_url_of(client: Any) -> Optional[str]:
|
|
365
|
+
"""The client's base URL, as a string (which gateway, if any, it talks to)."""
|
|
366
|
+
try:
|
|
367
|
+
url = getattr(client, "base_url", None)
|
|
368
|
+
return str(url) if url else None
|
|
369
|
+
except Exception:
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _prepare(provider: str, kind: str, kwargs: Dict[str, Any], base_url: Optional[str] = None) -> Prepared:
|
|
374
|
+
model = kwargs.get("model")
|
|
375
|
+
if kind == "responses":
|
|
376
|
+
source = kwargs.get("input")
|
|
377
|
+
else:
|
|
378
|
+
source = kwargs.get("messages")
|
|
379
|
+
call = LLMCall(provider, model, lambda: last_user_text(source))
|
|
380
|
+
call.base_url = base_url
|
|
381
|
+
observe: Callable[[LLMCall, Any], bool]
|
|
382
|
+
finish: Callable[[LLMCall, Any], None]
|
|
383
|
+
if kind == "chat":
|
|
384
|
+
swallow = False
|
|
385
|
+
if kwargs.get("stream") is True and not given(kwargs.get("stream_options")):
|
|
386
|
+
kwargs = dict(kwargs)
|
|
387
|
+
kwargs["stream_options"] = {"include_usage": True}
|
|
388
|
+
swallow = True
|
|
389
|
+
observe, finish = _make_chat_chunk_observer(swallow), _chat_response
|
|
390
|
+
elif kind == "responses":
|
|
391
|
+
observe, finish = _responses_event, _responses_response
|
|
392
|
+
else:
|
|
393
|
+
observe, finish = _anthropic_event, _anthropic_message
|
|
394
|
+
return call, kwargs, observe, finish
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _handle(result: Any, call: LLMCall, call_kwargs: Dict[str, Any], observe: Any, finish: Any) -> Any:
|
|
398
|
+
if call_kwargs.get("stream") is True:
|
|
399
|
+
if hasattr(result, "__aiter__") and not hasattr(result, "__iter__"):
|
|
400
|
+
return AsyncStream(result, call, observe)
|
|
401
|
+
if hasattr(result, "__iter__"):
|
|
402
|
+
return SyncStream(result, call, observe)
|
|
403
|
+
try:
|
|
404
|
+
finish(call, result)
|
|
405
|
+
except Exception:
|
|
406
|
+
pass
|
|
407
|
+
call.end(None)
|
|
408
|
+
return result
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
async def _ahandle(awaitable: Any, call: LLMCall, call_kwargs: Dict[str, Any], observe: Any, finish: Any) -> Any:
|
|
412
|
+
try:
|
|
413
|
+
with capturing(call):
|
|
414
|
+
result = await awaitable
|
|
415
|
+
except Exception as e:
|
|
416
|
+
call.end(e)
|
|
417
|
+
raise
|
|
418
|
+
return _handle(result, call, call_kwargs, observe, finish)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _wrap(resource: Any, attr: str, provider: str, kind: str, base_url: Optional[str] = None) -> None:
|
|
422
|
+
orig = getattr(resource, attr, None)
|
|
423
|
+
if orig is None or not callable(orig) or getattr(orig, _MARK, False):
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
def begin(kwargs: Dict[str, Any]) -> Optional[Prepared]:
|
|
427
|
+
if not enabled():
|
|
428
|
+
return None
|
|
429
|
+
try:
|
|
430
|
+
prepared = _prepare(provider, kind, kwargs, base_url)
|
|
431
|
+
prepared[0].start()
|
|
432
|
+
return prepared
|
|
433
|
+
except Exception:
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
if inspect.iscoroutinefunction(orig):
|
|
437
|
+
|
|
438
|
+
@functools.wraps(orig)
|
|
439
|
+
async def awrapper(*args: Any, **kwargs: Any) -> Any:
|
|
440
|
+
p = begin(kwargs)
|
|
441
|
+
if p is None:
|
|
442
|
+
return await orig(*args, **kwargs)
|
|
443
|
+
call, call_kwargs, observe, finish = p
|
|
444
|
+
return await _ahandle(orig(*args, **call_kwargs), call, call_kwargs, observe, finish)
|
|
445
|
+
|
|
446
|
+
wrapper: Any = awrapper
|
|
447
|
+
else:
|
|
448
|
+
# The SDKs' async methods are often plain functions returning a coroutine
|
|
449
|
+
# (openai's @required_args), so an awaitable result is handled here too.
|
|
450
|
+
@functools.wraps(orig)
|
|
451
|
+
def swrapper(*args: Any, **kwargs: Any) -> Any:
|
|
452
|
+
p = begin(kwargs)
|
|
453
|
+
if p is None:
|
|
454
|
+
return orig(*args, **kwargs)
|
|
455
|
+
call, call_kwargs, observe, finish = p
|
|
456
|
+
try:
|
|
457
|
+
with capturing(call):
|
|
458
|
+
result = orig(*args, **call_kwargs)
|
|
459
|
+
except Exception as e:
|
|
460
|
+
call.end(e)
|
|
461
|
+
raise
|
|
462
|
+
if inspect.isawaitable(result):
|
|
463
|
+
return _ahandle(result, call, call_kwargs, observe, finish)
|
|
464
|
+
return _handle(result, call, call_kwargs, observe, finish)
|
|
465
|
+
|
|
466
|
+
wrapper = swrapper
|
|
467
|
+
try:
|
|
468
|
+
setattr(wrapper, _MARK, True)
|
|
469
|
+
setattr(resource, attr, wrapper)
|
|
470
|
+
except Exception:
|
|
471
|
+
pass
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _wrap_stream_helper(resource: Any, base_url: Optional[str] = None) -> None:
|
|
475
|
+
orig = getattr(resource, "stream", None)
|
|
476
|
+
if orig is None or not callable(orig) or getattr(orig, _MARK, False):
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
@functools.wraps(orig)
|
|
480
|
+
def stream(*args: Any, **kwargs: Any) -> Any:
|
|
481
|
+
if not enabled():
|
|
482
|
+
return orig(*args, **kwargs)
|
|
483
|
+
manager = orig(*args, **kwargs)
|
|
484
|
+
try:
|
|
485
|
+
messages = kwargs.get("messages")
|
|
486
|
+
model = kwargs.get("model")
|
|
487
|
+
|
|
488
|
+
def make_call() -> LLMCall:
|
|
489
|
+
call = LLMCall("anthropic", model, lambda: last_user_text(messages))
|
|
490
|
+
call.base_url = base_url
|
|
491
|
+
return call
|
|
492
|
+
|
|
493
|
+
return _AnthropicStreamManager(manager, make_call)
|
|
494
|
+
except Exception:
|
|
495
|
+
return manager
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
setattr(stream, _MARK, True)
|
|
499
|
+
setattr(resource, "stream", stream)
|
|
500
|
+
except Exception:
|
|
501
|
+
pass
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _resource(obj: Any, *path: str) -> Any:
|
|
505
|
+
for p in path:
|
|
506
|
+
try:
|
|
507
|
+
obj = getattr(obj, p)
|
|
508
|
+
except Exception:
|
|
509
|
+
return None
|
|
510
|
+
if obj is None:
|
|
511
|
+
return None
|
|
512
|
+
return obj
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _provider_of(client: Any) -> Optional[str]:
|
|
516
|
+
for cls in type(client).__mro__:
|
|
517
|
+
mod = (getattr(cls, "__module__", "") or "").split(".")[0]
|
|
518
|
+
if mod in ("openai", "anthropic"):
|
|
519
|
+
return mod
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def observe(client: Any) -> Any:
|
|
524
|
+
"""Record every model call made through `client`. Returns the same client.
|
|
525
|
+
|
|
526
|
+
Supports openai.OpenAI / AsyncOpenAI (and Azure variants) and anthropic.Anthropic /
|
|
527
|
+
AsyncAnthropic (and Bedrock/Vertex variants). Anything else is returned untouched.
|
|
528
|
+
"""
|
|
529
|
+
try:
|
|
530
|
+
provider = _provider_of(client)
|
|
531
|
+
base = base_url_of(client) if provider else None
|
|
532
|
+
if provider:
|
|
533
|
+
hook_responses(client)
|
|
534
|
+
if provider == "openai":
|
|
535
|
+
completions = _resource(client, "chat", "completions")
|
|
536
|
+
if completions is not None:
|
|
537
|
+
_wrap(completions, "create", "openai", "chat", base)
|
|
538
|
+
_wrap(completions, "parse", "openai", "chat", base)
|
|
539
|
+
responses = _resource(client, "responses")
|
|
540
|
+
if responses is not None:
|
|
541
|
+
_wrap(responses, "create", "openai", "responses", base)
|
|
542
|
+
elif provider == "anthropic":
|
|
543
|
+
messages = _resource(client, "messages")
|
|
544
|
+
if messages is not None:
|
|
545
|
+
_wrap(messages, "create", "anthropic", "messages", base)
|
|
546
|
+
_wrap_stream_helper(messages, base)
|
|
547
|
+
except Exception:
|
|
548
|
+
pass
|
|
549
|
+
return client
|