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.
- agenthandler/__init__.py +163 -0
- agenthandler/approval.py +135 -0
- agenthandler/audit.py +144 -0
- agenthandler/auth.py +188 -0
- agenthandler/budget.py +94 -0
- agenthandler/builder.py +178 -0
- agenthandler/circuit_breaker.py +88 -0
- agenthandler/connectors.py +425 -0
- agenthandler/context.py +410 -0
- agenthandler/errors.py +130 -0
- agenthandler/guardrails/__init__.py +79 -0
- agenthandler/guardrails/deterministic.py +472 -0
- agenthandler/guardrails/llm_based.py +549 -0
- agenthandler/guardrails/types.py +83 -0
- agenthandler/observe.py +525 -0
- agenthandler/openclaw.py +370 -0
- agenthandler/pipeline.py +532 -0
- agenthandler/pipeline_poml.py +593 -0
- agenthandler/policy.py +251 -0
- agenthandler/py.typed +0 -0
- agenthandler/redact.py +260 -0
- agenthandler/reflection.py +371 -0
- agenthandler/replay.py +225 -0
- agenthandler/server.py +752 -0
- agenthandler/session.py +391 -0
- agenthandler/skill.py +265 -0
- agenthandler/store.py +341 -0
- agenthandler/streams.py +339 -0
- agenthandler/supervisor.py +882 -0
- agenthandler/triggers.py +471 -0
- agenthandler-0.92.0.dist-info/METADATA +1355 -0
- agenthandler-0.92.0.dist-info/RECORD +34 -0
- agenthandler-0.92.0.dist-info/WHEEL +4 -0
- agenthandler-0.92.0.dist-info/licenses/LICENSE +201 -0
agenthandler/observe.py
ADDED
|
@@ -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
|