agentcap-cli 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.
agentcap/ledger.py ADDED
@@ -0,0 +1,387 @@
1
+ """SQLite-backed reservation ledger with rolling spend caps.
2
+
3
+ Every reservation is checked and inserted inside ``BEGIN IMMEDIATE`` so that
4
+ concurrent workers sharing one ledger file cannot jointly overshoot a cap.
5
+ Amounts are stored as decimal strings and summed as :class:`~decimal.Decimal`
6
+ in Python, never as floats.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sqlite3
13
+ import time
14
+ import uuid
15
+ from contextlib import contextmanager
16
+ from dataclasses import dataclass
17
+ from decimal import Decimal, InvalidOperation
18
+ from pathlib import Path
19
+ from typing import Callable, Iterator
20
+
21
+ ZERO = Decimal("0")
22
+ _STATUSES = ("reserved", "settled", "released")
23
+
24
+
25
+ def default_ledger_path() -> Path:
26
+ env_path = os.environ.get("AGENTCAP_DB") or os.environ.get("AGENTCAP_LEDGER_PATH")
27
+ if env_path:
28
+ return Path(env_path).expanduser()
29
+ return Path.home() / ".local" / "state" / "agentcap" / "ledger.sqlite3"
30
+
31
+
32
+ def _money(value: object, *, field: str) -> Decimal:
33
+ if isinstance(value, bool):
34
+ raise TypeError(f"{field} must be a Decimal, not bool")
35
+ if isinstance(value, float):
36
+ raise TypeError(
37
+ f"{field} must be a Decimal/str/int, not float (money math must be exact)"
38
+ )
39
+ if isinstance(value, Decimal):
40
+ result = value
41
+ elif isinstance(value, (str, int)):
42
+ try:
43
+ result = Decimal(str(value))
44
+ except (InvalidOperation, ValueError) as exc:
45
+ raise ValueError(f"{field} is not a valid decimal: {value!r}") from exc
46
+ else:
47
+ raise TypeError(f"{field} must be a Decimal, str or int")
48
+ if not result.is_finite() or result < 0:
49
+ raise ValueError(f"{field} must be a finite non-negative decimal")
50
+ return result
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Caps:
55
+ """Active spend caps. ``None`` disables a scope."""
56
+
57
+ run_usd: Decimal | None = None
58
+ hour_usd: Decimal | None = None
59
+ day_usd: Decimal | None = None
60
+
61
+ def __post_init__(self) -> None:
62
+ for field in ("run_usd", "hour_usd", "day_usd"):
63
+ value = getattr(self, field)
64
+ if value is not None:
65
+ object.__setattr__(self, field, _money(value, field=field))
66
+
67
+
68
+ class LedgerError(Exception):
69
+ """Base ledger error."""
70
+
71
+
72
+ class ReservationNotFound(LedgerError):
73
+ pass
74
+
75
+
76
+ class InvalidReservationState(LedgerError):
77
+ pass
78
+
79
+
80
+ class CapExceeded(LedgerError):
81
+ """A reservation would push one of the active caps over its limit."""
82
+
83
+ def __init__(
84
+ self,
85
+ *,
86
+ scope: str,
87
+ cap: Decimal,
88
+ committed: Decimal,
89
+ requested: Decimal,
90
+ ) -> None:
91
+ self.scope = scope
92
+ self.cap = cap
93
+ self.committed = committed
94
+ self.requested = requested
95
+ super().__init__(
96
+ f"{scope} cap exceeded: committed ${committed} + requested "
97
+ f"${requested} > cap ${cap}"
98
+ )
99
+
100
+
101
+ class Ledger:
102
+ """Thread-safe ledger. One SQLite connection is opened per operation."""
103
+
104
+ def __init__(
105
+ self,
106
+ path: str | os.PathLike[str] | None = None,
107
+ *,
108
+ clock: Callable[[], float] | None = None,
109
+ ) -> None:
110
+ self.path = Path(path).expanduser() if path is not None else default_ledger_path()
111
+ self._clock = clock or time.time
112
+ self._initialise()
113
+
114
+ # ------------------------------------------------------------------ setup
115
+ def _initialise(self) -> None:
116
+ self.path.parent.mkdir(parents=True, exist_ok=True)
117
+ conn = sqlite3.connect(str(self.path), timeout=30.0, isolation_level=None)
118
+ try:
119
+ conn.execute("PRAGMA journal_mode=WAL")
120
+ conn.execute("PRAGMA synchronous=NORMAL")
121
+ conn.execute("PRAGMA busy_timeout=30000")
122
+ conn.executescript(
123
+ """
124
+ CREATE TABLE IF NOT EXISTS reservations (
125
+ id TEXT PRIMARY KEY,
126
+ run_id TEXT NOT NULL,
127
+ provider TEXT NOT NULL,
128
+ model TEXT NOT NULL,
129
+ reserved_usd TEXT NOT NULL,
130
+ actual_usd TEXT,
131
+ status TEXT NOT NULL
132
+ CHECK (status IN ('reserved', 'settled', 'released')),
133
+ created_at REAL NOT NULL,
134
+ settled_at REAL
135
+ );
136
+ CREATE INDEX IF NOT EXISTS idx_reservations_created
137
+ ON reservations(created_at);
138
+ CREATE INDEX IF NOT EXISTS idx_reservations_run
139
+ ON reservations(run_id, created_at);
140
+ CREATE INDEX IF NOT EXISTS idx_reservations_status
141
+ ON reservations(status, created_at);
142
+ """
143
+ )
144
+ finally:
145
+ conn.close()
146
+ try:
147
+ os.chmod(self.path, 0o600)
148
+ except OSError:
149
+ pass
150
+
151
+ def _connect(self) -> sqlite3.Connection:
152
+ conn = sqlite3.connect(
153
+ str(self.path), timeout=30.0, isolation_level=None
154
+ )
155
+ conn.row_factory = sqlite3.Row
156
+ conn.execute("PRAGMA busy_timeout=30000")
157
+ conn.execute("PRAGMA foreign_keys=ON")
158
+ return conn
159
+
160
+ @contextmanager
161
+ def _transaction(self) -> Iterator[sqlite3.Connection]:
162
+ conn = self._connect()
163
+ try:
164
+ conn.execute("BEGIN IMMEDIATE")
165
+ try:
166
+ yield conn
167
+ except BaseException:
168
+ try:
169
+ conn.execute("ROLLBACK")
170
+ except sqlite3.Error:
171
+ pass
172
+ raise
173
+ else:
174
+ conn.execute("COMMIT")
175
+ finally:
176
+ conn.close()
177
+
178
+ @contextmanager
179
+ def _reader(self) -> Iterator[sqlite3.Connection]:
180
+ conn = self._connect()
181
+ try:
182
+ yield conn
183
+ finally:
184
+ conn.close()
185
+
186
+ # --------------------------------------------------------------- internals
187
+ @staticmethod
188
+ def _committed(
189
+ conn: sqlite3.Connection,
190
+ *,
191
+ run_id: str | None = None,
192
+ since: float | None = None,
193
+ ) -> Decimal:
194
+ clauses = ["status IN ('reserved', 'settled')"]
195
+ params: list[object] = []
196
+ if run_id is not None:
197
+ clauses.append("run_id = ?")
198
+ params.append(run_id)
199
+ if since is not None:
200
+ clauses.append("created_at >= ?")
201
+ params.append(since)
202
+ sql = (
203
+ "SELECT status, reserved_usd, actual_usd FROM reservations WHERE "
204
+ + " AND ".join(clauses)
205
+ )
206
+ total = ZERO
207
+ for row in conn.execute(sql, params):
208
+ if row["status"] == "settled" and row["actual_usd"] is not None:
209
+ total += Decimal(row["actual_usd"])
210
+ else:
211
+ total += Decimal(row["reserved_usd"])
212
+ return total
213
+
214
+ def _check_cap(
215
+ self,
216
+ *,
217
+ scope: str,
218
+ cap: Decimal | None,
219
+ committed: Decimal,
220
+ requested: Decimal,
221
+ ) -> None:
222
+ if cap is None:
223
+ return
224
+ if committed + requested > cap:
225
+ raise CapExceeded(
226
+ scope=scope, cap=cap, committed=committed, requested=requested
227
+ )
228
+
229
+ # ----------------------------------------------------------------- writing
230
+ def reserve(
231
+ self,
232
+ run_id: str,
233
+ provider: str,
234
+ model: str,
235
+ amount: Decimal | str | int,
236
+ caps: Caps | None = None,
237
+ ) -> str:
238
+ """Atomically reserve worst-case spend and return the reservation id."""
239
+
240
+ if not isinstance(run_id, str) or not run_id:
241
+ raise ValueError("run_id must be a non-empty string")
242
+ requested = _money(amount, field="amount")
243
+ active = caps or Caps()
244
+ now = self._clock()
245
+
246
+ with self._transaction() as conn:
247
+ run_committed = self._committed(conn, run_id=run_id)
248
+ self._check_cap(
249
+ scope="run",
250
+ cap=active.run_usd,
251
+ committed=run_committed,
252
+ requested=requested,
253
+ )
254
+ hour_committed = self._committed(conn, since=now - 3600.0)
255
+ self._check_cap(
256
+ scope="hour",
257
+ cap=active.hour_usd,
258
+ committed=hour_committed,
259
+ requested=requested,
260
+ )
261
+ day_committed = self._committed(conn, since=now - 86400.0)
262
+ self._check_cap(
263
+ scope="day",
264
+ cap=active.day_usd,
265
+ committed=day_committed,
266
+ requested=requested,
267
+ )
268
+
269
+ reservation_id = uuid.uuid4().hex
270
+ conn.execute(
271
+ """
272
+ INSERT INTO reservations
273
+ (id, run_id, provider, model, reserved_usd, actual_usd,
274
+ status, created_at, settled_at)
275
+ VALUES (?, ?, ?, ?, ?, NULL, 'reserved', ?, NULL)
276
+ """,
277
+ (
278
+ reservation_id,
279
+ run_id,
280
+ provider,
281
+ model,
282
+ str(requested),
283
+ now,
284
+ ),
285
+ )
286
+ return reservation_id
287
+
288
+ def settle(self, reservation_id: str, actual_usd: Decimal | str | int) -> bool:
289
+ """Mark a reservation settled. Returns True when actual > reserved."""
290
+
291
+ actual = _money(actual_usd, field="actual_usd")
292
+ now = self._clock()
293
+ with self._transaction() as conn:
294
+ row = conn.execute(
295
+ "SELECT status, reserved_usd FROM reservations WHERE id = ?",
296
+ (reservation_id,),
297
+ ).fetchone()
298
+ if row is None:
299
+ raise ReservationNotFound(f"reservation {reservation_id!r} not found")
300
+ if row["status"] != "reserved":
301
+ raise InvalidReservationState(
302
+ f"reservation {reservation_id!r} is {row['status']}, not reserved"
303
+ )
304
+ overshoot = actual > Decimal(row["reserved_usd"])
305
+ conn.execute(
306
+ """
307
+ UPDATE reservations
308
+ SET status = 'settled', actual_usd = ?, settled_at = ?
309
+ WHERE id = ? AND status = 'reserved'
310
+ """,
311
+ (str(actual), now, reservation_id),
312
+ )
313
+ return overshoot
314
+
315
+ def release(self, reservation_id: str) -> None:
316
+ """Drop a reservation (e.g. upstream failed before producing tokens)."""
317
+
318
+ now = self._clock()
319
+ with self._transaction() as conn:
320
+ row = conn.execute(
321
+ "SELECT status FROM reservations WHERE id = ?",
322
+ (reservation_id,),
323
+ ).fetchone()
324
+ if row is None:
325
+ raise ReservationNotFound(f"reservation {reservation_id!r} not found")
326
+ if row["status"] != "reserved":
327
+ raise InvalidReservationState(
328
+ f"reservation {reservation_id!r} is {row['status']}, not reserved"
329
+ )
330
+ conn.execute(
331
+ """
332
+ UPDATE reservations
333
+ SET status = 'released', actual_usd = NULL, settled_at = ?
334
+ WHERE id = ? AND status = 'reserved'
335
+ """,
336
+ (now, reservation_id),
337
+ )
338
+
339
+ # ---------------------------------------------------------------- reading
340
+ def spent(self, run_id: str) -> Decimal:
341
+ """Committed spend for a run: settled actuals + open reservations."""
342
+
343
+ with self._reader() as conn:
344
+ return self._committed(conn, run_id=run_id)
345
+
346
+ def window_spent(self, seconds: float) -> Decimal:
347
+ """Committed spend across all runs in the last ``seconds``."""
348
+
349
+ if seconds <= 0:
350
+ raise ValueError("seconds must be positive")
351
+ cutoff = self._clock() - float(seconds)
352
+ with self._reader() as conn:
353
+ return self._committed(conn, since=cutoff)
354
+
355
+ def summary(self, run_id: str) -> dict[str, object]:
356
+ with self._reader() as conn:
357
+ rows = conn.execute(
358
+ "SELECT status, reserved_usd, actual_usd, created_at "
359
+ "FROM reservations WHERE run_id = ?",
360
+ (run_id,),
361
+ ).fetchall()
362
+
363
+ reserved = ZERO
364
+ actual = ZERO
365
+ open_count = 0
366
+ settled_count = 0
367
+ released_count = 0
368
+ for row in rows:
369
+ if row["status"] == "reserved":
370
+ open_count += 1
371
+ reserved += Decimal(row["reserved_usd"])
372
+ elif row["status"] == "settled":
373
+ settled_count += 1
374
+ actual += Decimal(row["actual_usd"] or row["reserved_usd"])
375
+ elif row["status"] == "released":
376
+ released_count += 1
377
+
378
+ return {
379
+ "run_id": run_id,
380
+ "reservations": len(rows),
381
+ "open": open_count,
382
+ "settled": settled_count,
383
+ "released": released_count,
384
+ "reserved_usd": reserved,
385
+ "actual_usd": actual,
386
+ "committed_usd": reserved + actual,
387
+ }