dnorio-agent-meter 0.1.0__tar.gz
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 dnorio-agent-meter might be problematic. Click here for more details.
- dnorio_agent_meter-0.1.0/.gitignore +1 -0
- dnorio_agent_meter-0.1.0/PKG-INFO +115 -0
- dnorio_agent_meter-0.1.0/README.md +97 -0
- dnorio_agent_meter-0.1.0/agent_meter/__init__.py +7 -0
- dnorio_agent_meter-0.1.0/agent_meter/client.py +192 -0
- dnorio_agent_meter-0.1.0/agent_meter/types.py +77 -0
- dnorio_agent_meter-0.1.0/pyproject.toml +28 -0
- dnorio_agent_meter-0.1.0/tests/__init__.py +0 -0
- dnorio_agent_meter-0.1.0/tests/test_sdk.py +69 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__pycache__/
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dnorio-agent-meter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight SDK to track AI agent tool calls, costs, and latency.
|
|
5
|
+
Project-URL: Homepage, https://github.com/dnorio/agent-meter
|
|
6
|
+
Project-URL: Repository, https://github.com/dnorio/agent-meter
|
|
7
|
+
Author-email: dnorio <dnorio@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: agents,ai,llm,observability,telemetry
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: httpx>=0.24
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# agent-meter
|
|
20
|
+
|
|
21
|
+
Lightweight Python SDK to track AI agent tool calls, costs, and latency.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install agentmeter-obs
|
|
27
|
+
|
|
28
|
+
# From source:
|
|
29
|
+
cd sdk/python && python3 -m pip install -e .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start (60 seconds)
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from agent_meter import AgentMeter
|
|
36
|
+
|
|
37
|
+
# Initialize (picks up AGENT_METER_API_KEY from env)
|
|
38
|
+
am = AgentMeter(api_key="am_live_...")
|
|
39
|
+
|
|
40
|
+
# Track a tool call
|
|
41
|
+
span = am.track("web_search", model="gpt-4o")
|
|
42
|
+
result = do_web_search(query)
|
|
43
|
+
span.finish()
|
|
44
|
+
|
|
45
|
+
# Track with tokens/cost
|
|
46
|
+
span = am.track("code_review", model="claude-sonnet-4-20250514")
|
|
47
|
+
span.estimated_input_tokens = 2000
|
|
48
|
+
span.estimated_output_tokens = 500
|
|
49
|
+
span.usd_cost = 0.012
|
|
50
|
+
span.finish()
|
|
51
|
+
|
|
52
|
+
# Flush happens automatically every 5s, or on exit
|
|
53
|
+
# Force flush:
|
|
54
|
+
am.flush()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
| Env Variable | Description | Default |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `AGENT_METER_API_KEY` | API key (`am_live_...`) | — |
|
|
62
|
+
| `AGENT_METER_ENDPOINT` | Server URL | `http://localhost:8081` |
|
|
63
|
+
| `AGENT_METER_IDE` | IDE identifier | `python-sdk` |
|
|
64
|
+
| `AGENT_METER_AGENT` | Agent name | — |
|
|
65
|
+
|
|
66
|
+
Or pass directly:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
am = AgentMeter(
|
|
70
|
+
api_key="am_live_...",
|
|
71
|
+
endpoint="http://localhost:3000",
|
|
72
|
+
ide="my-custom-agent",
|
|
73
|
+
agent="research-bot",
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Context Manager
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import contextlib
|
|
81
|
+
|
|
82
|
+
@contextlib.contextmanager
|
|
83
|
+
def tracked(am, tool_name, **kwargs):
|
|
84
|
+
span = am.track(tool_name, **kwargs)
|
|
85
|
+
try:
|
|
86
|
+
yield span
|
|
87
|
+
except Exception as e:
|
|
88
|
+
span.finish(ok=False, error=str(e))
|
|
89
|
+
raise
|
|
90
|
+
else:
|
|
91
|
+
span.finish()
|
|
92
|
+
|
|
93
|
+
# Usage
|
|
94
|
+
with tracked(am, "database_query", model="gpt-4o") as span:
|
|
95
|
+
results = db.execute(sql)
|
|
96
|
+
span.estimated_input_tokens = len(sql) // 4
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Conversations & Tasks
|
|
100
|
+
|
|
101
|
+
Group spans into conversations (sessions) and tasks:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
span = am.track(
|
|
105
|
+
"generate_code",
|
|
106
|
+
model="claude-sonnet-4-20250514",
|
|
107
|
+
conversation_id="conv-abc123",
|
|
108
|
+
task_id="implement-feature-x",
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Protocol
|
|
113
|
+
|
|
114
|
+
The SDK sends spans as OTLP-compatible JSON to `POST /v1/traces`. This is the
|
|
115
|
+
same format used by OpenTelemetry, making it compatible with any OTLP collector.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# agent-meter
|
|
2
|
+
|
|
3
|
+
Lightweight Python SDK to track AI agent tool calls, costs, and latency.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install agentmeter-obs
|
|
9
|
+
|
|
10
|
+
# From source:
|
|
11
|
+
cd sdk/python && python3 -m pip install -e .
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick Start (60 seconds)
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from agent_meter import AgentMeter
|
|
18
|
+
|
|
19
|
+
# Initialize (picks up AGENT_METER_API_KEY from env)
|
|
20
|
+
am = AgentMeter(api_key="am_live_...")
|
|
21
|
+
|
|
22
|
+
# Track a tool call
|
|
23
|
+
span = am.track("web_search", model="gpt-4o")
|
|
24
|
+
result = do_web_search(query)
|
|
25
|
+
span.finish()
|
|
26
|
+
|
|
27
|
+
# Track with tokens/cost
|
|
28
|
+
span = am.track("code_review", model="claude-sonnet-4-20250514")
|
|
29
|
+
span.estimated_input_tokens = 2000
|
|
30
|
+
span.estimated_output_tokens = 500
|
|
31
|
+
span.usd_cost = 0.012
|
|
32
|
+
span.finish()
|
|
33
|
+
|
|
34
|
+
# Flush happens automatically every 5s, or on exit
|
|
35
|
+
# Force flush:
|
|
36
|
+
am.flush()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
| Env Variable | Description | Default |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `AGENT_METER_API_KEY` | API key (`am_live_...`) | — |
|
|
44
|
+
| `AGENT_METER_ENDPOINT` | Server URL | `http://localhost:8081` |
|
|
45
|
+
| `AGENT_METER_IDE` | IDE identifier | `python-sdk` |
|
|
46
|
+
| `AGENT_METER_AGENT` | Agent name | — |
|
|
47
|
+
|
|
48
|
+
Or pass directly:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
am = AgentMeter(
|
|
52
|
+
api_key="am_live_...",
|
|
53
|
+
endpoint="http://localhost:3000",
|
|
54
|
+
ide="my-custom-agent",
|
|
55
|
+
agent="research-bot",
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Context Manager
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import contextlib
|
|
63
|
+
|
|
64
|
+
@contextlib.contextmanager
|
|
65
|
+
def tracked(am, tool_name, **kwargs):
|
|
66
|
+
span = am.track(tool_name, **kwargs)
|
|
67
|
+
try:
|
|
68
|
+
yield span
|
|
69
|
+
except Exception as e:
|
|
70
|
+
span.finish(ok=False, error=str(e))
|
|
71
|
+
raise
|
|
72
|
+
else:
|
|
73
|
+
span.finish()
|
|
74
|
+
|
|
75
|
+
# Usage
|
|
76
|
+
with tracked(am, "database_query", model="gpt-4o") as span:
|
|
77
|
+
results = db.execute(sql)
|
|
78
|
+
span.estimated_input_tokens = len(sql) // 4
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Conversations & Tasks
|
|
82
|
+
|
|
83
|
+
Group spans into conversations (sessions) and tasks:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
span = am.track(
|
|
87
|
+
"generate_code",
|
|
88
|
+
model="claude-sonnet-4-20250514",
|
|
89
|
+
conversation_id="conv-abc123",
|
|
90
|
+
task_id="implement-feature-x",
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Protocol
|
|
95
|
+
|
|
96
|
+
The SDK sends spans as OTLP-compatible JSON to `POST /v1/traces`. This is the
|
|
97
|
+
same format used by OpenTelemetry, making it compatible with any OTLP collector.
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Agent Meter client — batches and flushes tool calls via OTLP JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from agent_meter.types import ToolCall
|
|
14
|
+
|
|
15
|
+
_DEFAULT_ENDPOINT = "http://localhost:8081"
|
|
16
|
+
_FLUSH_INTERVAL = 5.0 # seconds
|
|
17
|
+
_MAX_BATCH = 100
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AgentMeter:
|
|
21
|
+
"""Lightweight client that buffers tool calls and flushes to agent-meter.
|
|
22
|
+
|
|
23
|
+
Usage::
|
|
24
|
+
|
|
25
|
+
from agent_meter import AgentMeter
|
|
26
|
+
|
|
27
|
+
am = AgentMeter(api_key="am_live_...")
|
|
28
|
+
span = am.track("my_tool", model="gpt-4o")
|
|
29
|
+
# ... do work ...
|
|
30
|
+
span.finish()
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: Optional[str] = None,
|
|
36
|
+
endpoint: Optional[str] = None,
|
|
37
|
+
ide: Optional[str] = None,
|
|
38
|
+
agent: Optional[str] = None,
|
|
39
|
+
flush_interval: float = _FLUSH_INTERVAL,
|
|
40
|
+
):
|
|
41
|
+
self._api_key = api_key or os.environ.get("AGENT_METER_API_KEY", "")
|
|
42
|
+
self._endpoint = (
|
|
43
|
+
endpoint or os.environ.get("AGENT_METER_ENDPOINT", _DEFAULT_ENDPOINT)
|
|
44
|
+
).rstrip("/")
|
|
45
|
+
self._ide = ide or os.environ.get("AGENT_METER_IDE", "python-sdk")
|
|
46
|
+
self._agent = agent or os.environ.get("AGENT_METER_AGENT")
|
|
47
|
+
self._buffer: list[ToolCall] = []
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
self._flush_interval = flush_interval
|
|
50
|
+
self._closed = False
|
|
51
|
+
|
|
52
|
+
# Background flush thread
|
|
53
|
+
self._timer: Optional[threading.Timer] = None
|
|
54
|
+
self._schedule_flush()
|
|
55
|
+
atexit.register(self.shutdown)
|
|
56
|
+
|
|
57
|
+
def track(
|
|
58
|
+
self,
|
|
59
|
+
tool_name: str,
|
|
60
|
+
*,
|
|
61
|
+
model: Optional[str] = None,
|
|
62
|
+
mcp_server: Optional[str] = None,
|
|
63
|
+
conversation_id: Optional[str] = None,
|
|
64
|
+
task_id: Optional[str] = None,
|
|
65
|
+
parent_span_id: Optional[str] = None,
|
|
66
|
+
) -> ToolCall:
|
|
67
|
+
"""Start tracking a tool call. Call .finish() when done."""
|
|
68
|
+
span = ToolCall(
|
|
69
|
+
tool_name=tool_name,
|
|
70
|
+
model=model,
|
|
71
|
+
mcp_server=mcp_server,
|
|
72
|
+
ide=self._ide,
|
|
73
|
+
agent=self._agent,
|
|
74
|
+
conversation_id=conversation_id,
|
|
75
|
+
task_id=task_id,
|
|
76
|
+
parent_span_id=parent_span_id,
|
|
77
|
+
)
|
|
78
|
+
with self._lock:
|
|
79
|
+
self._buffer.append(span)
|
|
80
|
+
return span
|
|
81
|
+
|
|
82
|
+
def flush(self) -> int:
|
|
83
|
+
"""Flush buffered spans to the server. Returns count sent."""
|
|
84
|
+
with self._lock:
|
|
85
|
+
if not self._buffer:
|
|
86
|
+
return 0
|
|
87
|
+
batch = self._buffer[:_MAX_BATCH]
|
|
88
|
+
self._buffer = self._buffer[_MAX_BATCH:]
|
|
89
|
+
|
|
90
|
+
payload = self._build_otlp_payload(batch)
|
|
91
|
+
try:
|
|
92
|
+
resp = httpx.post(
|
|
93
|
+
f"{self._endpoint}/v1/traces",
|
|
94
|
+
json=payload,
|
|
95
|
+
headers=self._headers(),
|
|
96
|
+
timeout=10.0,
|
|
97
|
+
)
|
|
98
|
+
resp.raise_for_status()
|
|
99
|
+
except httpx.HTTPError:
|
|
100
|
+
# Re-queue on failure (best-effort)
|
|
101
|
+
with self._lock:
|
|
102
|
+
self._buffer = batch + self._buffer
|
|
103
|
+
return 0
|
|
104
|
+
return len(batch)
|
|
105
|
+
|
|
106
|
+
def shutdown(self) -> None:
|
|
107
|
+
"""Flush remaining spans and stop background thread."""
|
|
108
|
+
self._closed = True
|
|
109
|
+
if self._timer:
|
|
110
|
+
self._timer.cancel()
|
|
111
|
+
# Flush all remaining
|
|
112
|
+
while True:
|
|
113
|
+
sent = self.flush()
|
|
114
|
+
if sent == 0:
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
def _schedule_flush(self) -> None:
|
|
118
|
+
if self._closed:
|
|
119
|
+
return
|
|
120
|
+
self._timer = threading.Timer(self._flush_interval, self._tick)
|
|
121
|
+
self._timer.daemon = True
|
|
122
|
+
self._timer.start()
|
|
123
|
+
|
|
124
|
+
def _tick(self) -> None:
|
|
125
|
+
self.flush()
|
|
126
|
+
self._schedule_flush()
|
|
127
|
+
|
|
128
|
+
def _headers(self) -> dict[str, str]:
|
|
129
|
+
h: dict[str, str] = {"Content-Type": "application/json"}
|
|
130
|
+
if self._api_key:
|
|
131
|
+
h["Authorization"] = f"Bearer {self._api_key}"
|
|
132
|
+
return h
|
|
133
|
+
|
|
134
|
+
def _build_otlp_payload(self, spans: list[ToolCall]) -> dict:
|
|
135
|
+
"""Build OTLP-compatible JSON payload."""
|
|
136
|
+
otlp_spans = []
|
|
137
|
+
for s in spans:
|
|
138
|
+
attrs = [{"key": "tool.name", "value": {"stringValue": s.tool_name}}]
|
|
139
|
+
if s.model:
|
|
140
|
+
attrs.append({"key": "gen_ai.request.model", "value": {"stringValue": s.model}})
|
|
141
|
+
if s.mcp_server:
|
|
142
|
+
attrs.append({"key": "mcp.server", "value": {"stringValue": s.mcp_server}})
|
|
143
|
+
if s.ide:
|
|
144
|
+
attrs.append({"key": "ide", "value": {"stringValue": s.ide}})
|
|
145
|
+
if s.agent:
|
|
146
|
+
attrs.append({"key": "agent", "value": {"stringValue": s.agent}})
|
|
147
|
+
if s.conversation_id:
|
|
148
|
+
attrs.append({"key": "session.id", "value": {"stringValue": s.conversation_id}})
|
|
149
|
+
if s.task_id:
|
|
150
|
+
attrs.append({"key": "task.id", "value": {"stringValue": s.task_id}})
|
|
151
|
+
if s.estimated_input_tokens is not None:
|
|
152
|
+
attrs.append({"key": "gen_ai.usage.input_tokens", "value": {"intValue": str(s.estimated_input_tokens)}})
|
|
153
|
+
if s.estimated_output_tokens is not None:
|
|
154
|
+
attrs.append({"key": "gen_ai.usage.output_tokens", "value": {"intValue": str(s.estimated_output_tokens)}})
|
|
155
|
+
if s.usd_cost is not None:
|
|
156
|
+
attrs.append({"key": "cost.usd", "value": {"doubleValue": s.usd_cost}})
|
|
157
|
+
if s.error:
|
|
158
|
+
attrs.append({"key": "error.message", "value": {"stringValue": s.error}})
|
|
159
|
+
|
|
160
|
+
end_ns = s.ended_at_ns or s.started_at_ns
|
|
161
|
+
status_code = 1 if s.ok else 2
|
|
162
|
+
status_msg = s.error or "" if not s.ok else ""
|
|
163
|
+
|
|
164
|
+
otlp_spans.append({
|
|
165
|
+
"traceId": s.trace_id,
|
|
166
|
+
"spanId": s.span_id,
|
|
167
|
+
"parentSpanId": s.parent_span_id or "",
|
|
168
|
+
"name": s.tool_name,
|
|
169
|
+
"kind": 3, # INTERNAL
|
|
170
|
+
"startTimeUnixNano": str(s.started_at_ns),
|
|
171
|
+
"endTimeUnixNano": str(end_ns),
|
|
172
|
+
"attributes": attrs,
|
|
173
|
+
"status": {"code": status_code, "message": status_msg},
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
"resourceSpans": [
|
|
178
|
+
{
|
|
179
|
+
"resource": {
|
|
180
|
+
"attributes": [
|
|
181
|
+
{"key": "service.name", "value": {"stringValue": "agent-meter-sdk-python"}},
|
|
182
|
+
]
|
|
183
|
+
},
|
|
184
|
+
"scopeSpans": [
|
|
185
|
+
{
|
|
186
|
+
"scope": {"name": "agent-meter-python", "version": "0.1.0"},
|
|
187
|
+
"spans": otlp_spans,
|
|
188
|
+
}
|
|
189
|
+
],
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Data types for Agent Meter spans/events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ToolCall:
|
|
13
|
+
"""A single tool call (span) to be reported."""
|
|
14
|
+
|
|
15
|
+
tool_name: str
|
|
16
|
+
started_at_ns: int = field(default_factory=lambda: time.time_ns())
|
|
17
|
+
ended_at_ns: Optional[int] = None
|
|
18
|
+
duration_ms: Optional[int] = None
|
|
19
|
+
ok: bool = True
|
|
20
|
+
error: Optional[str] = None
|
|
21
|
+
model: Optional[str] = None
|
|
22
|
+
mcp_server: Optional[str] = None
|
|
23
|
+
ide: Optional[str] = None
|
|
24
|
+
agent: Optional[str] = None
|
|
25
|
+
conversation_id: Optional[str] = None
|
|
26
|
+
task_id: Optional[str] = None
|
|
27
|
+
trace_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
28
|
+
span_id: str = field(default_factory=lambda: uuid.uuid4().hex[:16])
|
|
29
|
+
parent_span_id: Optional[str] = None
|
|
30
|
+
estimated_input_tokens: Optional[int] = None
|
|
31
|
+
estimated_output_tokens: Optional[int] = None
|
|
32
|
+
usd_cost: Optional[float] = None
|
|
33
|
+
|
|
34
|
+
def finish(self, ok: bool = True, error: Optional[str] = None) -> None:
|
|
35
|
+
"""Mark the tool call as finished."""
|
|
36
|
+
self.ended_at_ns = time.time_ns()
|
|
37
|
+
self.duration_ms = (self.ended_at_ns - self.started_at_ns) // 1_000_000
|
|
38
|
+
self.ok = ok
|
|
39
|
+
if error:
|
|
40
|
+
self.error = error
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict:
|
|
43
|
+
"""Serialize to the ingest payload format."""
|
|
44
|
+
d: dict = {
|
|
45
|
+
"tool_name": self.tool_name,
|
|
46
|
+
"started_at": self.started_at_ns,
|
|
47
|
+
"ok": self.ok,
|
|
48
|
+
"trace_id": self.trace_id,
|
|
49
|
+
"span_id": self.span_id,
|
|
50
|
+
}
|
|
51
|
+
if self.ended_at_ns:
|
|
52
|
+
d["ended_at"] = self.ended_at_ns
|
|
53
|
+
if self.duration_ms is not None:
|
|
54
|
+
d["duration_ms"] = self.duration_ms
|
|
55
|
+
if self.error:
|
|
56
|
+
d["error"] = self.error
|
|
57
|
+
if self.model:
|
|
58
|
+
d["model"] = self.model
|
|
59
|
+
if self.mcp_server:
|
|
60
|
+
d["mcp_server"] = self.mcp_server
|
|
61
|
+
if self.ide:
|
|
62
|
+
d["ide"] = self.ide
|
|
63
|
+
if self.agent:
|
|
64
|
+
d["agent"] = self.agent
|
|
65
|
+
if self.conversation_id:
|
|
66
|
+
d["conversation_id"] = self.conversation_id
|
|
67
|
+
if self.task_id:
|
|
68
|
+
d["task_id"] = self.task_id
|
|
69
|
+
if self.parent_span_id:
|
|
70
|
+
d["parent_span_id"] = self.parent_span_id
|
|
71
|
+
if self.estimated_input_tokens is not None:
|
|
72
|
+
d["estimated_input_tokens"] = self.estimated_input_tokens
|
|
73
|
+
if self.estimated_output_tokens is not None:
|
|
74
|
+
d["estimated_output_tokens"] = self.estimated_output_tokens
|
|
75
|
+
if self.usd_cost is not None:
|
|
76
|
+
d["usd_cost"] = self.usd_cost
|
|
77
|
+
return d
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dnorio-agent-meter"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lightweight SDK to track AI agent tool calls, costs, and latency."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "dnorio", email = "dnorio@users.noreply.github.com" }]
|
|
13
|
+
keywords = ["ai", "agents", "observability", "telemetry", "llm"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["httpx>=0.24"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/dnorio/agent-meter"
|
|
25
|
+
Repository = "https://github.com/dnorio/agent-meter"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["agent_meter"]
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Tests for agent_meter SDK."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import patch, MagicMock
|
|
5
|
+
|
|
6
|
+
from agent_meter import AgentMeter, ToolCall
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_tool_call_finish():
|
|
10
|
+
tc = ToolCall(tool_name="test_tool")
|
|
11
|
+
assert tc.ok is True
|
|
12
|
+
tc.finish(ok=False, error="timeout")
|
|
13
|
+
assert tc.ok is False
|
|
14
|
+
assert tc.error == "timeout"
|
|
15
|
+
assert tc.duration_ms is not None
|
|
16
|
+
assert tc.duration_ms >= 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_tool_call_to_dict():
|
|
20
|
+
tc = ToolCall(tool_name="search", model="gpt-4o")
|
|
21
|
+
tc.finish()
|
|
22
|
+
d = tc.to_dict()
|
|
23
|
+
assert d["tool_name"] == "search"
|
|
24
|
+
assert d["model"] == "gpt-4o"
|
|
25
|
+
assert d["ok"] is True
|
|
26
|
+
assert "duration_ms" in d
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_client_track():
|
|
30
|
+
am = AgentMeter(api_key="test", endpoint="http://localhost:3000", ide="python-sdk", flush_interval=999)
|
|
31
|
+
span = am.track("my_tool", model="gpt-4o")
|
|
32
|
+
assert span.tool_name == "my_tool"
|
|
33
|
+
assert span.ide == "python-sdk"
|
|
34
|
+
span.finish()
|
|
35
|
+
am._closed = True # prevent flush timer
|
|
36
|
+
if am._timer:
|
|
37
|
+
am._timer.cancel()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_client_build_payload():
|
|
41
|
+
am = AgentMeter(api_key="test", endpoint="http://localhost:3000", flush_interval=999)
|
|
42
|
+
span = ToolCall(tool_name="code_gen", model="claude-sonnet-4-20250514")
|
|
43
|
+
span.finish()
|
|
44
|
+
payload = am._build_otlp_payload([span])
|
|
45
|
+
assert "resourceSpans" in payload
|
|
46
|
+
rs = payload["resourceSpans"][0]
|
|
47
|
+
assert rs["scopeSpans"][0]["spans"][0]["name"] == "code_gen"
|
|
48
|
+
am._closed = True
|
|
49
|
+
if am._timer:
|
|
50
|
+
am._timer.cancel()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@patch("agent_meter.client.httpx.post")
|
|
54
|
+
def test_flush_sends_to_server(mock_post):
|
|
55
|
+
mock_resp = MagicMock()
|
|
56
|
+
mock_resp.raise_for_status = MagicMock()
|
|
57
|
+
mock_post.return_value = mock_resp
|
|
58
|
+
|
|
59
|
+
am = AgentMeter(api_key="am_live_test", endpoint="http://localhost:3000", flush_interval=999)
|
|
60
|
+
span = am.track("tool_a")
|
|
61
|
+
span.finish()
|
|
62
|
+
sent = am.flush()
|
|
63
|
+
assert sent == 1
|
|
64
|
+
mock_post.assert_called_once()
|
|
65
|
+
call_kwargs = mock_post.call_args
|
|
66
|
+
assert "/v1/traces" in call_kwargs.args[0] or "/v1/traces" in str(call_kwargs)
|
|
67
|
+
am._closed = True
|
|
68
|
+
if am._timer:
|
|
69
|
+
am._timer.cancel()
|