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/_trace.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""Traces, spans, sessions and the AgentEvent layout.
|
|
2
|
+
|
|
3
|
+
Event layout matches what src/otel/map.ts produces for the same OTel spans, so an app traced
|
|
4
|
+
with this SDK and the same app traced with OTel gen_ai spans yield equal Session.stats:
|
|
5
|
+
|
|
6
|
+
trace (root) session.start {name, traceId, userId?, metadata?}
|
|
7
|
+
user.message {text} (trace input, when given)
|
|
8
|
+
...children...
|
|
9
|
+
turn.end {ok, error?, durationMs, output?}
|
|
10
|
+
session.end {reason, error?} (only when the session is this trace)
|
|
11
|
+
model call llm.call {provider, requestModel, responseModel, ttftMs, stopReason, ok,
|
|
12
|
+
error?, toolCalls?} + usage, model, durationMs
|
|
13
|
+
assistant.message {text}
|
|
14
|
+
span("tool", n) tool.start {tool, toolUseId, input} . tool.end {tool, toolUseId, ok, durationMs, output}
|
|
15
|
+
span("step", n) span {name, ok, error?} (one event, durationMs on the event)
|
|
16
|
+
|
|
17
|
+
A model call or span made outside any trace gets an implicit root, exactly like a root OTel span.
|
|
18
|
+
Sessions: each top-level trace (or untraced call) is its own session, unless a session id is set
|
|
19
|
+
with trace(session_id=...) or with_session(...); then traces are turns of that conversation.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import contextvars
|
|
25
|
+
import functools
|
|
26
|
+
import inspect
|
|
27
|
+
import os
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from typing import Any, Callable, Dict, Optional, TypeVar
|
|
32
|
+
|
|
33
|
+
from ._client import Config, Transport, dumps
|
|
34
|
+
|
|
35
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
36
|
+
|
|
37
|
+
MAX_FIELD_CHARS = 64 * 1024
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------- runtime
|
|
40
|
+
|
|
41
|
+
_lock = threading.Lock()
|
|
42
|
+
_transport: Optional[Transport] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def init(
|
|
46
|
+
url: Optional[str] = None,
|
|
47
|
+
key: Optional[str] = None,
|
|
48
|
+
service: Optional[str] = None,
|
|
49
|
+
capture: Optional[str] = None,
|
|
50
|
+
flush_interval_ms: Optional[float] = None,
|
|
51
|
+
max_batch: Optional[int] = None,
|
|
52
|
+
max_queue: Optional[int] = None,
|
|
53
|
+
disabled: Optional[bool] = None,
|
|
54
|
+
debug: Optional[bool] = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Configure the SDK. Optional: the first event configures it from env vars.
|
|
57
|
+
|
|
58
|
+
Env: CODING_SH_URL, CODING_SH_INGEST_KEY, CODING_SH_SERVICE (or OTEL_SERVICE_NAME),
|
|
59
|
+
CODING_SH_CAPTURE_MODE (full|metadata), CODING_SH_DISABLED, CODING_SH_DEBUG.
|
|
60
|
+
Without a URL the SDK does nothing.
|
|
61
|
+
"""
|
|
62
|
+
global _transport
|
|
63
|
+
cfg = Config(url, key, service, capture, flush_interval_ms, max_batch, max_queue, disabled, debug)
|
|
64
|
+
with _lock:
|
|
65
|
+
old, _transport = _transport, Transport(cfg)
|
|
66
|
+
if old is not None:
|
|
67
|
+
try:
|
|
68
|
+
old.shutdown(1.0)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get() -> Transport:
|
|
74
|
+
global _transport
|
|
75
|
+
t = _transport
|
|
76
|
+
if t is None:
|
|
77
|
+
with _lock:
|
|
78
|
+
if _transport is None:
|
|
79
|
+
_transport = Transport(Config())
|
|
80
|
+
t = _transport
|
|
81
|
+
return t
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def enabled() -> bool:
|
|
85
|
+
try:
|
|
86
|
+
return _get().cfg.enabled
|
|
87
|
+
except Exception:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def full_capture() -> bool:
|
|
92
|
+
return _get().cfg.full_capture
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def flush(timeout: Optional[float] = None) -> bool:
|
|
96
|
+
"""Send everything queued so far. Returns True when the queue drained in time."""
|
|
97
|
+
try:
|
|
98
|
+
return _get().flush(timeout)
|
|
99
|
+
except Exception:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def shutdown(timeout: Optional[float] = 2.0) -> bool:
|
|
104
|
+
"""Flush, then stop accepting events."""
|
|
105
|
+
try:
|
|
106
|
+
return _get().shutdown(timeout)
|
|
107
|
+
except Exception:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def stats() -> Dict[str, int]:
|
|
112
|
+
"""{queued, sent, dropped, failed} event counts for this process."""
|
|
113
|
+
try:
|
|
114
|
+
return _get().stats()
|
|
115
|
+
except Exception:
|
|
116
|
+
return {"queued": 0, "sent": 0, "dropped": 0, "failed": 0}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------- helpers
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# One clock for every timestamp: perf_counter anchored to wall time. Mixing time.time() for
|
|
123
|
+
# starts with perf_counter() for durations lets a thread switch between the two reads order a
|
|
124
|
+
# parent's end before its children. The anchor follows wall-clock corrections over 1 s.
|
|
125
|
+
_anchor = (time.time(), time.perf_counter())
|
|
126
|
+
_anchor_checked = _anchor[1]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def now_ms() -> float:
|
|
130
|
+
global _anchor, _anchor_checked
|
|
131
|
+
p = time.perf_counter()
|
|
132
|
+
if p - _anchor_checked > 60.0:
|
|
133
|
+
_anchor_checked = p
|
|
134
|
+
wall = time.time()
|
|
135
|
+
if abs(_anchor[0] + (p - _anchor[1]) - wall) > 1.0:
|
|
136
|
+
_anchor = (wall, p)
|
|
137
|
+
return round((_anchor[0] + (p - _anchor[1])) * 1000.0, 3)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _new_id() -> str:
|
|
141
|
+
return str(uuid.uuid4())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _hex(nbytes: int) -> str:
|
|
145
|
+
return os.urandom(nbytes).hex()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def clip(text: Optional[str]) -> Optional[str]:
|
|
149
|
+
if text is None:
|
|
150
|
+
return None
|
|
151
|
+
if len(text) > MAX_FIELD_CHARS:
|
|
152
|
+
return text[:MAX_FIELD_CHARS] + "…[truncated]"
|
|
153
|
+
return text
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def jsonable(value: Any) -> Any:
|
|
157
|
+
"""A JSON-safe, size-bounded copy of an arbitrary input/output value."""
|
|
158
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
159
|
+
return value
|
|
160
|
+
if isinstance(value, str):
|
|
161
|
+
return clip(value)
|
|
162
|
+
try:
|
|
163
|
+
s = dumps(value)
|
|
164
|
+
except Exception:
|
|
165
|
+
return clip(repr(value))
|
|
166
|
+
if len(s) > MAX_FIELD_CHARS:
|
|
167
|
+
return clip(s)
|
|
168
|
+
try:
|
|
169
|
+
import json
|
|
170
|
+
|
|
171
|
+
return json.loads(s)
|
|
172
|
+
except Exception:
|
|
173
|
+
return clip(s)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def error_text(exc: BaseException) -> str:
|
|
177
|
+
msg = str(exc)
|
|
178
|
+
return clip(type(exc).__name__ + (": " + msg if msg else "")) or type(exc).__name__
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def emit(event: Dict[str, Any]) -> None:
|
|
182
|
+
try:
|
|
183
|
+
_get().enqueue(event)
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------- context
|
|
189
|
+
|
|
190
|
+
_session_var: "contextvars.ContextVar[Optional[str]]" = contextvars.ContextVar("coding_sh_session", default=None)
|
|
191
|
+
_node_var: "contextvars.ContextVar[Optional[Node]]" = contextvars.ContextVar("coding_sh_node", default=None)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def current_session_id() -> Optional[str]:
|
|
195
|
+
"""The session the next event would go to, if one is set."""
|
|
196
|
+
n = _node_var.get()
|
|
197
|
+
if n is not None:
|
|
198
|
+
return n.root.session_id
|
|
199
|
+
return _session_var.get()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Root:
|
|
203
|
+
"""One trace: a session, or one turn of a conversation session."""
|
|
204
|
+
|
|
205
|
+
__slots__ = ("session_id", "trace_id", "conversation")
|
|
206
|
+
|
|
207
|
+
def __init__(self, session_id: Optional[str]) -> None:
|
|
208
|
+
self.trace_id = _hex(16)
|
|
209
|
+
self.conversation = session_id is not None
|
|
210
|
+
self.session_id = session_id if session_id is not None else _new_id()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class Node:
|
|
214
|
+
"""A position in the trace tree that events attach to.
|
|
215
|
+
|
|
216
|
+
Like the TS SDK, every node has an anchor event (session.start for a root, tool.start / span /
|
|
217
|
+
llm.call for a child) whose id is fixed up front; a child's events point at their parent's
|
|
218
|
+
anchor through parentId.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
__slots__ = ("root", "span_id", "parent_span_id", "anchor_id", "parent_anchor_id")
|
|
222
|
+
|
|
223
|
+
def __init__(self, root: Root, parent: Optional["Node"]) -> None:
|
|
224
|
+
self.root = root
|
|
225
|
+
self.span_id = _hex(8)
|
|
226
|
+
self.parent_span_id = parent.span_id if parent is not None else None
|
|
227
|
+
self.anchor_id = _new_id()
|
|
228
|
+
self.parent_anchor_id = parent.anchor_id if parent is not None else None
|
|
229
|
+
|
|
230
|
+
def event(
|
|
231
|
+
self, kind: str, ts: float, payload: Dict[str, Any], anchor: bool = False, child: bool = False, **extra: Any
|
|
232
|
+
) -> Dict[str, Any]:
|
|
233
|
+
"""anchor: this is the node's anchor event. child: link it to the parent's anchor."""
|
|
234
|
+
e: Dict[str, Any] = {
|
|
235
|
+
"id": self.anchor_id if anchor else _new_id(),
|
|
236
|
+
"v": 0,
|
|
237
|
+
"sessionId": self.root.session_id,
|
|
238
|
+
"kind": kind,
|
|
239
|
+
"ts": ts,
|
|
240
|
+
"source": "sdk",
|
|
241
|
+
"traceId": self.root.trace_id,
|
|
242
|
+
"spanId": self.span_id,
|
|
243
|
+
"payload": {k: v for k, v in payload.items() if v is not None},
|
|
244
|
+
}
|
|
245
|
+
if self.parent_span_id:
|
|
246
|
+
e["parentSpanId"] = self.parent_span_id
|
|
247
|
+
if child and self.parent_anchor_id:
|
|
248
|
+
e["parentId"] = self.parent_anchor_id
|
|
249
|
+
if self.root.conversation:
|
|
250
|
+
e["turnId"] = self.root.trace_id
|
|
251
|
+
for k, v in extra.items():
|
|
252
|
+
if v is not None:
|
|
253
|
+
e[k] = v
|
|
254
|
+
return e
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class Scope:
|
|
258
|
+
"""Base for anything that opens a node: traces, spans, implicit roots of model calls."""
|
|
259
|
+
|
|
260
|
+
def __init__(self) -> None:
|
|
261
|
+
self.node: Optional[Node] = None
|
|
262
|
+
self.is_root = False
|
|
263
|
+
self._token: Optional[contextvars.Token] = None
|
|
264
|
+
self._t0 = 0.0
|
|
265
|
+
self._p0 = 0.0
|
|
266
|
+
self._done = False
|
|
267
|
+
self.output: Any = None
|
|
268
|
+
self.input: Any = None
|
|
269
|
+
|
|
270
|
+
# Model calls do not bind the context: a returned stream outlives the call site.
|
|
271
|
+
bind_context = True
|
|
272
|
+
|
|
273
|
+
# subclasses
|
|
274
|
+
def _on_start(self) -> None: ...
|
|
275
|
+
def _on_end(self, error: Optional[BaseException], duration_ms: float, end_ts: float) -> None: ...
|
|
276
|
+
|
|
277
|
+
def start(self) -> "Scope":
|
|
278
|
+
self._t0 = now_ms()
|
|
279
|
+
self._p0 = time.perf_counter() # for ttft
|
|
280
|
+
try:
|
|
281
|
+
if enabled():
|
|
282
|
+
parent = _node_var.get()
|
|
283
|
+
if parent is None:
|
|
284
|
+
root = Root(_session_var.get())
|
|
285
|
+
self.node = Node(root, None)
|
|
286
|
+
self.is_root = True
|
|
287
|
+
else:
|
|
288
|
+
self.node = Node(parent.root, parent)
|
|
289
|
+
if self.bind_context:
|
|
290
|
+
self._token = _node_var.set(self.node)
|
|
291
|
+
self._root_start()
|
|
292
|
+
self._on_start()
|
|
293
|
+
except Exception:
|
|
294
|
+
self.node = None
|
|
295
|
+
return self
|
|
296
|
+
|
|
297
|
+
def end(self, error: Optional[BaseException] = None) -> None:
|
|
298
|
+
if self._done:
|
|
299
|
+
return
|
|
300
|
+
self._done = True
|
|
301
|
+
if self._token is not None:
|
|
302
|
+
try:
|
|
303
|
+
_node_var.reset(self._token)
|
|
304
|
+
except ValueError: # ended in a different context (generator, thread): leave it
|
|
305
|
+
pass
|
|
306
|
+
self._token = None
|
|
307
|
+
if self.node is None:
|
|
308
|
+
return
|
|
309
|
+
try:
|
|
310
|
+
end_ts = now_ms()
|
|
311
|
+
duration = round(max(0.0, end_ts - self._t0), 3)
|
|
312
|
+
self._on_end(error, duration, end_ts)
|
|
313
|
+
self._root_end(error, duration, end_ts)
|
|
314
|
+
except Exception:
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
# The implicit-root wrapper every top-level scope gets.
|
|
318
|
+
def _root_name(self) -> str:
|
|
319
|
+
return "trace"
|
|
320
|
+
|
|
321
|
+
def _root_input(self) -> Optional[str]:
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
def _root_meta(self) -> Dict[str, Any]:
|
|
325
|
+
return {}
|
|
326
|
+
|
|
327
|
+
def _root_start(self) -> None:
|
|
328
|
+
if not self.is_root or self.node is None:
|
|
329
|
+
return
|
|
330
|
+
n = self.node
|
|
331
|
+
payload: Dict[str, Any] = {"name": self._root_name(), "traceId": n.root.trace_id}
|
|
332
|
+
payload.update(self._root_meta())
|
|
333
|
+
emit(n.event("session.start", self._t0, payload, anchor=True))
|
|
334
|
+
text = self._root_input()
|
|
335
|
+
if text and full_capture():
|
|
336
|
+
emit(n.event("user.message", self._t0, {"text": clip(text)}))
|
|
337
|
+
|
|
338
|
+
def _root_output(self) -> Any:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
def _root_end(self, error: Optional[BaseException], duration: float, end_ts: float) -> None:
|
|
342
|
+
if not self.is_root or self.node is None:
|
|
343
|
+
return
|
|
344
|
+
n = self.node
|
|
345
|
+
p: Dict[str, Any] = {"ok": error is None, "error": error_text(error) if error else None, "durationMs": duration}
|
|
346
|
+
out = self._root_output()
|
|
347
|
+
if out is not None and full_capture():
|
|
348
|
+
p["output"] = out if isinstance(out, str) else jsonable(out)
|
|
349
|
+
if isinstance(p["output"], str):
|
|
350
|
+
p["output"] = clip(p["output"])
|
|
351
|
+
emit(n.event("turn.end", end_ts, p, durationMs=duration))
|
|
352
|
+
if not n.root.conversation:
|
|
353
|
+
emit(
|
|
354
|
+
n.event(
|
|
355
|
+
"session.end",
|
|
356
|
+
end_ts,
|
|
357
|
+
{"reason": "error" if error else "completed", "error": error_text(error) if error else None},
|
|
358
|
+
durationMs=duration,
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Used by the trace/span context managers.
|
|
363
|
+
def set_output(self, value: Any) -> None:
|
|
364
|
+
self.output = value
|
|
365
|
+
|
|
366
|
+
def set_input(self, value: Any) -> None:
|
|
367
|
+
self.input = value
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------- trace / span
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class _Trace(Scope):
|
|
374
|
+
def __init__(
|
|
375
|
+
self, name: str, session_id: Optional[str], user_id: Optional[str], metadata: Optional[Dict[str, Any]], input: Any
|
|
376
|
+
) -> None:
|
|
377
|
+
super().__init__()
|
|
378
|
+
self.name = name
|
|
379
|
+
self.session_id_arg = session_id
|
|
380
|
+
self.user_id = user_id
|
|
381
|
+
self.metadata = metadata
|
|
382
|
+
self.input = input
|
|
383
|
+
self._sess_token: Optional[contextvars.Token] = None
|
|
384
|
+
self._input_sent = False
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def session_id(self) -> Optional[str]:
|
|
388
|
+
return self.node.root.session_id if self.node else self.session_id_arg
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def trace_id(self) -> Optional[str]:
|
|
392
|
+
return self.node.root.trace_id if self.node else None
|
|
393
|
+
|
|
394
|
+
def start(self) -> "Scope":
|
|
395
|
+
if self.session_id_arg is not None and _node_var.get() is None:
|
|
396
|
+
self._sess_token = _session_var.set(str(self.session_id_arg))
|
|
397
|
+
return super().start()
|
|
398
|
+
|
|
399
|
+
def end(self, error: Optional[BaseException] = None) -> None:
|
|
400
|
+
super().end(error)
|
|
401
|
+
if self._sess_token is not None:
|
|
402
|
+
try:
|
|
403
|
+
_session_var.reset(self._sess_token)
|
|
404
|
+
except ValueError:
|
|
405
|
+
pass
|
|
406
|
+
self._sess_token = None
|
|
407
|
+
|
|
408
|
+
def _root_name(self) -> str:
|
|
409
|
+
return self.name
|
|
410
|
+
|
|
411
|
+
def _root_meta(self) -> Dict[str, Any]:
|
|
412
|
+
m: Dict[str, Any] = {}
|
|
413
|
+
if self.user_id is not None:
|
|
414
|
+
m["userId"] = str(self.user_id)
|
|
415
|
+
if self.metadata:
|
|
416
|
+
m["metadata"] = jsonable(self.metadata)
|
|
417
|
+
return m
|
|
418
|
+
|
|
419
|
+
def _root_input(self) -> Optional[str]:
|
|
420
|
+
if self.input is None:
|
|
421
|
+
return None
|
|
422
|
+
self._input_sent = True
|
|
423
|
+
return self.input if isinstance(self.input, str) else dumps(jsonable(self.input))
|
|
424
|
+
|
|
425
|
+
def _root_output(self) -> Any:
|
|
426
|
+
return self.output
|
|
427
|
+
|
|
428
|
+
def _root_end(self, error: Optional[BaseException], duration: float, end_ts: float) -> None:
|
|
429
|
+
# Input given after start (set_input) still lands at the start of the turn.
|
|
430
|
+
if self.is_root and self.node is not None and not self._input_sent and self.input is not None and full_capture():
|
|
431
|
+
text = self.input if isinstance(self.input, str) else dumps(jsonable(self.input))
|
|
432
|
+
emit(self.node.event("user.message", self._t0, {"text": clip(text)}))
|
|
433
|
+
super()._root_end(error, duration, end_ts)
|
|
434
|
+
|
|
435
|
+
def _on_end(self, error: Optional[BaseException], duration: float, end_ts: float) -> None:
|
|
436
|
+
# A trace nested in another trace is a step of it.
|
|
437
|
+
if not self.is_root and self.node is not None:
|
|
438
|
+
_step_event(self.node, self.name, self._t0, duration, error, self.input, self.output, self.is_root)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _step_event(
|
|
442
|
+
node: Node, name: str, ts: float, duration: float, error: Optional[BaseException], inp: Any, out: Any, is_root: bool = False
|
|
443
|
+
) -> None:
|
|
444
|
+
attrs: Dict[str, Any] = {}
|
|
445
|
+
if full_capture():
|
|
446
|
+
if inp is not None:
|
|
447
|
+
attrs["input"] = jsonable(inp)
|
|
448
|
+
if out is not None:
|
|
449
|
+
attrs["output"] = jsonable(out)
|
|
450
|
+
p: Dict[str, Any] = {"name": name, "ok": error is None, "error": error_text(error) if error else None}
|
|
451
|
+
if attrs:
|
|
452
|
+
p["attributes"] = attrs
|
|
453
|
+
emit(node.event("span", ts, p, anchor=not is_root, child=True, durationMs=duration))
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class _Span(Scope):
|
|
457
|
+
def __init__(self, kind: str, name: str, input: Any) -> None:
|
|
458
|
+
super().__init__()
|
|
459
|
+
self.kind = "tool" if kind == "tool" else "step"
|
|
460
|
+
self.name = name
|
|
461
|
+
self.input = input
|
|
462
|
+
|
|
463
|
+
def _root_name(self) -> str:
|
|
464
|
+
return self.name
|
|
465
|
+
|
|
466
|
+
def _on_start(self) -> None:
|
|
467
|
+
if self.kind == "tool" and self.node is not None:
|
|
468
|
+
p: Dict[str, Any] = {"tool": self.name, "toolUseId": self.node.span_id}
|
|
469
|
+
if self.input is not None and full_capture():
|
|
470
|
+
p["input"] = jsonable(self.input)
|
|
471
|
+
emit(self.node.event("tool.start", self._t0, p, anchor=not self.is_root, child=True))
|
|
472
|
+
|
|
473
|
+
def _on_end(self, error: Optional[BaseException], duration: float, end_ts: float) -> None:
|
|
474
|
+
if self.node is None:
|
|
475
|
+
return
|
|
476
|
+
if self.kind == "tool":
|
|
477
|
+
p: Dict[str, Any] = {
|
|
478
|
+
"tool": self.name,
|
|
479
|
+
"toolUseId": self.node.span_id,
|
|
480
|
+
"ok": error is None,
|
|
481
|
+
"durationMs": duration,
|
|
482
|
+
"error": error_text(error) if error else None,
|
|
483
|
+
}
|
|
484
|
+
if self.output is not None and full_capture():
|
|
485
|
+
p["output"] = jsonable(self.output)
|
|
486
|
+
emit(self.node.event("tool.end", end_ts, p, child=True, durationMs=duration))
|
|
487
|
+
else:
|
|
488
|
+
_step_event(self.node, self.name, self._t0, duration, error, self.input, self.output, self.is_root)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class _ScopeCM:
|
|
492
|
+
"""Context manager (sync and async) and decorator around a Scope factory."""
|
|
493
|
+
|
|
494
|
+
def __init__(self, factory: Callable[[], Scope], capture_args: bool = False) -> None:
|
|
495
|
+
self._factory = factory
|
|
496
|
+
self._capture_args = capture_args
|
|
497
|
+
self._scopes: list = []
|
|
498
|
+
|
|
499
|
+
def __enter__(self) -> Scope:
|
|
500
|
+
s = self._factory().start()
|
|
501
|
+
self._scopes.append(s)
|
|
502
|
+
return s
|
|
503
|
+
|
|
504
|
+
def __exit__(self, exc_type: Any, exc: Optional[BaseException], tb: Any) -> bool:
|
|
505
|
+
if self._scopes:
|
|
506
|
+
self._scopes.pop().end(exc if isinstance(exc, Exception) else None)
|
|
507
|
+
return False
|
|
508
|
+
|
|
509
|
+
async def __aenter__(self) -> Scope:
|
|
510
|
+
return self.__enter__()
|
|
511
|
+
|
|
512
|
+
async def __aexit__(self, exc_type: Any, exc: Optional[BaseException], tb: Any) -> bool:
|
|
513
|
+
return self.__exit__(exc_type, exc, tb)
|
|
514
|
+
|
|
515
|
+
def __call__(self, fn: F) -> F:
|
|
516
|
+
factory = self._factory
|
|
517
|
+
capture_args = self._capture_args
|
|
518
|
+
|
|
519
|
+
def begin(args: Any, kwargs: Any) -> Scope:
|
|
520
|
+
s = factory()
|
|
521
|
+
if capture_args and s.input is None:
|
|
522
|
+
s.input = _bound_args(fn, args, kwargs)
|
|
523
|
+
return s.start()
|
|
524
|
+
|
|
525
|
+
if inspect.iscoroutinefunction(fn):
|
|
526
|
+
|
|
527
|
+
@functools.wraps(fn)
|
|
528
|
+
async def awrapper(*args: Any, **kwargs: Any) -> Any:
|
|
529
|
+
s = begin(args, kwargs)
|
|
530
|
+
try:
|
|
531
|
+
result = await fn(*args, **kwargs)
|
|
532
|
+
except Exception as e:
|
|
533
|
+
s.end(e)
|
|
534
|
+
raise
|
|
535
|
+
except BaseException:
|
|
536
|
+
s.end(None)
|
|
537
|
+
raise
|
|
538
|
+
if s.output is None:
|
|
539
|
+
s.output = result
|
|
540
|
+
s.end(None)
|
|
541
|
+
return result
|
|
542
|
+
|
|
543
|
+
return awrapper # type: ignore[return-value]
|
|
544
|
+
|
|
545
|
+
@functools.wraps(fn)
|
|
546
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
547
|
+
s = begin(args, kwargs)
|
|
548
|
+
try:
|
|
549
|
+
result = fn(*args, **kwargs)
|
|
550
|
+
except Exception as e:
|
|
551
|
+
s.end(e)
|
|
552
|
+
raise
|
|
553
|
+
except BaseException:
|
|
554
|
+
s.end(None)
|
|
555
|
+
raise
|
|
556
|
+
if s.output is None:
|
|
557
|
+
s.output = result
|
|
558
|
+
s.end(None)
|
|
559
|
+
return result
|
|
560
|
+
|
|
561
|
+
return wrapper # type: ignore[return-value]
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _bound_args(fn: Callable[..., Any], args: Any, kwargs: Any) -> Any:
|
|
565
|
+
try:
|
|
566
|
+
bound = inspect.signature(fn).bind_partial(*args, **kwargs)
|
|
567
|
+
out = {k: v for k, v in bound.arguments.items() if k not in ("self", "cls")}
|
|
568
|
+
return out or None
|
|
569
|
+
except Exception:
|
|
570
|
+
return None
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def trace(
|
|
574
|
+
name: str = "trace",
|
|
575
|
+
session_id: Optional[str] = None,
|
|
576
|
+
user_id: Optional[str] = None,
|
|
577
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
578
|
+
input: Any = None,
|
|
579
|
+
) -> _ScopeCM:
|
|
580
|
+
"""A unit of work: `with coding_sh.trace("checkout", session_id=conv_id, input=prompt) as t:`.
|
|
581
|
+
|
|
582
|
+
Also a decorator (sync or async). The yielded object has set_input(), set_output(),
|
|
583
|
+
session_id and trace_id. A decorated function's return value becomes the output.
|
|
584
|
+
"""
|
|
585
|
+
return _ScopeCM(lambda: _Trace(name, session_id, user_id, metadata, input))
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def span(kind: str, name: str, input: Any = None) -> _ScopeCM:
|
|
589
|
+
"""A tool call (`span("tool", "lookup_order")`) or a step (`span("step", "rerank")`).
|
|
590
|
+
|
|
591
|
+
Context manager or decorator; as a decorator, the call's arguments become the input and
|
|
592
|
+
its return value the output.
|
|
593
|
+
"""
|
|
594
|
+
return _ScopeCM(lambda: _Span(kind, name, input), capture_args=True)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class _SessionCM:
|
|
598
|
+
def __init__(self, session_id: str) -> None:
|
|
599
|
+
self.session_id = str(session_id)
|
|
600
|
+
self._tokens: list = []
|
|
601
|
+
|
|
602
|
+
def __enter__(self) -> str:
|
|
603
|
+
self._tokens.append(_session_var.set(self.session_id))
|
|
604
|
+
return self.session_id
|
|
605
|
+
|
|
606
|
+
def __exit__(self, *exc: Any) -> bool:
|
|
607
|
+
if self._tokens:
|
|
608
|
+
try:
|
|
609
|
+
_session_var.reset(self._tokens.pop())
|
|
610
|
+
except ValueError:
|
|
611
|
+
pass
|
|
612
|
+
return False
|
|
613
|
+
|
|
614
|
+
async def __aenter__(self) -> str:
|
|
615
|
+
return self.__enter__()
|
|
616
|
+
|
|
617
|
+
async def __aexit__(self, *exc: Any) -> bool:
|
|
618
|
+
return self.__exit__(*exc)
|
|
619
|
+
|
|
620
|
+
def __call__(self, fn: F) -> F:
|
|
621
|
+
sid = self.session_id
|
|
622
|
+
if inspect.iscoroutinefunction(fn):
|
|
623
|
+
|
|
624
|
+
@functools.wraps(fn)
|
|
625
|
+
async def awrapper(*args: Any, **kwargs: Any) -> Any:
|
|
626
|
+
with _SessionCM(sid):
|
|
627
|
+
return await fn(*args, **kwargs)
|
|
628
|
+
|
|
629
|
+
return awrapper # type: ignore[return-value]
|
|
630
|
+
|
|
631
|
+
@functools.wraps(fn)
|
|
632
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
633
|
+
with _SessionCM(sid):
|
|
634
|
+
return fn(*args, **kwargs)
|
|
635
|
+
|
|
636
|
+
return wrapper # type: ignore[return-value]
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def with_session(session_id: str) -> _SessionCM:
|
|
640
|
+
"""Group traces and untraced calls into one conversation session (context manager or decorator)."""
|
|
641
|
+
return _SessionCM(session_id)
|