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/__init__.py +7 -0
- agentcap/alerts.py +312 -0
- agentcap/clamp.py +174 -0
- agentcap/cli.py +373 -0
- agentcap/data/pricing.json +383 -0
- agentcap/devtools/__init__.py +1 -0
- agentcap/devtools/fake_upstream.py +412 -0
- agentcap/estimate.py +271 -0
- agentcap/ledger.py +387 -0
- agentcap/license.py +634 -0
- agentcap/loop.py +118 -0
- agentcap/pricing.py +620 -0
- agentcap/proxy.py +1181 -0
- agentcap/runner.py +456 -0
- agentcap/upstreams.py +132 -0
- agentcap/usage.py +428 -0
- agentcap_cli-0.1.0.dist-info/METADATA +304 -0
- agentcap_cli-0.1.0.dist-info/RECORD +21 -0
- agentcap_cli-0.1.0.dist-info/WHEEL +4 -0
- agentcap_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentcap_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
}
|