agentdynamics 0.4.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.
- agentdynamics/__init__.py +12 -0
- agentdynamics/__main__.py +322 -0
- agentdynamics/analysis.py +755 -0
- agentdynamics/autotrace.py +697 -0
- agentdynamics/bootstrap/sitecustomize.py +27 -0
- agentdynamics/collectors/__init__.py +0 -0
- agentdynamics/collectors/aegis_audit.py +72 -0
- agentdynamics/collectors/claude_code.py +257 -0
- agentdynamics/collectors/generic.py +103 -0
- agentdynamics/collectors/inbox.py +95 -0
- agentdynamics/collectors/langfuse.py +98 -0
- agentdynamics/collectors/langsmith.py +258 -0
- agentdynamics/collectors/otlp.py +349 -0
- agentdynamics/collectors/spans.py +202 -0
- agentdynamics/config.py +139 -0
- agentdynamics/engine.py +376 -0
- agentdynamics/flows.py +142 -0
- agentdynamics/govern.py +231 -0
- agentdynamics/integrations/__init__.py +1 -0
- agentdynamics/integrations/aegis.py +352 -0
- agentdynamics/phases.py +82 -0
- agentdynamics/pricing.py +69 -0
- agentdynamics/privacy.py +41 -0
- agentdynamics/sdk.py +105 -0
- agentdynamics/server.py +949 -0
- agentdynamics/slo.py +84 -0
- agentdynamics/store.py +228 -0
- agentdynamics/web/app.js +1069 -0
- agentdynamics/web/charts.js +185 -0
- agentdynamics/web/index.html +40 -0
- agentdynamics/web/style.css +244 -0
- agentdynamics-0.4.0.dist-info/METADATA +195 -0
- agentdynamics-0.4.0.dist-info/RECORD +37 -0
- agentdynamics-0.4.0.dist-info/WHEEL +5 -0
- agentdynamics-0.4.0.dist-info/entry_points.txt +2 -0
- agentdynamics-0.4.0.dist-info/licenses/LICENSE +202 -0
- agentdynamics-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
"""One-line instrumentation.
|
|
2
|
+
|
|
3
|
+
import agentdynamics
|
|
4
|
+
agentdynamics.init() # that's it
|
|
5
|
+
|
|
6
|
+
`init()` looks at what is installed and wires up everything it finds:
|
|
7
|
+
* LangChain / LangGraph -> redirects LangSmith tracing to AgentDynamics (unless you already trace to LangSmith)
|
|
8
|
+
* OpenTelemetry SDK -> adds an OTLP exporter to your tracer provider (OpenInference, OpenLLMetry, GenAI semconv)
|
|
9
|
+
* Anthropic SDK -> records every messages.create call (sync, async, streaming) with tokens, latency, stop reason
|
|
10
|
+
* OpenAI SDK -> records chat.completions.create and responses.create
|
|
11
|
+
|
|
12
|
+
Optional, for richer data:
|
|
13
|
+
@agentdynamics.trace # group everything a function does into one task / workflow
|
|
14
|
+
@agentdynamics.tool # record a function as a tool call
|
|
15
|
+
with agentdynamics.span("retrieve"): # mark a graph node / stage
|
|
16
|
+
|
|
17
|
+
Telemetry is sent from a background thread; your code never waits on it and never fails because of it.
|
|
18
|
+
Settings come from arguments or environment variables:
|
|
19
|
+
AGENTDYNAMICS_URL (default http://127.0.0.1:8787), AGENTDYNAMICS_API_KEY, AGENTDYNAMICS_PROJECT,
|
|
20
|
+
AGENTDYNAMICS_ENVIRONMENT, AGENTDYNAMICS_CAPTURE_CONTENT (1/0), AGENTDYNAMICS_DISABLED (1)
|
|
21
|
+
"""
|
|
22
|
+
import atexit
|
|
23
|
+
import contextvars
|
|
24
|
+
import functools
|
|
25
|
+
import inspect
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import queue
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
import uuid
|
|
35
|
+
|
|
36
|
+
_cfg = {"url": None, "key": None, "project": None, "environment": None, "content": True, "on": False}
|
|
37
|
+
_current = contextvars.ContextVar("agentdynamics_run", default=None)
|
|
38
|
+
_node = contextvars.ContextVar("agentdynamics_node", default=None)
|
|
39
|
+
_q = queue.Queue(maxsize=10000)
|
|
40
|
+
_sender = None
|
|
41
|
+
_patched = set()
|
|
42
|
+
_warned = set()
|
|
43
|
+
|
|
44
|
+
# Extension points used by integrations (e.g. agentdynamics.integrations.aegis):
|
|
45
|
+
# llm_gates: objects with before(provider, model, kwargs) -> handle (may raise to block the call)
|
|
46
|
+
# and after(handle, usd, tokens, error) (settle what before() reserved)
|
|
47
|
+
# step(run, step) observe every recorded step (watchdogs)
|
|
48
|
+
# run_meta(run) -> dict extra fields for the run payload
|
|
49
|
+
_hooks = {"llm_gates": [], "step": [], "run_meta": []}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def current_run():
|
|
53
|
+
"""The task being traced in this context, or None."""
|
|
54
|
+
return _current.get()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def current_node():
|
|
58
|
+
return _node.get()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _warn(key, msg):
|
|
62
|
+
if key not in _warned:
|
|
63
|
+
_warned.add(key)
|
|
64
|
+
print(f"[agentdynamics] {msg}", file=sys.stderr)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _clip(v, n=600):
|
|
68
|
+
if not _cfg["content"]:
|
|
69
|
+
return ""
|
|
70
|
+
if v is None:
|
|
71
|
+
return ""
|
|
72
|
+
if not isinstance(v, str):
|
|
73
|
+
try:
|
|
74
|
+
v = json.dumps(v, default=str)
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
v = str(v)
|
|
77
|
+
return v[:n]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------- transport
|
|
81
|
+
|
|
82
|
+
def _send_loop():
|
|
83
|
+
while True:
|
|
84
|
+
batch = [_q.get()]
|
|
85
|
+
try:
|
|
86
|
+
while len(batch) < 50:
|
|
87
|
+
batch.append(_q.get_nowait())
|
|
88
|
+
except queue.Empty:
|
|
89
|
+
pass
|
|
90
|
+
items = [b for b in batch if b is not None]
|
|
91
|
+
if items:
|
|
92
|
+
_post(items)
|
|
93
|
+
for _ in batch:
|
|
94
|
+
_q.task_done()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _post(items):
|
|
98
|
+
headers = {"Content-Type": "application/json"}
|
|
99
|
+
if _cfg["key"]:
|
|
100
|
+
headers["Authorization"] = f"Bearer {_cfg['key']}"
|
|
101
|
+
data = json.dumps(items, default=str).encode()
|
|
102
|
+
for attempt in range(3):
|
|
103
|
+
try:
|
|
104
|
+
req = urllib.request.Request(_cfg["url"] + "/api/ingest", data=data, headers=headers, method="POST")
|
|
105
|
+
urllib.request.urlopen(req, timeout=10).read()
|
|
106
|
+
return
|
|
107
|
+
except urllib.error.HTTPError as ex:
|
|
108
|
+
if ex.code in (401, 403):
|
|
109
|
+
_warn("auth", f"server rejected telemetry ({ex.code}); check AGENTDYNAMICS_API_KEY has the 'ingest' role")
|
|
110
|
+
return
|
|
111
|
+
except OSError:
|
|
112
|
+
pass
|
|
113
|
+
time.sleep(0.5 * (attempt + 1))
|
|
114
|
+
_warn("down", f"could not reach {_cfg['url']}; telemetry is being dropped (your app is unaffected)")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _emit(payload):
|
|
118
|
+
if not _cfg["on"]:
|
|
119
|
+
return
|
|
120
|
+
try:
|
|
121
|
+
_q.put_nowait(payload)
|
|
122
|
+
except queue.Full:
|
|
123
|
+
_warn("full", "telemetry queue full; dropping runs")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def flush(timeout=5.0):
|
|
127
|
+
"""Block until queued telemetry is sent (called automatically at exit)."""
|
|
128
|
+
if not _cfg["on"]:
|
|
129
|
+
return
|
|
130
|
+
end = time.time() + timeout
|
|
131
|
+
while _q.unfinished_tasks and time.time() < end:
|
|
132
|
+
time.sleep(0.05)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------- runs, spans, tools
|
|
136
|
+
|
|
137
|
+
class _Run:
|
|
138
|
+
def __init__(self, name, prompt=None, thread_id=None, user_id=None, metadata=None):
|
|
139
|
+
self.id = f"{_cfg['project'] or 'app'}-{uuid.uuid4().hex[:16]}"
|
|
140
|
+
self.name = name
|
|
141
|
+
self.steps = [{"kind": "prompt", "ts": time.time(), "text": _clip(prompt if prompt is not None else name, 2000) or name}]
|
|
142
|
+
self.error = None
|
|
143
|
+
self.thread_id = thread_id
|
|
144
|
+
self.user_id = user_id
|
|
145
|
+
self.metadata = metadata or {}
|
|
146
|
+
self.feedback = []
|
|
147
|
+
self._lock = threading.Lock()
|
|
148
|
+
|
|
149
|
+
def add(self, step):
|
|
150
|
+
with self._lock:
|
|
151
|
+
node = _node.get()
|
|
152
|
+
if node and "node" not in step:
|
|
153
|
+
step["node"] = node
|
|
154
|
+
self.steps.append(step)
|
|
155
|
+
for hook in list(_hooks["step"]):
|
|
156
|
+
try:
|
|
157
|
+
hook(self, step)
|
|
158
|
+
except Exception as ex: # observers never break the agent
|
|
159
|
+
_warn(f"step-hook:{id(hook)}", f"step hook failed: {ex!r}")
|
|
160
|
+
|
|
161
|
+
def payload(self):
|
|
162
|
+
out = {"id": self.id, "agent": self.name, "workflow": self.name, "project": _cfg["project"] or "default",
|
|
163
|
+
"environment": _cfg["environment"], "source": "sdk", "framework": "agentdynamics-sdk",
|
|
164
|
+
"thread_id": self.thread_id, "user_id": self.user_id, "status": "error" if self.error else "ok",
|
|
165
|
+
"error": self.error, "complete": True, "feedback": self.feedback, "steps": self.steps}
|
|
166
|
+
for hook in list(_hooks["run_meta"]):
|
|
167
|
+
try:
|
|
168
|
+
out.update(hook(self) or {})
|
|
169
|
+
except Exception as ex:
|
|
170
|
+
_warn(f"meta-hook:{id(hook)}", f"run metadata hook failed: {ex!r}")
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class trace:
|
|
175
|
+
"""Group work into one task. Use as @trace, @trace("name"), or `with trace("name", prompt=...)`."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, name=None, prompt=None, thread_id=None, user_id=None, **metadata):
|
|
178
|
+
self._fn = None
|
|
179
|
+
if callable(name):
|
|
180
|
+
self._fn, name = name, name.__name__
|
|
181
|
+
functools.update_wrapper(self, self._fn)
|
|
182
|
+
self.name, self.prompt, self.thread_id, self.user_id, self.metadata = name, prompt, thread_id, user_id, metadata
|
|
183
|
+
self.run = None
|
|
184
|
+
|
|
185
|
+
# context manager
|
|
186
|
+
def __enter__(self):
|
|
187
|
+
parent = _current.get()
|
|
188
|
+
if parent is not None: # nested trace -> a node inside the parent workflow
|
|
189
|
+
self._span = span(self.name or "step")
|
|
190
|
+
self._span.__enter__()
|
|
191
|
+
self.run = parent
|
|
192
|
+
return self
|
|
193
|
+
self.run = _Run(self.name or "task", self.prompt, self.thread_id, self.user_id, self.metadata)
|
|
194
|
+
self._tok = _current.set(self.run)
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def __exit__(self, et, ev, tb):
|
|
198
|
+
if hasattr(self, "_span"):
|
|
199
|
+
self._span.__exit__(et, ev, tb)
|
|
200
|
+
del self._span
|
|
201
|
+
return False
|
|
202
|
+
if ev is not None:
|
|
203
|
+
self.run.error = f"{et.__name__}: {ev}"[:400]
|
|
204
|
+
_current.reset(self._tok)
|
|
205
|
+
_emit(self.run.payload())
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
async def __aenter__(self):
|
|
209
|
+
return self.__enter__()
|
|
210
|
+
|
|
211
|
+
async def __aexit__(self, et, ev, tb):
|
|
212
|
+
return self.__exit__(et, ev, tb)
|
|
213
|
+
|
|
214
|
+
def feedback(self, key, score):
|
|
215
|
+
"""Attach a user/eval score (0..1) to this task."""
|
|
216
|
+
self.run.feedback.append({"key": key, "score": score})
|
|
217
|
+
|
|
218
|
+
# decorator
|
|
219
|
+
def __call__(self, *args, **kwargs):
|
|
220
|
+
if self._fn is None: # used as @trace("name")
|
|
221
|
+
fn = args[0]
|
|
222
|
+
return trace(self.name or fn.__name__, self.prompt, self.thread_id, self.user_id, **self.metadata).__wrap(fn)
|
|
223
|
+
return self.__wrap(self._fn)(*args, **kwargs)
|
|
224
|
+
|
|
225
|
+
def __wrap(self, fn):
|
|
226
|
+
name = self.name or fn.__name__
|
|
227
|
+
|
|
228
|
+
def prompt_of(args, kwargs):
|
|
229
|
+
for v in list(args) + list(kwargs.values()):
|
|
230
|
+
if isinstance(v, str) and v.strip():
|
|
231
|
+
return v
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
if inspect.iscoroutinefunction(fn):
|
|
235
|
+
@functools.wraps(fn)
|
|
236
|
+
async def aw(*a, **k):
|
|
237
|
+
async with trace(name, self.prompt or prompt_of(a, k), self.thread_id, self.user_id, **self.metadata):
|
|
238
|
+
return await fn(*a, **k)
|
|
239
|
+
return aw
|
|
240
|
+
|
|
241
|
+
@functools.wraps(fn)
|
|
242
|
+
def w(*a, **k):
|
|
243
|
+
with trace(name, self.prompt or prompt_of(a, k), self.thread_id, self.user_id, **self.metadata):
|
|
244
|
+
return fn(*a, **k)
|
|
245
|
+
return w
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class span:
|
|
249
|
+
"""Mark a stage / graph node. LLM and tool calls inside it are attributed to the node."""
|
|
250
|
+
|
|
251
|
+
def __init__(self, name, kind="node"):
|
|
252
|
+
self.name, self.kind = name, kind
|
|
253
|
+
|
|
254
|
+
def __enter__(self):
|
|
255
|
+
self.t0 = time.time()
|
|
256
|
+
self.tok = _node.set(self.name)
|
|
257
|
+
return self
|
|
258
|
+
|
|
259
|
+
def __exit__(self, et, ev, tb):
|
|
260
|
+
_node.reset(self.tok)
|
|
261
|
+
run = _current.get()
|
|
262
|
+
if run is not None:
|
|
263
|
+
run.add({"kind": "span", "name": self.name, "node": self.name, "span_kind": self.kind, "ts": self.t0,
|
|
264
|
+
"start_ts": self.t0, "end_ts": time.time(), "is_error": ev is not None,
|
|
265
|
+
"error": f"{et.__name__}: {ev}"[:300] if ev else None})
|
|
266
|
+
# keep execution order by start time
|
|
267
|
+
run.steps.sort(key=lambda s: (s.get("kind") != "prompt", s.get("ts") or 0))
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
async def __aenter__(self):
|
|
271
|
+
return self.__enter__()
|
|
272
|
+
|
|
273
|
+
async def __aexit__(self, et, ev, tb):
|
|
274
|
+
return self.__exit__(et, ev, tb)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def tool(fn=None, *, name=None):
|
|
278
|
+
"""Record a function as a tool call (@tool or @tool(name="search"))."""
|
|
279
|
+
def deco(f):
|
|
280
|
+
tname = name or f.__name__
|
|
281
|
+
|
|
282
|
+
def record(t0, args, kwargs, result=None, err=None):
|
|
283
|
+
run = _current.get()
|
|
284
|
+
if run is None:
|
|
285
|
+
return
|
|
286
|
+
inp = {**{f"arg{i}": a for i, a in enumerate(args)}, **kwargs}
|
|
287
|
+
run.add({"kind": "tool", "name": tname, "ts": t0, "end_ts": time.time(), "input": inp if _cfg["content"] else {},
|
|
288
|
+
"is_error": err is not None, "error": f"{type(err).__name__}: {err}"[:300] if err else None,
|
|
289
|
+
"output_chars": len(_clip(result, 10**7)) if result is not None else 0, "text": _clip(result, 300)})
|
|
290
|
+
|
|
291
|
+
if inspect.iscoroutinefunction(f):
|
|
292
|
+
@functools.wraps(f)
|
|
293
|
+
async def aw(*a, **k):
|
|
294
|
+
t0 = time.time()
|
|
295
|
+
try:
|
|
296
|
+
r = await f(*a, **k)
|
|
297
|
+
except Exception as ex:
|
|
298
|
+
record(t0, a, k, err=ex)
|
|
299
|
+
raise
|
|
300
|
+
record(t0, a, k, r)
|
|
301
|
+
return r
|
|
302
|
+
return aw
|
|
303
|
+
|
|
304
|
+
@functools.wraps(f)
|
|
305
|
+
def w(*a, **k):
|
|
306
|
+
t0 = time.time()
|
|
307
|
+
try:
|
|
308
|
+
r = f(*a, **k)
|
|
309
|
+
except Exception as ex:
|
|
310
|
+
record(t0, a, k, err=ex)
|
|
311
|
+
raise
|
|
312
|
+
record(t0, a, k, r)
|
|
313
|
+
return r
|
|
314
|
+
return w
|
|
315
|
+
return deco(fn) if fn else deco
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _llm_begin(provider, model, kwargs):
|
|
319
|
+
"""Ask every gate before the request is sent. A gate may raise to block it; gates that already
|
|
320
|
+
reserved something are released so nothing leaks."""
|
|
321
|
+
done = []
|
|
322
|
+
for gate in list(_hooks["llm_gates"]):
|
|
323
|
+
try:
|
|
324
|
+
done.append((gate, gate.before(provider, model, kwargs)))
|
|
325
|
+
except Exception as ex:
|
|
326
|
+
for g, h in done:
|
|
327
|
+
g.after(h, 0.0, 0, ex)
|
|
328
|
+
raise
|
|
329
|
+
return done
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _is_denial(err):
|
|
333
|
+
v = getattr(err, "verdict", None)
|
|
334
|
+
return v is not None and getattr(v, "allowed", True) is False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _record_llm(model, it, ot, cr, cw, stop, t0, t1, text="", err=None, ttft=None, provider=None, handles=()):
|
|
338
|
+
from .pricing import cost as _price
|
|
339
|
+
usd = _price(model, it or 0, ot or 0, cr or 0, cw or 0, 0)
|
|
340
|
+
step = {"kind": "llm", "model": model, "ts": t0, "end_ts": t1, "input_tokens": it or 0, "output_tokens": ot or 0,
|
|
341
|
+
"cache_read": cr or 0, "cache_write": cw or 0, "stop_reason": stop, "text": _clip(text), "provider": provider,
|
|
342
|
+
"cost": usd}
|
|
343
|
+
if ttft is not None:
|
|
344
|
+
step["ttft_ms"] = ttft
|
|
345
|
+
if err is not None and _is_denial(err): # blocked before it was sent (budget / revoked grant)
|
|
346
|
+
step["denied"] = True
|
|
347
|
+
step["rule"] = err.verdict.rule
|
|
348
|
+
step["guard"] = getattr(err.verdict, "guard", None)
|
|
349
|
+
step["error"] = str(err)[:300]
|
|
350
|
+
elif err is not None:
|
|
351
|
+
step["is_error"] = True
|
|
352
|
+
step["error"] = f"{type(err).__name__}: {err}"[:300]
|
|
353
|
+
step["rate_limited"] = getattr(err, "status_code", None) in (429, 529) or "rate" in str(err).lower()
|
|
354
|
+
tokens = (it or 0) + (ot or 0) + (cr or 0) + (cw or 0)
|
|
355
|
+
for gate, handle in handles:
|
|
356
|
+
try:
|
|
357
|
+
gate.after(handle, usd, tokens, err)
|
|
358
|
+
except Exception as ex:
|
|
359
|
+
_warn(f"gate:{id(gate)}", f"model gate settle failed: {ex!r}")
|
|
360
|
+
run = _current.get()
|
|
361
|
+
if run is not None:
|
|
362
|
+
run.add(step)
|
|
363
|
+
else: # a model call outside any @trace becomes its own small task
|
|
364
|
+
r = _Run(f"{provider or 'llm'}.call")
|
|
365
|
+
r.add(step)
|
|
366
|
+
if err is not None:
|
|
367
|
+
r.error = step["error"]
|
|
368
|
+
_emit(r.payload())
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class llm_call:
|
|
372
|
+
"""Record (and gate) a call to any model client the SDK doesn't patch: local models, other SDKs, raw HTTP.
|
|
373
|
+
|
|
374
|
+
with agentdynamics.llm_call("my-model", max_tokens=512, input=messages) as call:
|
|
375
|
+
resp = my_client.generate(...)
|
|
376
|
+
call.usage(input_tokens=resp.in_tok, output_tokens=resp.out_tok, stop_reason=resp.stop)
|
|
377
|
+
|
|
378
|
+
Integrations such as Aegis budget gating run before the body, so an exhausted budget stops the call.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
def __init__(self, model, provider="custom", max_tokens=None, input=None):
|
|
382
|
+
self.model, self.provider = model, provider
|
|
383
|
+
self.kwargs = {"model": model, "max_tokens": max_tokens, "messages": input}
|
|
384
|
+
self._u = {"it": 0, "ot": 0, "cr": 0, "cw": 0, "stop": None, "text": ""}
|
|
385
|
+
|
|
386
|
+
def usage(self, input_tokens=0, output_tokens=0, cache_read=0, cache_write=0, stop_reason=None, text=""):
|
|
387
|
+
self._u.update(it=input_tokens, ot=output_tokens, cr=cache_read, cw=cache_write, stop=stop_reason, text=text)
|
|
388
|
+
|
|
389
|
+
def __enter__(self):
|
|
390
|
+
self.t0 = time.time()
|
|
391
|
+
try:
|
|
392
|
+
self.h = _llm_begin(self.provider, self.model, self.kwargs)
|
|
393
|
+
except Exception as ex:
|
|
394
|
+
_record_llm(self.model, 0, 0, 0, 0, None, self.t0, time.time(), err=ex, provider=self.provider)
|
|
395
|
+
raise
|
|
396
|
+
return self
|
|
397
|
+
|
|
398
|
+
def __exit__(self, et, ev, tb):
|
|
399
|
+
u = self._u
|
|
400
|
+
_record_llm(self.model, u["it"], u["ot"], u["cr"], u["cw"], u["stop"], self.t0, time.time(), u["text"], ev,
|
|
401
|
+
provider=self.provider, handles=self.h)
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def record_llm(model, input_tokens=0, output_tokens=0, cache_read=0, cache_write=0, stop_reason=None,
|
|
406
|
+
start=None, end=None, text="", provider="custom"):
|
|
407
|
+
"""Record a model call after the fact (no gating). Prefer `llm_call` when you can wrap the call."""
|
|
408
|
+
end = end or time.time()
|
|
409
|
+
_record_llm(model, input_tokens, output_tokens, cache_read, cache_write, stop_reason, start or end, end, text,
|
|
410
|
+
provider=provider)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ---------------------------------------------------------------- Anthropic
|
|
414
|
+
|
|
415
|
+
def _anthropic_usage(msg):
|
|
416
|
+
u = getattr(msg, "usage", None)
|
|
417
|
+
g = (lambda k: getattr(u, k, 0) or 0) if u is not None else (lambda k: 0)
|
|
418
|
+
text = "".join(getattr(b, "text", "") for b in getattr(msg, "content", None) or [] if getattr(b, "type", "") == "text")
|
|
419
|
+
return g("input_tokens"), g("output_tokens"), g("cache_read_input_tokens"), g("cache_creation_input_tokens"), getattr(msg, "stop_reason", None), text
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class _AnthropicStream:
|
|
423
|
+
"""Wraps a stream=True iterator, accumulating usage from message_start / message_delta events."""
|
|
424
|
+
|
|
425
|
+
def __init__(self, inner, model, t0, handles=()):
|
|
426
|
+
self._inner, self._model, self._t0, self._handles = inner, model, t0, handles
|
|
427
|
+
self._u = {"i": 0, "o": 0, "cr": 0, "cw": 0}
|
|
428
|
+
self._stop, self._ttft, self._text, self._done = None, None, [], False
|
|
429
|
+
|
|
430
|
+
def __iter__(self):
|
|
431
|
+
return self
|
|
432
|
+
|
|
433
|
+
def __next__(self):
|
|
434
|
+
try:
|
|
435
|
+
ev = next(self._inner)
|
|
436
|
+
except StopIteration:
|
|
437
|
+
self._finish()
|
|
438
|
+
raise
|
|
439
|
+
except Exception as ex:
|
|
440
|
+
self._finish(ex)
|
|
441
|
+
raise
|
|
442
|
+
self._see(ev)
|
|
443
|
+
return ev
|
|
444
|
+
|
|
445
|
+
def __aiter__(self):
|
|
446
|
+
return self
|
|
447
|
+
|
|
448
|
+
async def __anext__(self):
|
|
449
|
+
try:
|
|
450
|
+
ev = await self._inner.__anext__()
|
|
451
|
+
except StopAsyncIteration:
|
|
452
|
+
self._finish()
|
|
453
|
+
raise
|
|
454
|
+
except Exception as ex:
|
|
455
|
+
self._finish(ex)
|
|
456
|
+
raise
|
|
457
|
+
self._see(ev)
|
|
458
|
+
return ev
|
|
459
|
+
|
|
460
|
+
def _see(self, ev):
|
|
461
|
+
t = getattr(ev, "type", "")
|
|
462
|
+
if t == "message_start":
|
|
463
|
+
m = ev.message
|
|
464
|
+
self._model = getattr(m, "model", self._model)
|
|
465
|
+
u = m.usage
|
|
466
|
+
self._u.update(i=u.input_tokens or 0, cr=getattr(u, "cache_read_input_tokens", 0) or 0,
|
|
467
|
+
cw=getattr(u, "cache_creation_input_tokens", 0) or 0, o=u.output_tokens or 0)
|
|
468
|
+
elif t == "content_block_delta":
|
|
469
|
+
if self._ttft is None:
|
|
470
|
+
self._ttft = (time.time() - self._t0) * 1000
|
|
471
|
+
d = getattr(ev, "delta", None)
|
|
472
|
+
if getattr(d, "type", "") == "text_delta" and sum(map(len, self._text)) < 600:
|
|
473
|
+
self._text.append(d.text)
|
|
474
|
+
elif t == "message_delta":
|
|
475
|
+
self._stop = getattr(ev.delta, "stop_reason", None) or self._stop
|
|
476
|
+
if getattr(ev, "usage", None) is not None:
|
|
477
|
+
self._u["o"] = ev.usage.output_tokens or self._u["o"]
|
|
478
|
+
|
|
479
|
+
def _finish(self, err=None):
|
|
480
|
+
if not self._done:
|
|
481
|
+
self._done = True
|
|
482
|
+
_record_llm(self._model, self._u["i"], self._u["o"], self._u["cr"], self._u["cw"], self._stop, self._t0, time.time(),
|
|
483
|
+
"".join(self._text), err, self._ttft, "anthropic", self._handles)
|
|
484
|
+
|
|
485
|
+
def __enter__(self):
|
|
486
|
+
return self
|
|
487
|
+
|
|
488
|
+
def __exit__(self, *a):
|
|
489
|
+
self._finish()
|
|
490
|
+
return getattr(self._inner, "__exit__", lambda *x: False)(*a)
|
|
491
|
+
|
|
492
|
+
def __getattr__(self, k):
|
|
493
|
+
return getattr(self._inner, k)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _patch_anthropic():
|
|
497
|
+
try:
|
|
498
|
+
from anthropic.resources.messages import AsyncMessages, Messages
|
|
499
|
+
except ImportError:
|
|
500
|
+
return False
|
|
501
|
+
if "anthropic" in _patched:
|
|
502
|
+
return True
|
|
503
|
+
orig, aorig = Messages.create, AsyncMessages.create
|
|
504
|
+
|
|
505
|
+
@functools.wraps(orig)
|
|
506
|
+
def create(self, *a, **k):
|
|
507
|
+
t0 = time.time()
|
|
508
|
+
h = ()
|
|
509
|
+
try:
|
|
510
|
+
h = _llm_begin("anthropic", k.get("model"), k)
|
|
511
|
+
r = orig(self, *a, **k)
|
|
512
|
+
except Exception as ex:
|
|
513
|
+
_record_llm(k.get("model"), 0, 0, 0, 0, None, t0, time.time(), err=ex, provider="anthropic", handles=h)
|
|
514
|
+
raise
|
|
515
|
+
if k.get("stream"):
|
|
516
|
+
return _AnthropicStream(r, k.get("model"), t0, h)
|
|
517
|
+
it, ot, cr, cw, stop, text = _anthropic_usage(r)
|
|
518
|
+
_record_llm(getattr(r, "model", k.get("model")), it, ot, cr, cw, stop, t0, time.time(), text, provider="anthropic", handles=h)
|
|
519
|
+
return r
|
|
520
|
+
|
|
521
|
+
@functools.wraps(aorig)
|
|
522
|
+
async def acreate(self, *a, **k):
|
|
523
|
+
t0 = time.time()
|
|
524
|
+
h = ()
|
|
525
|
+
try:
|
|
526
|
+
h = _llm_begin("anthropic", k.get("model"), k)
|
|
527
|
+
r = await aorig(self, *a, **k)
|
|
528
|
+
except Exception as ex:
|
|
529
|
+
_record_llm(k.get("model"), 0, 0, 0, 0, None, t0, time.time(), err=ex, provider="anthropic", handles=h)
|
|
530
|
+
raise
|
|
531
|
+
if k.get("stream"):
|
|
532
|
+
return _AnthropicStream(r, k.get("model"), t0, h)
|
|
533
|
+
it, ot, cr, cw, stop, text = _anthropic_usage(r)
|
|
534
|
+
_record_llm(getattr(r, "model", k.get("model")), it, ot, cr, cw, stop, t0, time.time(), text, provider="anthropic", handles=h)
|
|
535
|
+
return r
|
|
536
|
+
|
|
537
|
+
Messages.create, AsyncMessages.create = create, acreate
|
|
538
|
+
_patched.add("anthropic")
|
|
539
|
+
return True
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# ---------------------------------------------------------------- OpenAI
|
|
543
|
+
|
|
544
|
+
def _openai_record(r, k, t0, err=None, handles=()):
|
|
545
|
+
if err is not None:
|
|
546
|
+
_record_llm(k.get("model"), 0, 0, 0, 0, None, t0, time.time(), err=err, provider="openai", handles=handles)
|
|
547
|
+
return
|
|
548
|
+
u = getattr(r, "usage", None)
|
|
549
|
+
it = getattr(u, "prompt_tokens", None) or getattr(u, "input_tokens", 0) or 0
|
|
550
|
+
ot = getattr(u, "completion_tokens", None) or getattr(u, "output_tokens", 0) or 0
|
|
551
|
+
det = getattr(u, "prompt_tokens_details", None) or getattr(u, "input_tokens_details", None)
|
|
552
|
+
cr = getattr(det, "cached_tokens", 0) or 0
|
|
553
|
+
stop, text = None, ""
|
|
554
|
+
if getattr(r, "choices", None):
|
|
555
|
+
stop = r.choices[0].finish_reason
|
|
556
|
+
text = getattr(r.choices[0].message, "content", "") or ""
|
|
557
|
+
elif hasattr(r, "output_text"):
|
|
558
|
+
stop = getattr(r, "status", None)
|
|
559
|
+
text = r.output_text or ""
|
|
560
|
+
_record_llm(getattr(r, "model", k.get("model")), max(0, it - cr), ot, cr, 0, stop, t0, time.time(), text, provider="openai",
|
|
561
|
+
handles=handles)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _patch_openai():
|
|
565
|
+
try:
|
|
566
|
+
from openai.resources.chat.completions import AsyncCompletions, Completions
|
|
567
|
+
from openai.resources.responses import AsyncResponses, Responses
|
|
568
|
+
except ImportError:
|
|
569
|
+
return False
|
|
570
|
+
if "openai" in _patched:
|
|
571
|
+
return True
|
|
572
|
+
|
|
573
|
+
def wrap(cls):
|
|
574
|
+
orig = cls.create
|
|
575
|
+
|
|
576
|
+
if inspect.iscoroutinefunction(orig):
|
|
577
|
+
@functools.wraps(orig)
|
|
578
|
+
async def acreate(self, *a, **k):
|
|
579
|
+
t0 = time.time()
|
|
580
|
+
h = ()
|
|
581
|
+
try:
|
|
582
|
+
h = _llm_begin("openai", k.get("model"), k)
|
|
583
|
+
r = await orig(self, *a, **k)
|
|
584
|
+
except Exception as ex:
|
|
585
|
+
_openai_record(None, k, t0, ex, h)
|
|
586
|
+
raise
|
|
587
|
+
if not k.get("stream"):
|
|
588
|
+
_openai_record(r, k, t0, handles=h)
|
|
589
|
+
return r
|
|
590
|
+
cls.create = acreate
|
|
591
|
+
else:
|
|
592
|
+
@functools.wraps(orig)
|
|
593
|
+
def create(self, *a, **k):
|
|
594
|
+
t0 = time.time()
|
|
595
|
+
h = ()
|
|
596
|
+
try:
|
|
597
|
+
h = _llm_begin("openai", k.get("model"), k)
|
|
598
|
+
r = orig(self, *a, **k)
|
|
599
|
+
except Exception as ex:
|
|
600
|
+
_openai_record(None, k, t0, ex, h)
|
|
601
|
+
raise
|
|
602
|
+
if not k.get("stream"):
|
|
603
|
+
_openai_record(r, k, t0, handles=h)
|
|
604
|
+
return r
|
|
605
|
+
cls.create = create
|
|
606
|
+
|
|
607
|
+
for c in (Completions, AsyncCompletions, Responses, AsyncResponses):
|
|
608
|
+
wrap(c)
|
|
609
|
+
_patched.add("openai")
|
|
610
|
+
return True
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# ---------------------------------------------------------------- LangChain / LangGraph, OpenTelemetry
|
|
614
|
+
|
|
615
|
+
def _setup_langchain():
|
|
616
|
+
import importlib.util
|
|
617
|
+
if not (importlib.util.find_spec("langsmith") or importlib.util.find_spec("langchain_core")):
|
|
618
|
+
return False
|
|
619
|
+
existing = os.environ.get("LANGSMITH_ENDPOINT") or os.environ.get("LANGCHAIN_ENDPOINT")
|
|
620
|
+
tracing_on = (os.environ.get("LANGSMITH_TRACING") or os.environ.get("LANGCHAIN_TRACING_V2") or "").lower() == "true"
|
|
621
|
+
if tracing_on and existing and not existing.startswith(_cfg["url"]):
|
|
622
|
+
_warn("ls", "LangSmith tracing already configured; leaving it alone. Add a 'langsmith_api' source to pull those runs.")
|
|
623
|
+
return "kept-langsmith"
|
|
624
|
+
ep = _cfg["url"] + "/langsmith"
|
|
625
|
+
for ns in ("LANGSMITH", "LANGCHAIN"):
|
|
626
|
+
os.environ[f"{ns}_ENDPOINT"] = ep
|
|
627
|
+
os.environ[f"{ns}_API_KEY"] = _cfg["key"] or "agentdynamics-local"
|
|
628
|
+
if _cfg["project"]:
|
|
629
|
+
os.environ[f"{ns}_PROJECT"] = _cfg["project"]
|
|
630
|
+
os.environ["LANGSMITH_TRACING"] = "true"
|
|
631
|
+
os.environ["LANGCHAIN_TRACING_V2"] = "true"
|
|
632
|
+
# clients created before init() captured the old settings
|
|
633
|
+
for mod, attr in (("langchain_core.tracers.langchain", "_CLIENT"),):
|
|
634
|
+
m = sys.modules.get(mod)
|
|
635
|
+
if m is not None and hasattr(m, attr):
|
|
636
|
+
setattr(m, attr, None)
|
|
637
|
+
lsu = sys.modules.get("langsmith.utils")
|
|
638
|
+
for fn in ("get_env_var", "get_tracer_project"):
|
|
639
|
+
f = getattr(lsu, fn, None) if lsu else None
|
|
640
|
+
if hasattr(f, "cache_clear"):
|
|
641
|
+
f.cache_clear()
|
|
642
|
+
return True
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _setup_otel():
|
|
646
|
+
try:
|
|
647
|
+
from opentelemetry import trace as ot
|
|
648
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
649
|
+
from opentelemetry.sdk.resources import Resource
|
|
650
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
651
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
652
|
+
except ImportError:
|
|
653
|
+
return False
|
|
654
|
+
headers = {"Authorization": f"Bearer {_cfg['key']}"} if _cfg["key"] else {}
|
|
655
|
+
exporter = OTLPSpanExporter(endpoint=_cfg["url"] + "/v1/traces", headers=headers)
|
|
656
|
+
provider = ot.get_tracer_provider()
|
|
657
|
+
if not hasattr(provider, "add_span_processor"):
|
|
658
|
+
attrs = {"service.name": _cfg["project"] or "agent"}
|
|
659
|
+
if _cfg["environment"]:
|
|
660
|
+
attrs["deployment.environment.name"] = _cfg["environment"]
|
|
661
|
+
provider = TracerProvider(resource=Resource.create(attrs))
|
|
662
|
+
ot.set_tracer_provider(provider)
|
|
663
|
+
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
664
|
+
return True
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# ---------------------------------------------------------------- entry point
|
|
668
|
+
|
|
669
|
+
def init(url=None, api_key=None, project=None, environment=None, capture_content=None,
|
|
670
|
+
langchain=True, otel=True, anthropic=True, openai=True, quiet=False):
|
|
671
|
+
"""Connect this process to AgentDynamics. Safe to call more than once."""
|
|
672
|
+
if os.environ.get("AGENTDYNAMICS_DISABLED") in ("1", "true"):
|
|
673
|
+
return {}
|
|
674
|
+
env = os.environ.get
|
|
675
|
+
_cfg["url"] = (url or env("AGENTDYNAMICS_URL") or "http://127.0.0.1:8787").rstrip("/")
|
|
676
|
+
_cfg["key"] = api_key or env("AGENTDYNAMICS_API_KEY")
|
|
677
|
+
_cfg["project"] = project or env("AGENTDYNAMICS_PROJECT") or os.path.basename(os.getcwd())
|
|
678
|
+
_cfg["environment"] = environment or env("AGENTDYNAMICS_ENVIRONMENT") or "development"
|
|
679
|
+
cc = capture_content if capture_content is not None else env("AGENTDYNAMICS_CAPTURE_CONTENT", "1") not in ("0", "false")
|
|
680
|
+
_cfg["content"] = cc
|
|
681
|
+
_cfg["on"] = True
|
|
682
|
+
global _sender
|
|
683
|
+
if _sender is None:
|
|
684
|
+
_sender = threading.Thread(target=_send_loop, daemon=True, name="agentdynamics-sender")
|
|
685
|
+
_sender.start()
|
|
686
|
+
atexit.register(flush)
|
|
687
|
+
done = {
|
|
688
|
+
"langchain": _setup_langchain() if langchain else False,
|
|
689
|
+
"opentelemetry": _setup_otel() if otel else False,
|
|
690
|
+
"anthropic": _patch_anthropic() if anthropic else False,
|
|
691
|
+
"openai": _patch_openai() if openai else False,
|
|
692
|
+
}
|
|
693
|
+
if not quiet:
|
|
694
|
+
on = [k for k, v in done.items() if v]
|
|
695
|
+
print(f"[agentdynamics] sending to {_cfg['url']} (project '{_cfg['project']}') · instrumented: {', '.join(on) or 'nothing detected'}",
|
|
696
|
+
file=sys.stderr)
|
|
697
|
+
return done
|