useagentledger 0.1.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.
agentledger/__init__.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""AgentLedger Python SDK.
|
|
2
|
+
|
|
3
|
+
Track, attribute, and govern AI agent spend across OpenAI, Anthropic, and
|
|
4
|
+
Gemini. Mirrors the TypeScript SDK:
|
|
5
|
+
|
|
6
|
+
- ``run()`` wraps an LLM call with a pre-call kill-switch check and
|
|
7
|
+
fire-and-forget usage tracking (cost is computed server-side from
|
|
8
|
+
AgentLedger's authoritative pricing table — the SDK carries no pricing copy).
|
|
9
|
+
- Control-plane calls (block checks, tracking, sessions) always fail open:
|
|
10
|
+
a network error on AgentLedger's side never breaks your LLM call.
|
|
11
|
+
|
|
12
|
+
Zero dependencies — stdlib only.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from typing import Any, Dict, Generator, Iterable, List, Optional
|
|
24
|
+
|
|
25
|
+
__all__ = ["AgentLedger", "AgentLedgerBlockError"]
|
|
26
|
+
|
|
27
|
+
_BLOCK_CACHE_TTL = 5.0 # seconds, matches the TS SDK and proxy cache
|
|
28
|
+
_CONTROL_PLANE_TIMEOUT = 10.0
|
|
29
|
+
_LLM_TIMEOUT = 600.0
|
|
30
|
+
|
|
31
|
+
_ENV_VARS = {
|
|
32
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
33
|
+
"openai": "OPENAI_API_KEY",
|
|
34
|
+
"gemini": "GOOGLE_API_KEY",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AgentLedgerBlockError(Exception):
|
|
39
|
+
"""Raised when the AgentLedger kill switch blocks a call before it is made."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, dimension: str, dimension_value: str):
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.dimension = dimension
|
|
44
|
+
self.dimension_value = dimension_value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _post_json(url: str, body: Dict[str, Any], headers: Dict[str, str], timeout: float) -> Dict[str, Any]:
|
|
48
|
+
data = json.dumps(body).encode("utf-8")
|
|
49
|
+
req = urllib.request.Request(url, data=data, method="POST")
|
|
50
|
+
req.add_header("Content-Type", "application/json")
|
|
51
|
+
for k, v in headers.items():
|
|
52
|
+
req.add_header(k, v)
|
|
53
|
+
with urllib.request.urlopen(req, timeout=timeout) as res:
|
|
54
|
+
return json.loads(res.read().decode("utf-8"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _iter_sse(res: Any) -> Generator[Dict[str, Any], None, None]:
|
|
58
|
+
"""Parse a text/event-stream response into JSON event dicts."""
|
|
59
|
+
for raw_line in res:
|
|
60
|
+
line = raw_line.decode("utf-8").rstrip("\r\n")
|
|
61
|
+
if not line.startswith("data:"):
|
|
62
|
+
continue
|
|
63
|
+
payload = line[5:].strip()
|
|
64
|
+
if payload == "[DONE]":
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
yield json.loads(payload)
|
|
68
|
+
except (ValueError, TypeError):
|
|
69
|
+
continue # skip malformed event data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AgentLedger:
|
|
73
|
+
"""Client for tracking and governing LLM calls through AgentLedger.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
api_key: Your AgentLedger API key (``al_live_...``).
|
|
77
|
+
base_url: AgentLedger API base URL. Defaults to the hosted service.
|
|
78
|
+
provider_keys: Optional dict with ``anthropic`` / ``openai`` / ``gemini``
|
|
79
|
+
API keys. Falls back to ``ANTHROPIC_API_KEY`` / ``OPENAI_API_KEY`` /
|
|
80
|
+
``GOOGLE_API_KEY`` environment variables.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
api_key: str,
|
|
86
|
+
base_url: str = "https://api.useagentledger.com",
|
|
87
|
+
provider_keys: Optional[Dict[str, str]] = None,
|
|
88
|
+
):
|
|
89
|
+
import os
|
|
90
|
+
|
|
91
|
+
self._api_key = api_key
|
|
92
|
+
self._base_url = base_url.rstrip("/")
|
|
93
|
+
keys = provider_keys or {}
|
|
94
|
+
self._provider_keys = {
|
|
95
|
+
provider: keys.get(provider) or os.environ.get(env_var, "")
|
|
96
|
+
for provider, env_var in _ENV_VARS.items()
|
|
97
|
+
}
|
|
98
|
+
self._block_cache: Dict[str, Dict[str, Any]] = {}
|
|
99
|
+
self._block_cache_lock = threading.Lock()
|
|
100
|
+
|
|
101
|
+
# ── Control plane ─────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
def _auth_headers(self) -> Dict[str, str]:
|
|
104
|
+
return {"Authorization": f"Bearer {self._api_key}"}
|
|
105
|
+
|
|
106
|
+
def _check_blocks(
|
|
107
|
+
self,
|
|
108
|
+
agent: str,
|
|
109
|
+
task: Optional[str],
|
|
110
|
+
user: Optional[str],
|
|
111
|
+
customer: Optional[str],
|
|
112
|
+
) -> None:
|
|
113
|
+
cache_key = f"{agent}:{task or ''}:{user or ''}:{customer or ''}"
|
|
114
|
+
now = time.monotonic()
|
|
115
|
+
|
|
116
|
+
with self._block_cache_lock:
|
|
117
|
+
cached = self._block_cache.get(cache_key)
|
|
118
|
+
if cached and now < cached["expires_at"]:
|
|
119
|
+
if cached["isBlocked"]:
|
|
120
|
+
raise AgentLedgerBlockError(
|
|
121
|
+
f"AgentLedger kill switch: {cached['reason']}",
|
|
122
|
+
cached["dimension"],
|
|
123
|
+
cached["dimension_value"],
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
if cached:
|
|
127
|
+
del self._block_cache[cache_key]
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
status = _post_json(
|
|
131
|
+
f"{self._base_url}/api/v1/blocks/check",
|
|
132
|
+
{"agent": agent, "task": task, "user_id": user, "customer_id": customer},
|
|
133
|
+
self._auth_headers(),
|
|
134
|
+
_CONTROL_PLANE_TIMEOUT,
|
|
135
|
+
)
|
|
136
|
+
except Exception:
|
|
137
|
+
return # fail open — AgentLedger being unreachable never blocks the call
|
|
138
|
+
|
|
139
|
+
with self._block_cache_lock:
|
|
140
|
+
self._block_cache[cache_key] = {**status, "expires_at": now + _BLOCK_CACHE_TTL}
|
|
141
|
+
|
|
142
|
+
if status.get("isBlocked"):
|
|
143
|
+
raise AgentLedgerBlockError(
|
|
144
|
+
f"AgentLedger kill switch: {status.get('reason', '')}",
|
|
145
|
+
status.get("dimension", ""),
|
|
146
|
+
status.get("dimension_value", ""),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def track(
|
|
150
|
+
self,
|
|
151
|
+
agent: str,
|
|
152
|
+
provider: str,
|
|
153
|
+
model: str,
|
|
154
|
+
input_tokens: int,
|
|
155
|
+
output_tokens: int,
|
|
156
|
+
task: Optional[str] = None,
|
|
157
|
+
user_id: Optional[str] = None,
|
|
158
|
+
customer_id: Optional[str] = None,
|
|
159
|
+
cost_usd: Optional[float] = None,
|
|
160
|
+
latency_ms: Optional[int] = None,
|
|
161
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
162
|
+
session_id: Optional[str] = None,
|
|
163
|
+
input_hash: Optional[str] = None,
|
|
164
|
+
background: bool = True,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Record an LLM call. Cost is computed server-side when ``cost_usd`` is omitted.
|
|
167
|
+
|
|
168
|
+
With ``background=True`` (default) the HTTP call runs on a daemon thread
|
|
169
|
+
so tracking adds no latency to your agent. Errors are always swallowed.
|
|
170
|
+
"""
|
|
171
|
+
body = {
|
|
172
|
+
"agent": agent,
|
|
173
|
+
"task": task,
|
|
174
|
+
"user_id": user_id,
|
|
175
|
+
"customer_id": customer_id,
|
|
176
|
+
"provider": provider,
|
|
177
|
+
"model": model,
|
|
178
|
+
"input_tokens": input_tokens,
|
|
179
|
+
"output_tokens": output_tokens,
|
|
180
|
+
"session_id": session_id,
|
|
181
|
+
"input_hash": input_hash,
|
|
182
|
+
}
|
|
183
|
+
if cost_usd is not None:
|
|
184
|
+
body["cost_usd"] = cost_usd
|
|
185
|
+
if latency_ms is not None:
|
|
186
|
+
body["latency_ms"] = latency_ms
|
|
187
|
+
if metadata is not None:
|
|
188
|
+
body["metadata"] = metadata
|
|
189
|
+
|
|
190
|
+
def send() -> None:
|
|
191
|
+
try:
|
|
192
|
+
_post_json(f"{self._base_url}/api/v1/track", body, self._auth_headers(), _CONTROL_PLANE_TIMEOUT)
|
|
193
|
+
except Exception:
|
|
194
|
+
pass # tracking failures never surface
|
|
195
|
+
|
|
196
|
+
if background:
|
|
197
|
+
threading.Thread(target=send, daemon=True).start()
|
|
198
|
+
else:
|
|
199
|
+
send()
|
|
200
|
+
|
|
201
|
+
def start_session(
|
|
202
|
+
self,
|
|
203
|
+
agent: str,
|
|
204
|
+
task: Optional[str] = None,
|
|
205
|
+
user: Optional[str] = None,
|
|
206
|
+
customer: Optional[str] = None,
|
|
207
|
+
) -> Optional[str]:
|
|
208
|
+
"""Create an explicit session for grouping multi-step agent work. Returns the session ID, or None on failure."""
|
|
209
|
+
try:
|
|
210
|
+
res = _post_json(
|
|
211
|
+
f"{self._base_url}/api/v1/track/sessions",
|
|
212
|
+
{"agent": agent, "task": task, "user_id": user, "customer_id": customer},
|
|
213
|
+
self._auth_headers(),
|
|
214
|
+
_CONTROL_PLANE_TIMEOUT,
|
|
215
|
+
)
|
|
216
|
+
return res.get("session_id")
|
|
217
|
+
except Exception:
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
def end_session(self, session_id: str, success: bool = True) -> None:
|
|
221
|
+
try:
|
|
222
|
+
data = json.dumps({"success": success}).encode("utf-8")
|
|
223
|
+
req = urllib.request.Request(
|
|
224
|
+
f"{self._base_url}/api/v1/track/sessions/{session_id}", data=data, method="PATCH"
|
|
225
|
+
)
|
|
226
|
+
req.add_header("Content-Type", "application/json")
|
|
227
|
+
req.add_header("Authorization", f"Bearer {self._api_key}")
|
|
228
|
+
urllib.request.urlopen(req, timeout=_CONTROL_PLANE_TIMEOUT).close()
|
|
229
|
+
except Exception:
|
|
230
|
+
pass # session errors never surface
|
|
231
|
+
|
|
232
|
+
def record_unit(
|
|
233
|
+
self,
|
|
234
|
+
agent: str,
|
|
235
|
+
session_id: Optional[str] = None,
|
|
236
|
+
unit_count: int = 1,
|
|
237
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Record a completed business unit (for ROI reporting with custom_event counting)."""
|
|
240
|
+
try:
|
|
241
|
+
_post_json(
|
|
242
|
+
f"{self._base_url}/api/v1/roi/events",
|
|
243
|
+
{"agent": agent, "session_id": session_id, "unit_count": unit_count, "metadata": metadata},
|
|
244
|
+
self._auth_headers(),
|
|
245
|
+
_CONTROL_PLANE_TIMEOUT,
|
|
246
|
+
)
|
|
247
|
+
except Exception:
|
|
248
|
+
pass # never surface recording errors
|
|
249
|
+
|
|
250
|
+
# ── LLM execution ─────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _hash_messages(messages: Iterable[Any]) -> Optional[str]:
|
|
254
|
+
try:
|
|
255
|
+
s = json.dumps(list(messages), separators=(",", ":"), default=str)[:4000]
|
|
256
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:16]
|
|
257
|
+
except Exception:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
def _require_key(self, provider: str) -> str:
|
|
261
|
+
key = self._provider_keys.get(provider, "")
|
|
262
|
+
if not key:
|
|
263
|
+
raise ValueError(
|
|
264
|
+
f"{provider} API key not set. Pass provider_keys={{'{provider}': ...}} or set {_ENV_VARS[provider]}."
|
|
265
|
+
)
|
|
266
|
+
return key
|
|
267
|
+
|
|
268
|
+
def _provider_request(
|
|
269
|
+
self, provider: str, model: str, messages: List[Any], options: Dict[str, Any], stream: bool
|
|
270
|
+
) -> urllib.request.Request:
|
|
271
|
+
if provider == "anthropic":
|
|
272
|
+
key = self._require_key("anthropic")
|
|
273
|
+
body = {"model": model, "messages": messages, **options}
|
|
274
|
+
if stream:
|
|
275
|
+
body["stream"] = True
|
|
276
|
+
req = urllib.request.Request(
|
|
277
|
+
"https://api.anthropic.com/v1/messages",
|
|
278
|
+
data=json.dumps(body).encode("utf-8"),
|
|
279
|
+
method="POST",
|
|
280
|
+
)
|
|
281
|
+
req.add_header("x-api-key", key)
|
|
282
|
+
req.add_header("anthropic-version", "2023-06-01")
|
|
283
|
+
elif provider == "openai":
|
|
284
|
+
key = self._require_key("openai")
|
|
285
|
+
body = {"model": model, "messages": messages, **options}
|
|
286
|
+
if stream:
|
|
287
|
+
body["stream"] = True
|
|
288
|
+
# include_usage makes OpenAI emit a final chunk carrying token usage
|
|
289
|
+
body.setdefault("stream_options", {"include_usage": True})
|
|
290
|
+
req = urllib.request.Request(
|
|
291
|
+
"https://api.openai.com/v1/chat/completions",
|
|
292
|
+
data=json.dumps(body).encode("utf-8"),
|
|
293
|
+
method="POST",
|
|
294
|
+
)
|
|
295
|
+
req.add_header("Authorization", f"Bearer {key}")
|
|
296
|
+
elif provider == "gemini":
|
|
297
|
+
key = self._require_key("gemini")
|
|
298
|
+
action = "streamGenerateContent?alt=sse" if stream else "generateContent"
|
|
299
|
+
body = {"contents": messages, **options}
|
|
300
|
+
req = urllib.request.Request(
|
|
301
|
+
f"https://generativelanguage.googleapis.com/v1beta/models/{model}:{action}",
|
|
302
|
+
data=json.dumps(body).encode("utf-8"),
|
|
303
|
+
method="POST",
|
|
304
|
+
)
|
|
305
|
+
req.add_header("x-goog-api-key", key)
|
|
306
|
+
else:
|
|
307
|
+
raise ValueError(f"Unsupported provider: {provider}. Use 'anthropic', 'openai', or 'gemini'.")
|
|
308
|
+
|
|
309
|
+
req.add_header("Content-Type", "application/json")
|
|
310
|
+
return req
|
|
311
|
+
|
|
312
|
+
def run(
|
|
313
|
+
self,
|
|
314
|
+
agent: str,
|
|
315
|
+
llm: Dict[str, Any],
|
|
316
|
+
task: Optional[str] = None,
|
|
317
|
+
user: Optional[str] = None,
|
|
318
|
+
customer: Optional[str] = None,
|
|
319
|
+
session_id: Optional[str] = None,
|
|
320
|
+
) -> Any:
|
|
321
|
+
"""Run an LLM call with kill-switch enforcement and automatic tracking.
|
|
322
|
+
|
|
323
|
+
``llm`` must contain ``provider`` ('anthropic' | 'openai' | 'gemini'),
|
|
324
|
+
``model``, and ``messages``; any other keys are passed through to the
|
|
325
|
+
provider (e.g. ``max_tokens``, ``temperature``).
|
|
326
|
+
|
|
327
|
+
Non-streaming (default): returns the provider's JSON response as a dict.
|
|
328
|
+
Streaming (``stream=True`` in ``llm``): returns a generator of parsed
|
|
329
|
+
stream events. Usage is tracked when the stream ends, including early
|
|
330
|
+
termination by the consumer.
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
AgentLedgerBlockError: if an active kill-switch block matches this call.
|
|
334
|
+
"""
|
|
335
|
+
llm = dict(llm)
|
|
336
|
+
provider = llm.pop("provider")
|
|
337
|
+
model = llm.pop("model")
|
|
338
|
+
messages = llm.pop("messages")
|
|
339
|
+
stream = bool(llm.pop("stream", False))
|
|
340
|
+
|
|
341
|
+
self._check_blocks(agent, task, user, customer)
|
|
342
|
+
|
|
343
|
+
start = time.monotonic()
|
|
344
|
+
req = self._provider_request(provider, model, messages, llm, stream)
|
|
345
|
+
|
|
346
|
+
def track_usage(input_tokens: int, output_tokens: int) -> None:
|
|
347
|
+
self.track(
|
|
348
|
+
agent=agent,
|
|
349
|
+
task=task,
|
|
350
|
+
user_id=user,
|
|
351
|
+
customer_id=customer,
|
|
352
|
+
provider=provider,
|
|
353
|
+
model=model,
|
|
354
|
+
input_tokens=input_tokens,
|
|
355
|
+
output_tokens=output_tokens,
|
|
356
|
+
latency_ms=int((time.monotonic() - start) * 1000),
|
|
357
|
+
session_id=session_id,
|
|
358
|
+
input_hash=self._hash_messages(messages),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if stream:
|
|
362
|
+
try:
|
|
363
|
+
res = urllib.request.urlopen(req, timeout=_LLM_TIMEOUT)
|
|
364
|
+
except urllib.error.HTTPError as err:
|
|
365
|
+
detail = err.read().decode("utf-8", errors="replace")[:500]
|
|
366
|
+
raise RuntimeError(f"{provider} streaming request failed ({err.code}): {detail}") from err
|
|
367
|
+
|
|
368
|
+
def event_stream() -> Generator[Dict[str, Any], None, None]:
|
|
369
|
+
input_tokens = 0
|
|
370
|
+
output_tokens = 0
|
|
371
|
+
try:
|
|
372
|
+
for event in _iter_sse(res):
|
|
373
|
+
if provider == "anthropic":
|
|
374
|
+
if event.get("type") == "message_start":
|
|
375
|
+
input_tokens = event.get("message", {}).get("usage", {}).get("input_tokens", 0)
|
|
376
|
+
elif event.get("type") == "message_delta":
|
|
377
|
+
usage = event.get("usage") or {}
|
|
378
|
+
if isinstance(usage.get("output_tokens"), int):
|
|
379
|
+
output_tokens = usage["output_tokens"]
|
|
380
|
+
elif provider == "openai":
|
|
381
|
+
usage = event.get("usage") or {}
|
|
382
|
+
if isinstance(usage.get("prompt_tokens"), int):
|
|
383
|
+
input_tokens = usage["prompt_tokens"]
|
|
384
|
+
output_tokens = usage.get("completion_tokens", 0)
|
|
385
|
+
elif provider == "gemini":
|
|
386
|
+
usage = event.get("usageMetadata") or {}
|
|
387
|
+
if isinstance(usage.get("promptTokenCount"), int):
|
|
388
|
+
input_tokens = usage["promptTokenCount"]
|
|
389
|
+
output_tokens = usage.get("candidatesTokenCount", 0)
|
|
390
|
+
yield event
|
|
391
|
+
finally:
|
|
392
|
+
res.close()
|
|
393
|
+
# tracks whatever usage was observed, even on early termination
|
|
394
|
+
track_usage(input_tokens, output_tokens)
|
|
395
|
+
|
|
396
|
+
return event_stream()
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
with urllib.request.urlopen(req, timeout=_LLM_TIMEOUT) as res:
|
|
400
|
+
response = json.loads(res.read().decode("utf-8"))
|
|
401
|
+
except urllib.error.HTTPError as err:
|
|
402
|
+
# Provider returned an error body — surface it as the response, like the TS SDK
|
|
403
|
+
try:
|
|
404
|
+
response = json.loads(err.read().decode("utf-8"))
|
|
405
|
+
except Exception:
|
|
406
|
+
raise RuntimeError(f"{provider} request failed ({err.code})") from err
|
|
407
|
+
|
|
408
|
+
if provider == "anthropic":
|
|
409
|
+
usage = response.get("usage") or {}
|
|
410
|
+
track_usage(usage.get("input_tokens", 0), usage.get("output_tokens", 0))
|
|
411
|
+
elif provider == "openai":
|
|
412
|
+
usage = response.get("usage") or {}
|
|
413
|
+
track_usage(usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0))
|
|
414
|
+
else: # gemini
|
|
415
|
+
usage = response.get("usageMetadata") or {}
|
|
416
|
+
track_usage(usage.get("promptTokenCount", 0), usage.get("candidatesTokenCount", 0))
|
|
417
|
+
|
|
418
|
+
return response
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: useagentledger
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AgentLedger Python SDK — track, attribute, and govern AI agent spend across OpenAI, Anthropic, and Gemini.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://useagentledger.com
|
|
7
|
+
Project-URL: Documentation, https://useagentledger.com/blog
|
|
8
|
+
Keywords: llm,cost-tracking,ai-agents,openai,anthropic,gemini,observability
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# AgentLedger Python SDK
|
|
17
|
+
|
|
18
|
+
Track, attribute, and govern AI agent spend across OpenAI, Anthropic, and Gemini — with per-agent / per-task / per-user / per-customer cost attribution, kill switches, loop detection, and ROI reporting.
|
|
19
|
+
|
|
20
|
+
Zero dependencies. Python 3.9+.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install useagentledger
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
(The import name is `agentledger`: `from agentledger import AgentLedger`.)
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from agentledger import AgentLedger
|
|
32
|
+
|
|
33
|
+
al = AgentLedger(api_key="al_live_...") # provider keys read from env by default
|
|
34
|
+
|
|
35
|
+
response = al.run(
|
|
36
|
+
agent="invoice-processor",
|
|
37
|
+
task="extraction",
|
|
38
|
+
customer="acme-corp",
|
|
39
|
+
llm={
|
|
40
|
+
"provider": "anthropic",
|
|
41
|
+
"model": "claude-sonnet-4-6",
|
|
42
|
+
"max_tokens": 1024,
|
|
43
|
+
"messages": [{"role": "user", "content": "Extract line items from this invoice: ..."}],
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
print(response["content"][0]["text"])
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Every call is tracked automatically — token counts, latency, and attribution dimensions. Cost is computed server-side from AgentLedger's pricing table (134+ models), so the SDK never goes stale.
|
|
50
|
+
|
|
51
|
+
## Kill switch
|
|
52
|
+
|
|
53
|
+
`run()` checks AgentLedger's block list before every call (cached 5 seconds). If an alert rule has blocked this agent, task, user, or customer, the call raises **before** any provider spend happens:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from agentledger import AgentLedger, AgentLedgerBlockError
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
al.run(agent="support-bot", customer="acme-corp", llm={...})
|
|
60
|
+
except AgentLedgerBlockError as e:
|
|
61
|
+
print(f"Blocked: {e} (dimension={e.dimension}, value={e.dimension_value})")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The check **fails open** — if AgentLedger is unreachable, your LLM call proceeds normally. Governance never becomes a point of failure.
|
|
65
|
+
|
|
66
|
+
## Streaming
|
|
67
|
+
|
|
68
|
+
Pass `stream=True` and iterate the returned generator. Usage is captured from the stream's own events and tracked when the stream ends (even if you stop early):
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
stream = al.run(
|
|
72
|
+
agent="chat-assistant",
|
|
73
|
+
user="user-42",
|
|
74
|
+
llm={
|
|
75
|
+
"provider": "openai",
|
|
76
|
+
"model": "gpt-4o",
|
|
77
|
+
"stream": True,
|
|
78
|
+
"messages": [{"role": "user", "content": "Hello!"}],
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
for event in stream:
|
|
82
|
+
delta = event.get("choices", [{}])[0].get("delta", {}).get("content")
|
|
83
|
+
if delta:
|
|
84
|
+
print(delta, end="", flush=True)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Works for all three providers (Anthropic `message_start`/`message_delta` events, OpenAI usage chunk, Gemini `usageMetadata`).
|
|
88
|
+
|
|
89
|
+
## Sessions (multi-step agents)
|
|
90
|
+
|
|
91
|
+
Group a multi-step task into one session so loop detection and per-session cost work across steps:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
session_id = al.start_session(agent="research-bot", task="report")
|
|
95
|
+
for step in plan:
|
|
96
|
+
al.run(agent="research-bot", session_id=session_id, llm={...})
|
|
97
|
+
al.end_session(session_id, success=True)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Calls without an explicit session are auto-grouped server-side (same agent + task within a 10-minute window).
|
|
101
|
+
|
|
102
|
+
## Track calls you make yourself
|
|
103
|
+
|
|
104
|
+
Already using the official `openai` / `anthropic` clients? Just report usage:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
result = openai_client.chat.completions.create(model="gpt-4o-mini", messages=msgs)
|
|
108
|
+
|
|
109
|
+
al.track(
|
|
110
|
+
agent="summarizer",
|
|
111
|
+
customer="acme-corp",
|
|
112
|
+
provider="openai",
|
|
113
|
+
model="gpt-4o-mini",
|
|
114
|
+
input_tokens=result.usage.prompt_tokens,
|
|
115
|
+
output_tokens=result.usage.completion_tokens,
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`track()` runs on a background thread by default (zero added latency) and swallows all errors. Pass `background=False` to send synchronously.
|
|
120
|
+
|
|
121
|
+
## ROI units
|
|
122
|
+
|
|
123
|
+
Record completed business outcomes for cost-per-outcome reporting (`custom_event` count method):
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
al.record_unit(agent="invoice-processor", unit_count=1)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Configuration
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
al = AgentLedger(
|
|
133
|
+
api_key="al_live_...",
|
|
134
|
+
base_url="https://api.useagentledger.com", # or your self-hosted proxy
|
|
135
|
+
provider_keys={ # optional — env vars used otherwise
|
|
136
|
+
"anthropic": "...", # ANTHROPIC_API_KEY
|
|
137
|
+
"openai": "...", # OPENAI_API_KEY
|
|
138
|
+
"gemini": "...", # GOOGLE_API_KEY
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
```
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
agentledger/__init__.py,sha256=UqAcYFbiWZH8kyvjWPA8E-nsO7QwB7dRdXfm_tdM4io,16703
|
|
2
|
+
useagentledger-0.1.0.dist-info/METADATA,sha256=UiCLId9DVzPAClaSN2rlis_Oqw2OrkR67g1biBj97qw,4751
|
|
3
|
+
useagentledger-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
4
|
+
useagentledger-0.1.0.dist-info/top_level.txt,sha256=5yo3cw8VcXmvosSKHloeSGsojarwfgP12L4B1EoL74A,12
|
|
5
|
+
useagentledger-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentledger
|