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.
@@ -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