agenthandler 0.92.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.

Potentially problematic release.


This version of agenthandler might be problematic. Click here for more details.

@@ -0,0 +1,525 @@
1
+ """Agent observability — structured telemetry for tool calls and sessions.
2
+
3
+ Collects per-tool and per-session metrics: latency, success/failure rates,
4
+ token counts, dollar cost. Optional OpenTelemetry export for Datadog, Grafana,
5
+ Honeycomb, etc.
6
+
7
+ Zero overhead when disabled. No required dependencies.
8
+
9
+ Usage:
10
+ from agenthandler.observe import Observer, PricingTable
11
+
12
+ # Optional: configure pricing for real dollar cost attribution
13
+ pricing = PricingTable.default() # or PricingTable(models={...})
14
+
15
+ obs = Observer(pricing=pricing)
16
+ obs.record_tool_call(
17
+ "llm_call",
18
+ duration_ms=120,
19
+ succeeded=True,
20
+ input_tokens=500,
21
+ output_tokens=200,
22
+ model="claude-sonnet-4-6",
23
+ )
24
+
25
+ summary = obs.tool_summary("llm_call")
26
+ # {"calls": 1, "total_input_tokens": 500, "total_cost_usd": 0.0035, ...}
27
+
28
+ OpenTelemetry (optional):
29
+ obs = Observer(otlp_endpoint="http://localhost:4317")
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import threading
35
+ import time
36
+ from collections import defaultdict
37
+ from dataclasses import dataclass, field
38
+ from typing import Any, Dict, List, Optional
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Pricing table
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ @dataclass
46
+ class ModelPricing:
47
+ """Pricing for one model, in USD per million tokens."""
48
+
49
+ input_per_million: float = 0.0
50
+ output_per_million: float = 0.0
51
+
52
+ def cost_usd(self, input_tokens: int, output_tokens: int) -> float:
53
+ return (input_tokens / 1_000_000) * self.input_per_million + (
54
+ output_tokens / 1_000_000
55
+ ) * self.output_per_million
56
+
57
+
58
+ @dataclass
59
+ class PricingTable:
60
+ """Model → pricing map, with a default fallback.
61
+
62
+ Use ``PricingTable.default()`` for a built-in table covering common models,
63
+ or construct your own with ``PricingTable(models={...})``.
64
+ """
65
+
66
+ models: Dict[str, ModelPricing] = field(default_factory=dict)
67
+ default_model: str = "claude-sonnet-4-6"
68
+
69
+ @classmethod
70
+ def default(cls) -> "PricingTable":
71
+ """Built-in pricing for common models (USD per million tokens).
72
+
73
+ Approximate pricing as of 2026. Update as needed for your use case.
74
+ """
75
+ return cls(
76
+ models={
77
+ # Anthropic
78
+ "claude-opus-4-6": ModelPricing(input_per_million=15.0, output_per_million=75.0),
79
+ "claude-sonnet-4-6": ModelPricing(input_per_million=3.0, output_per_million=15.0),
80
+ "claude-haiku-4-5": ModelPricing(input_per_million=0.25, output_per_million=1.25),
81
+ # OpenAI
82
+ "gpt-4o": ModelPricing(input_per_million=2.5, output_per_million=10.0),
83
+ "gpt-4o-mini": ModelPricing(input_per_million=0.15, output_per_million=0.6),
84
+ "gpt-4-turbo": ModelPricing(input_per_million=10.0, output_per_million=30.0),
85
+ # Google
86
+ "gemini-1.5-pro": ModelPricing(input_per_million=1.25, output_per_million=5.0),
87
+ "gemini-1.5-flash": ModelPricing(input_per_million=0.075, output_per_million=0.3),
88
+ }
89
+ )
90
+
91
+ def cost_usd(self, model: str, input_tokens: int, output_tokens: int) -> float:
92
+ """Compute dollar cost for a tool call. Returns 0 if model not in table."""
93
+ pricing = self.models.get(model)
94
+ if pricing is None:
95
+ return 0.0
96
+ return pricing.cost_usd(input_tokens, output_tokens)
97
+
98
+ def set_model(self, model: str, input_per_million: float, output_per_million: float) -> None:
99
+ """Add or update a model's pricing."""
100
+ self.models[model] = ModelPricing(input_per_million, output_per_million)
101
+
102
+ def to_dict(self) -> Dict[str, Any]:
103
+ return {
104
+ "models": {
105
+ name: {
106
+ "input_per_million": p.input_per_million,
107
+ "output_per_million": p.output_per_million,
108
+ }
109
+ for name, p in self.models.items()
110
+ },
111
+ "default_model": self.default_model,
112
+ }
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Records and metrics
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ @dataclass
121
+ class ToolCallRecord:
122
+ """A single observed tool call."""
123
+
124
+ tool_name: str
125
+ session_id: str = ""
126
+ agent_id: str = ""
127
+ duration_ms: int = 0
128
+ succeeded: bool = True
129
+ input_tokens: int = 0
130
+ output_tokens: int = 0
131
+ cost_usd: float = 0.0
132
+ model: str = ""
133
+ timestamp: float = 0.0
134
+ error_kind: str = ""
135
+
136
+ @property
137
+ def tokens(self) -> int:
138
+ """Backward-compat: total tokens (input + output)."""
139
+ return self.input_tokens + self.output_tokens
140
+
141
+ def __post_init__(self) -> None:
142
+ if not self.timestamp:
143
+ self.timestamp = time.time()
144
+
145
+
146
+ @dataclass
147
+ class ToolMetrics:
148
+ """Aggregated metrics for a single tool."""
149
+
150
+ calls: int = 0
151
+ successes: int = 0
152
+ failures: int = 0
153
+ total_latency_ms: int = 0
154
+ total_input_tokens: int = 0
155
+ total_output_tokens: int = 0
156
+ total_cost_usd: float = 0.0
157
+ min_latency_ms: int = 0
158
+ max_latency_ms: int = 0
159
+ error_counts: Dict[str, int] = field(default_factory=dict)
160
+
161
+ @property
162
+ def total_tokens(self) -> int:
163
+ return self.total_input_tokens + self.total_output_tokens
164
+
165
+ @property
166
+ def avg_latency_ms(self) -> float:
167
+ return self.total_latency_ms / self.calls if self.calls > 0 else 0.0
168
+
169
+ @property
170
+ def success_rate(self) -> float:
171
+ return self.successes / self.calls if self.calls > 0 else 0.0
172
+
173
+ @property
174
+ def avg_cost_usd(self) -> float:
175
+ return self.total_cost_usd / self.calls if self.calls > 0 else 0.0
176
+
177
+ def to_dict(self) -> Dict[str, Any]:
178
+ return {
179
+ "calls": self.calls,
180
+ "successes": self.successes,
181
+ "failures": self.failures,
182
+ "success_rate": round(self.success_rate, 4),
183
+ "avg_latency_ms": round(self.avg_latency_ms, 2),
184
+ "min_latency_ms": self.min_latency_ms,
185
+ "max_latency_ms": self.max_latency_ms,
186
+ "total_tokens": self.total_tokens,
187
+ "total_input_tokens": self.total_input_tokens,
188
+ "total_output_tokens": self.total_output_tokens,
189
+ "total_cost_usd": round(self.total_cost_usd, 6),
190
+ "avg_cost_usd": round(self.avg_cost_usd, 6),
191
+ "error_counts": dict(self.error_counts),
192
+ }
193
+
194
+
195
+ @dataclass
196
+ class SessionMetrics:
197
+ """Aggregated metrics for a session."""
198
+
199
+ session_id: str
200
+ total_calls: int = 0
201
+ total_input_tokens: int = 0
202
+ total_output_tokens: int = 0
203
+ total_cost_usd: float = 0.0
204
+ total_duration_ms: int = 0
205
+ tool_metrics: Dict[str, ToolMetrics] = field(default_factory=dict)
206
+
207
+ @property
208
+ def total_tokens(self) -> int:
209
+ return self.total_input_tokens + self.total_output_tokens
210
+
211
+ def to_dict(self) -> Dict[str, Any]:
212
+ return {
213
+ "session_id": self.session_id,
214
+ "total_calls": self.total_calls,
215
+ "total_tokens": self.total_tokens,
216
+ "total_input_tokens": self.total_input_tokens,
217
+ "total_output_tokens": self.total_output_tokens,
218
+ "total_cost_usd": round(self.total_cost_usd, 6),
219
+ "total_duration_ms": self.total_duration_ms,
220
+ "tools": {k: v.to_dict() for k, v in self.tool_metrics.items()},
221
+ }
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Observer
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ class Observer:
230
+ """Collects and aggregates tool call telemetry.
231
+
232
+ Thread-safe. Attach to a Supervisor or use standalone.
233
+
234
+ Args:
235
+ otlp_endpoint: If set, export spans/metrics via OpenTelemetry.
236
+ Requires opentelemetry-sdk (optional dep).
237
+ Example: "http://localhost:4317"
238
+ pricing: Optional PricingTable for dollar cost attribution.
239
+ Without it, costs are always 0.0.
240
+ """
241
+
242
+ def __init__(
243
+ self,
244
+ otlp_endpoint: Optional[str] = None,
245
+ pricing: Optional[PricingTable] = None,
246
+ ):
247
+ self._records: List[ToolCallRecord] = []
248
+ self._lock = threading.Lock()
249
+ self._otel_tracer = None
250
+ self._otel_meter = None
251
+ self._pricing = pricing
252
+
253
+ if otlp_endpoint:
254
+ self._init_otel(otlp_endpoint)
255
+
256
+ @property
257
+ def pricing(self) -> Optional[PricingTable]:
258
+ return self._pricing
259
+
260
+ def set_pricing(self, pricing: PricingTable) -> None:
261
+ self._pricing = pricing
262
+
263
+ def _init_otel(self, endpoint: str) -> None:
264
+ """Initialize OpenTelemetry tracer and meter. Fails silently if not installed."""
265
+ try:
266
+ from opentelemetry import metrics, trace # type: ignore[import-not-found]
267
+ from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( # type: ignore[import-not-found]
268
+ OTLPMetricExporter,
269
+ )
270
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # type: ignore[import-not-found]
271
+ OTLPSpanExporter,
272
+ )
273
+ from opentelemetry.sdk.metrics import MeterProvider # type: ignore[import-not-found]
274
+ from opentelemetry.sdk.metrics.export import ( # type: ignore[import-not-found]
275
+ PeriodicExportingMetricReader,
276
+ )
277
+ from opentelemetry.sdk.resources import Resource # type: ignore[import-not-found]
278
+ from opentelemetry.sdk.trace import TracerProvider # type: ignore[import-not-found]
279
+ from opentelemetry.sdk.trace.export import ( # type: ignore[import-not-found]
280
+ BatchSpanProcessor,
281
+ )
282
+
283
+ resource = Resource.create({"service.name": "agenthandler"})
284
+
285
+ tracer_provider = TracerProvider(resource=resource)
286
+ tracer_provider.add_span_processor(
287
+ BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))
288
+ )
289
+ trace.set_tracer_provider(tracer_provider)
290
+ self._otel_tracer = trace.get_tracer("agenthandler")
291
+
292
+ reader = PeriodicExportingMetricReader(OTLPMetricExporter(endpoint=endpoint))
293
+ meter_provider = MeterProvider(resource=resource, metric_readers=[reader])
294
+ metrics.set_meter_provider(meter_provider)
295
+ self._otel_meter = metrics.get_meter("agenthandler")
296
+ except ImportError:
297
+ pass
298
+
299
+ def record_tool_call(
300
+ self,
301
+ tool_name: str,
302
+ duration_ms: int = 0,
303
+ succeeded: bool = True,
304
+ tokens: int = 0,
305
+ session_id: str = "",
306
+ agent_id: str = "",
307
+ error_kind: str = "",
308
+ input_tokens: Optional[int] = None,
309
+ output_tokens: Optional[int] = None,
310
+ model: str = "",
311
+ ) -> None:
312
+ """Record a tool call observation.
313
+
314
+ Backward-compatible: if input_tokens/output_tokens aren't provided,
315
+ the legacy ``tokens`` value is treated as output tokens.
316
+ """
317
+ if input_tokens is None and output_tokens is None:
318
+ # Legacy path — single tokens value counts as output
319
+ in_tok = 0
320
+ out_tok = tokens
321
+ else:
322
+ in_tok = input_tokens or 0
323
+ out_tok = output_tokens or 0
324
+
325
+ cost = 0.0
326
+ if self._pricing and model:
327
+ cost = self._pricing.cost_usd(model, in_tok, out_tok)
328
+
329
+ record = ToolCallRecord(
330
+ tool_name=tool_name,
331
+ session_id=session_id,
332
+ agent_id=agent_id,
333
+ duration_ms=duration_ms,
334
+ succeeded=succeeded,
335
+ input_tokens=in_tok,
336
+ output_tokens=out_tok,
337
+ cost_usd=cost,
338
+ model=model,
339
+ error_kind=error_kind,
340
+ )
341
+ with self._lock:
342
+ self._records.append(record)
343
+
344
+ if self._otel_tracer:
345
+ with self._otel_tracer.start_as_current_span(f"tool_call.{tool_name}") as span:
346
+ span.set_attribute("tool.name", tool_name)
347
+ span.set_attribute("tool.duration_ms", duration_ms)
348
+ span.set_attribute("tool.succeeded", succeeded)
349
+ span.set_attribute("tool.input_tokens", in_tok)
350
+ span.set_attribute("tool.output_tokens", out_tok)
351
+ span.set_attribute("tool.cost_usd", cost)
352
+ if model:
353
+ span.set_attribute("tool.model", model)
354
+ if session_id:
355
+ span.set_attribute("session.id", session_id)
356
+ if error_kind:
357
+ span.set_attribute("error.kind", error_kind)
358
+
359
+ def tool_summary(self, tool_name: str) -> Dict[str, Any]:
360
+ """Get aggregated metrics for a specific tool."""
361
+ with self._lock:
362
+ records = [r for r in self._records if r.tool_name == tool_name]
363
+ return self._aggregate(records).to_dict()
364
+
365
+ def session_summary(self, session_id: str) -> Dict[str, Any]:
366
+ """Get aggregated metrics for a specific session."""
367
+ with self._lock:
368
+ records = [r for r in self._records if r.session_id == session_id]
369
+
370
+ metrics = SessionMetrics(session_id=session_id)
371
+ by_tool: Dict[str, List[ToolCallRecord]] = defaultdict(list)
372
+ for r in records:
373
+ by_tool[r.tool_name].append(r)
374
+ metrics.total_calls += 1
375
+ metrics.total_input_tokens += r.input_tokens
376
+ metrics.total_output_tokens += r.output_tokens
377
+ metrics.total_cost_usd += r.cost_usd
378
+ metrics.total_duration_ms += r.duration_ms
379
+
380
+ for name, tool_records in by_tool.items():
381
+ metrics.tool_metrics[name] = self._aggregate(tool_records)
382
+
383
+ return metrics.to_dict()
384
+
385
+ def all_tools_summary(self) -> Dict[str, Dict[str, Any]]:
386
+ """Get aggregated metrics for all tools."""
387
+ with self._lock:
388
+ records = list(self._records)
389
+
390
+ by_tool: Dict[str, List[ToolCallRecord]] = defaultdict(list)
391
+ for r in records:
392
+ by_tool[r.tool_name].append(r)
393
+
394
+ return {name: self._aggregate(recs).to_dict() for name, recs in by_tool.items()}
395
+
396
+ def cost_summary(self) -> Dict[str, Any]:
397
+ """Total cost breakdown by agent, session, and tool.
398
+
399
+ Returns a dict suitable for the dashboard Costs tab.
400
+ """
401
+ with self._lock:
402
+ records = list(self._records)
403
+
404
+ total_cost = sum(r.cost_usd for r in records)
405
+ total_input = sum(r.input_tokens for r in records)
406
+ total_output = sum(r.output_tokens for r in records)
407
+
408
+ # By agent
409
+ by_agent: Dict[str, Dict[str, Any]] = {}
410
+ for r in records:
411
+ key = r.agent_id or "(unknown)"
412
+ if key not in by_agent:
413
+ by_agent[key] = {"agent": key, "calls": 0, "tokens": 0, "cost": 0.0}
414
+ by_agent[key]["calls"] += 1
415
+ by_agent[key]["tokens"] += r.input_tokens + r.output_tokens
416
+ by_agent[key]["cost"] += r.cost_usd
417
+
418
+ # By session
419
+ by_session: Dict[str, Dict[str, Any]] = {}
420
+ for r in records:
421
+ key = r.session_id or "(unknown)"
422
+ if key not in by_session:
423
+ by_session[key] = {"session_id": key, "calls": 0, "tokens": 0, "cost": 0.0}
424
+ by_session[key]["calls"] += 1
425
+ by_session[key]["tokens"] += r.input_tokens + r.output_tokens
426
+ by_session[key]["cost"] += r.cost_usd
427
+
428
+ # By tool
429
+ by_tool: Dict[str, Dict[str, Any]] = {}
430
+ for r in records:
431
+ key = r.tool_name
432
+ if key not in by_tool:
433
+ by_tool[key] = {"tool": key, "calls": 0, "tokens": 0, "cost": 0.0}
434
+ by_tool[key]["calls"] += 1
435
+ by_tool[key]["tokens"] += r.input_tokens + r.output_tokens
436
+ by_tool[key]["cost"] += r.cost_usd
437
+
438
+ # By model
439
+ by_model: Dict[str, Dict[str, Any]] = {}
440
+ for r in records:
441
+ if not r.model:
442
+ continue
443
+ key = r.model
444
+ if key not in by_model:
445
+ by_model[key] = {
446
+ "model": key,
447
+ "calls": 0,
448
+ "input_tokens": 0,
449
+ "output_tokens": 0,
450
+ "cost": 0.0,
451
+ }
452
+ by_model[key]["calls"] += 1
453
+ by_model[key]["input_tokens"] += r.input_tokens
454
+ by_model[key]["output_tokens"] += r.output_tokens
455
+ by_model[key]["cost"] += r.cost_usd
456
+
457
+ return {
458
+ "total_cost_usd": round(total_cost, 6),
459
+ "total_input_tokens": total_input,
460
+ "total_output_tokens": total_output,
461
+ "total_tokens": total_input + total_output,
462
+ "total_calls": len(records),
463
+ "by_agent": [
464
+ {**v, "cost": round(v["cost"], 6)}
465
+ for v in sorted(by_agent.values(), key=lambda x: -x["cost"])
466
+ ],
467
+ "by_session": [
468
+ {**v, "cost": round(v["cost"], 6)}
469
+ for v in sorted(by_session.values(), key=lambda x: -x["cost"])
470
+ ],
471
+ "by_tool": [
472
+ {**v, "cost": round(v["cost"], 6)}
473
+ for v in sorted(by_tool.values(), key=lambda x: -x["cost"])
474
+ ],
475
+ "by_model": [
476
+ {**v, "cost": round(v["cost"], 6)}
477
+ for v in sorted(by_model.values(), key=lambda x: -x["cost"])
478
+ ],
479
+ }
480
+
481
+ def recent_calls(self, limit: int = 50) -> List[Dict[str, Any]]:
482
+ """Get the most recent tool call records."""
483
+ with self._lock:
484
+ records = self._records[-limit:]
485
+ return [
486
+ {
487
+ "tool_name": r.tool_name,
488
+ "session_id": r.session_id,
489
+ "agent_id": r.agent_id,
490
+ "duration_ms": r.duration_ms,
491
+ "succeeded": r.succeeded,
492
+ "input_tokens": r.input_tokens,
493
+ "output_tokens": r.output_tokens,
494
+ "tokens": r.tokens,
495
+ "cost_usd": round(r.cost_usd, 6),
496
+ "model": r.model,
497
+ "error_kind": r.error_kind,
498
+ "timestamp": r.timestamp,
499
+ }
500
+ for r in reversed(records)
501
+ ]
502
+
503
+ @staticmethod
504
+ def _aggregate(records: List[ToolCallRecord]) -> ToolMetrics:
505
+ if not records:
506
+ return ToolMetrics()
507
+ m = ToolMetrics()
508
+ m.min_latency_ms = records[0].duration_ms
509
+ for r in records:
510
+ m.calls += 1
511
+ m.total_latency_ms += r.duration_ms
512
+ m.total_input_tokens += r.input_tokens
513
+ m.total_output_tokens += r.output_tokens
514
+ m.total_cost_usd += r.cost_usd
515
+ if r.succeeded:
516
+ m.successes += 1
517
+ else:
518
+ m.failures += 1
519
+ if r.error_kind:
520
+ m.error_counts[r.error_kind] = m.error_counts.get(r.error_kind, 0) + 1
521
+ if r.duration_ms < m.min_latency_ms:
522
+ m.min_latency_ms = r.duration_ms
523
+ if r.duration_ms > m.max_latency_ms:
524
+ m.max_latency_ms = r.duration_ms
525
+ return m