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