floe-guard 0.7.0 → 0.9.0

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.
package/dist/index.js CHANGED
@@ -15,15 +15,31 @@ var FloeGuardError = class extends Error {
15
15
  var BudgetExceeded = class extends FloeGuardError {
16
16
  spentUsd;
17
17
  limitUsd;
18
- constructor(spentUsd, limitUsd) {
18
+ constructor(spentUsd, limitUsd, message) {
19
19
  super(
20
- `BUDGET EXCEEDED \u2014 call blocked (spent $${spentUsd.toFixed(6)} of $${limitUsd.toFixed(6)} ceiling)`
20
+ message ?? `BUDGET EXCEEDED \u2014 call blocked (spent $${spentUsd.toFixed(6)} of $${limitUsd.toFixed(6)} ceiling)`
21
21
  );
22
22
  this.name = "BudgetExceeded";
23
23
  this.spentUsd = spentUsd;
24
24
  this.limitUsd = limitUsd;
25
25
  }
26
26
  };
27
+ var TokenBudgetExceeded = class extends BudgetExceeded {
28
+ spentTokens;
29
+ limitTokens;
30
+ scope;
31
+ constructor(spentTokens, limitTokens, scope) {
32
+ super(
33
+ 0,
34
+ 0,
35
+ `TOKEN BUDGET EXCEEDED \u2014 call blocked (${scope}: ${spentTokens} of ${limitTokens} token ceiling)`
36
+ );
37
+ this.name = "TokenBudgetExceeded";
38
+ this.spentTokens = spentTokens;
39
+ this.limitTokens = limitTokens;
40
+ this.scope = scope;
41
+ }
42
+ };
27
43
  var UnpriceableModelError = class extends FloeGuardError {
28
44
  model;
29
45
  constructor(model) {
@@ -1088,12 +1104,23 @@ function priceTokens(priced, promptTokens, completionTokens) {
1088
1104
 
1089
1105
  // src/guard.ts
1090
1106
  var EPS = 1e-12;
1107
+ function isReservation(h) {
1108
+ return typeof h === "object" && h !== null && typeof h.usd === "number" && typeof h.tokens === "number";
1109
+ }
1091
1110
  var BudgetGuard = class {
1092
1111
  limitUsd;
1093
1112
  spentUsd = 0;
1094
1113
  priceOverrides;
1095
1114
  failClosed;
1096
1115
  nearLimitBps;
1116
+ /** Aggregate token ceiling, or null when the token dimension is disabled. */
1117
+ tokenLimit;
1118
+ /** Aggregate tokens accrued (prompt + completion + cache buckets). */
1119
+ spentTokens = 0;
1120
+ /** Tokens held in-flight — the token twin of `reserved`. */
1121
+ reservedTokens = 0;
1122
+ /** Step stack: innermost step is last. Empty when no step() is active. */
1123
+ steps = [];
1097
1124
  onBlock;
1098
1125
  /**
1099
1126
  * Costs of the most recent priced LLM call and tool call, tracked
@@ -1135,7 +1162,13 @@ var BudgetGuard = class {
1135
1162
  `maxLogEvents must be a non-negative integer, got ${options.maxLogEvents}`
1136
1163
  );
1137
1164
  }
1165
+ if (options.tokenLimit !== void 0 && (!Number.isInteger(options.tokenLimit) || options.tokenLimit < 0)) {
1166
+ throw new RangeError(
1167
+ `tokenLimit must be a non-negative integer, got ${options.tokenLimit}`
1168
+ );
1169
+ }
1138
1170
  this.limitUsd = limitUsd;
1171
+ this.tokenLimit = options.tokenLimit ?? null;
1139
1172
  this.maxLogEvents = options.maxLogEvents;
1140
1173
  this.priceOverrides = options.priceOverrides;
1141
1174
  this.failClosed = options.failClosed ?? true;
@@ -1155,7 +1188,7 @@ var BudgetGuard = class {
1155
1188
  * Note: `check` is a non-binding peek. For parallel calls, use `reserve()` /
1156
1189
  * `settle()`, which hold the estimate across the await.
1157
1190
  */
1158
- check(estimatedNextCost) {
1191
+ check(estimatedNextCost, options = {}) {
1159
1192
  const rawEstimate = estimatedNextCost === void 0 ? this.defaultEstimate() : estimatedNextCost;
1160
1193
  if (!Number.isFinite(rawEstimate)) {
1161
1194
  throw new RangeError(
@@ -1163,11 +1196,10 @@ var BudgetGuard = class {
1163
1196
  );
1164
1197
  }
1165
1198
  const estimate = Math.max(0, rawEstimate);
1166
- const committed = this.spentUsd + this.reserved;
1167
- if (committed > this.limitUsd - EPS || committed + estimate > this.limitUsd + EPS) {
1168
- this.onBlock(this.spentUsd, this.limitUsd);
1169
- throw new BudgetExceeded(this.spentUsd, this.limitUsd);
1170
- }
1199
+ this.validateTokenEstimate(options.estimatedTokens);
1200
+ const tokens = Math.max(0, options.estimatedTokens ?? 0);
1201
+ const blocked = this.blockingCross(estimate, tokens);
1202
+ if (blocked !== null) this.raiseBlock(blocked);
1171
1203
  }
1172
1204
  /**
1173
1205
  * Atomically check the ceiling AND hold the estimated cost in flight.
@@ -1180,7 +1212,7 @@ var BudgetGuard = class {
1180
1212
  * `estimatedCost` defaults to the costlier of the last LLM call and the last
1181
1213
  * tool call.
1182
1214
  */
1183
- reserve(estimatedCost) {
1215
+ reserve(estimatedCost, options = {}) {
1184
1216
  const rawEstimate = estimatedCost === void 0 ? this.defaultEstimate() : estimatedCost;
1185
1217
  if (!Number.isFinite(rawEstimate)) {
1186
1218
  throw new RangeError(
@@ -1188,13 +1220,19 @@ var BudgetGuard = class {
1188
1220
  );
1189
1221
  }
1190
1222
  const estimate = Math.max(0, rawEstimate);
1191
- const committed = this.spentUsd + this.reserved;
1192
- if (committed > this.limitUsd - EPS || committed + estimate > this.limitUsd + EPS) {
1193
- this.onBlock(this.spentUsd, this.limitUsd);
1194
- throw new BudgetExceeded(this.spentUsd, this.limitUsd);
1195
- }
1223
+ this.validateTokenEstimate(options.estimatedTokens);
1224
+ const tokens = Math.max(0, options.estimatedTokens ?? 0);
1225
+ const blocked = this.blockingCross(estimate, tokens);
1226
+ if (blocked !== null) this.raiseBlock(blocked);
1196
1227
  this.reserved += estimate;
1197
- return estimate;
1228
+ this.reservedTokens += tokens;
1229
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1230
+ if (step !== null) {
1231
+ step.reservedUsd += estimate;
1232
+ step.reservedTokens += tokens;
1233
+ }
1234
+ if (tokens === 0 && step === null) return estimate;
1235
+ return { usd: estimate, tokens };
1198
1236
  }
1199
1237
  /**
1200
1238
  * Release a reservation and record the actual cost. `record` is `settle` with
@@ -1207,9 +1245,7 @@ var BudgetGuard = class {
1207
1245
  */
1208
1246
  settle(model, promptTokens, completionTokens, options = {}) {
1209
1247
  const reserved = options.reserved ?? 0;
1210
- if (!Number.isFinite(reserved) || reserved < 0) {
1211
- throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
1212
- }
1248
+ const reservedUsd = this.reservedUsdOf(reserved);
1213
1249
  let overrides = this.priceOverrides;
1214
1250
  if (options.price !== void 0) {
1215
1251
  overrides = { ...overrides ?? {}, [model]: options.price };
@@ -1235,7 +1271,10 @@ var BudgetGuard = class {
1235
1271
  if (reserved) {
1236
1272
  this.consumeReservation(reserved);
1237
1273
  }
1274
+ const accruedTokens = Math.max(0, promptTokens) + Math.max(0, completionTokens);
1238
1275
  this.spentUsd += cost;
1276
+ this.spentTokens += accruedTokens;
1277
+ this.accrueStep(cost, accruedTokens);
1239
1278
  if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
1240
1279
  this.spentUsd = this.limitUsd;
1241
1280
  }
@@ -1249,8 +1288,9 @@ var BudgetGuard = class {
1249
1288
  costUsd: cost,
1250
1289
  ...options.label !== void 0 ? { label: options.label } : {},
1251
1290
  // 0 means "no reservation" (the plain record() path) — omit rather than
1252
- // log a meaningless zero.
1253
- ...reserved ? { reserved } : {}
1291
+ // log a meaningless zero. Log the USD amount so the ledger schema stays
1292
+ // number-shaped even for a BudgetReservation.
1293
+ ...reservedUsd ? { reserved: reservedUsd } : {}
1254
1294
  });
1255
1295
  return cost;
1256
1296
  }
@@ -1315,13 +1355,12 @@ var BudgetGuard = class {
1315
1355
  throw new RangeError(`costUsd must be a finite, non-negative number, got ${costUsd}`);
1316
1356
  }
1317
1357
  const reserved = options.reserved ?? 0;
1318
- if (!Number.isFinite(reserved) || reserved < 0) {
1319
- throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
1320
- }
1358
+ const reservedUsd = this.reservedUsdOf(reserved);
1321
1359
  if (reserved) {
1322
1360
  this.consumeReservation(reserved);
1323
1361
  }
1324
1362
  this.spentUsd += costUsd;
1363
+ this.accrueStep(costUsd, 0);
1325
1364
  if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
1326
1365
  this.spentUsd = this.limitUsd;
1327
1366
  }
@@ -1335,7 +1374,7 @@ var BudgetGuard = class {
1335
1374
  completionTokens: null,
1336
1375
  costUsd,
1337
1376
  ...options.label !== void 0 ? { label: options.label } : {},
1338
- ...reserved ? { reserved } : {}
1377
+ ...reservedUsd ? { reserved: reservedUsd } : {}
1339
1378
  });
1340
1379
  return costUsd;
1341
1380
  }
@@ -1355,9 +1394,7 @@ var BudgetGuard = class {
1355
1394
  * before producing usage). Safe to call with `0`.
1356
1395
  */
1357
1396
  release(reserved) {
1358
- if (!Number.isFinite(reserved) || reserved < 0) {
1359
- throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
1360
- }
1397
+ this.reservedUsdOf(reserved);
1361
1398
  if (!reserved) return;
1362
1399
  this.consumeReservation(reserved);
1363
1400
  }
@@ -1429,12 +1466,171 @@ var BudgetGuard = class {
1429
1466
  * responsibility.
1430
1467
  */
1431
1468
  consumeReservation(reserved) {
1432
- if (reserved > this.reserved + EPS) {
1469
+ const usd = isReservation(reserved) ? reserved.usd : reserved;
1470
+ const tokens = isReservation(reserved) ? reserved.tokens : 0;
1471
+ if (usd > this.reserved + EPS) {
1433
1472
  throw new RangeError(
1434
- `reserved handle (${reserved}) exceeds total in-flight reservations (${this.reserved}) \u2014 a handle must come from a matching reserve()`
1473
+ `reserved handle (${usd}) exceeds total in-flight reservations (${this.reserved}) \u2014 a handle must come from a matching reserve()`
1435
1474
  );
1436
1475
  }
1437
- this.reserved = Math.max(0, this.reserved - reserved);
1476
+ if (tokens > this.reservedTokens) {
1477
+ throw new RangeError(
1478
+ `reserved token handle (${tokens}) exceeds total in-flight token reservations (${this.reservedTokens}) \u2014 a handle must come from a matching reserve()`
1479
+ );
1480
+ }
1481
+ this.reserved = Math.max(0, this.reserved - usd);
1482
+ this.reservedTokens = Math.max(0, this.reservedTokens - tokens);
1483
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1484
+ if (step !== null) {
1485
+ step.reservedUsd = Math.max(0, step.reservedUsd - usd);
1486
+ step.reservedTokens = Math.max(0, step.reservedTokens - tokens);
1487
+ }
1488
+ }
1489
+ /**
1490
+ * The USD amount of a handle, validated. Both shapes are checked — a raw
1491
+ * number and a {@link BudgetReservation}'s `usd`/`tokens` fields — so a bad
1492
+ * hand-rolled handle can't corrupt the in-flight tally.
1493
+ */
1494
+ /**
1495
+ * Validate a caller-supplied token estimate. Rejects a fraction, NaN,
1496
+ * Infinity, or boolean (via `Number.isInteger`) so it can't disable the
1497
+ * integer token hard-stops or corrupt the in-flight token tally. `undefined`
1498
+ * (the default) is fine; a negative integer is clamped by `Math.max(0, ...)`,
1499
+ * matching the lenient USD estimate. Mirrors Python's `_validate_token_estimate`.
1500
+ */
1501
+ validateTokenEstimate(estimatedTokens) {
1502
+ if (estimatedTokens !== void 0 && !Number.isInteger(estimatedTokens)) {
1503
+ throw new RangeError(`estimatedTokens must be an integer, got ${estimatedTokens}`);
1504
+ }
1505
+ }
1506
+ reservedUsdOf(reserved) {
1507
+ if (isReservation(reserved)) {
1508
+ if (!Number.isFinite(reserved.usd) || reserved.usd < 0) {
1509
+ throw new RangeError(
1510
+ `reserved.usd must be a finite, non-negative number, got ${reserved.usd}`
1511
+ );
1512
+ }
1513
+ if (!Number.isInteger(reserved.tokens) || reserved.tokens < 0) {
1514
+ throw new RangeError(
1515
+ `reserved.tokens must be a non-negative integer, got ${reserved.tokens}`
1516
+ );
1517
+ }
1518
+ return reserved.usd;
1519
+ }
1520
+ if (!Number.isFinite(reserved) || reserved < 0) {
1521
+ throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
1522
+ }
1523
+ return reserved;
1524
+ }
1525
+ /**
1526
+ * Accrue a settled call into the innermost active step (no-op when none).
1527
+ * Sequential-loop contract: the innermost step owns the call.
1528
+ */
1529
+ accrueStep(cost, tokens) {
1530
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1531
+ if (step !== null) {
1532
+ step.spentUsd += cost;
1533
+ step.spentTokens += tokens;
1534
+ }
1535
+ }
1536
+ /**
1537
+ * The ONE choke point, across two dimensions and two scopes. Returns the first
1538
+ * ceiling that blocks as `[dimension, scope]` — dimension `"usd" | "tokens"`,
1539
+ * scope `"aggregate" | "step"` — or `null` if the call fits everywhere.
1540
+ * Aggregate is checked before step (the guard-wide ceiling is the hard limit).
1541
+ */
1542
+ blockingCross(estimateUsd, estimateTokens) {
1543
+ const committed = this.spentUsd + this.reserved;
1544
+ if (committed > this.limitUsd - EPS || committed + estimateUsd > this.limitUsd + EPS) {
1545
+ return ["usd", "aggregate"];
1546
+ }
1547
+ if (this.tokenLimit !== null) {
1548
+ const committedT = this.spentTokens + this.reservedTokens;
1549
+ if (committedT >= this.tokenLimit || committedT + estimateTokens > this.tokenLimit) {
1550
+ return ["tokens", "aggregate"];
1551
+ }
1552
+ }
1553
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1554
+ if (step !== null) {
1555
+ if (step.maxUsd !== null) {
1556
+ const sCommitted = step.spentUsd + step.reservedUsd;
1557
+ if (sCommitted > step.maxUsd - EPS || sCommitted + estimateUsd > step.maxUsd + EPS) {
1558
+ return ["usd", "step"];
1559
+ }
1560
+ }
1561
+ if (step.maxTokens !== null) {
1562
+ const sCommittedT = step.spentTokens + step.reservedTokens;
1563
+ if (sCommittedT >= step.maxTokens || sCommittedT + estimateTokens > step.maxTokens) {
1564
+ return ["tokens", "step"];
1565
+ }
1566
+ }
1567
+ }
1568
+ return null;
1569
+ }
1570
+ /** Notify + throw the right error for a [dimension, scope] block. */
1571
+ raiseBlock(blocked) {
1572
+ const [dimension, scope] = blocked;
1573
+ if (dimension === "usd") {
1574
+ this.onBlock(this.spentUsd, this.limitUsd);
1575
+ throw new BudgetExceeded(this.spentUsd, this.limitUsd);
1576
+ }
1577
+ let spentT;
1578
+ let limitT;
1579
+ if (scope === "step") {
1580
+ const step = this.steps[this.steps.length - 1];
1581
+ spentT = step.spentTokens + step.reservedTokens;
1582
+ limitT = step.maxTokens ?? 0;
1583
+ } else {
1584
+ spentT = this.spentTokens + this.reservedTokens;
1585
+ limitT = this.tokenLimit ?? 0;
1586
+ }
1587
+ throw new TokenBudgetExceeded(spentT, limitT, scope);
1588
+ }
1589
+ /**
1590
+ * Scope a per-step USD and/or token cap for a **sequential agent loop**.
1591
+ *
1592
+ * Runs `fn` with a step pushed onto the guard's stack; the
1593
+ * enforcement/accrual path honours the innermost active step *on top of* the
1594
+ * aggregate ceilings. A call that would cross the step's `maxUsd` / `maxTokens`
1595
+ * is hard-blocked ({@link BudgetExceeded} / {@link TokenBudgetExceeded} with
1596
+ * `scope: "step"`) even if the aggregate budget has room. `fn` receives the
1597
+ * SAME guard, so no adapter needs to know about steps. The step is popped when
1598
+ * `fn` settles or throws. **Not for concurrent parallel steps on one guard** —
1599
+ * that's out of scope for issue #46; use one guard per parallel branch.
1600
+ */
1601
+ step(options, fn) {
1602
+ const { maxUsd, maxTokens } = options;
1603
+ if (maxUsd !== void 0 && (!Number.isFinite(maxUsd) || maxUsd < 0)) {
1604
+ throw new RangeError(`maxUsd must be a finite, non-negative number, got ${maxUsd}`);
1605
+ }
1606
+ if (maxTokens !== void 0 && (!Number.isInteger(maxTokens) || maxTokens < 0)) {
1607
+ throw new RangeError(`maxTokens must be a non-negative integer, got ${maxTokens}`);
1608
+ }
1609
+ const state = {
1610
+ maxUsd: maxUsd ?? null,
1611
+ maxTokens: maxTokens ?? null,
1612
+ spentUsd: 0,
1613
+ spentTokens: 0,
1614
+ reservedUsd: 0,
1615
+ reservedTokens: 0
1616
+ };
1617
+ this.steps.push(state);
1618
+ const pop = () => {
1619
+ const idx = this.steps.lastIndexOf(state);
1620
+ if (idx !== -1) this.steps.splice(idx, 1);
1621
+ };
1622
+ let result;
1623
+ try {
1624
+ result = fn(this);
1625
+ } catch (err) {
1626
+ pop();
1627
+ throw err;
1628
+ }
1629
+ if (result != null && typeof result.then === "function") {
1630
+ return result.finally(pop);
1631
+ }
1632
+ pop();
1633
+ return result;
1438
1634
  }
1439
1635
  appendEvent(event) {
1440
1636
  this.spendEvents.push(Object.freeze(event));
@@ -1451,17 +1647,63 @@ var BudgetGuard = class {
1451
1647
  */
1452
1648
  advisory() {
1453
1649
  const usedBps = this.limitUsd <= 0 ? 1e4 : Math.max(0, Math.min(1e4, Math.floor(this.spentUsd / this.limitUsd * 1e4 + 1e-9)));
1650
+ const remainingUsd = Math.max(0, this.limitUsd - this.spentUsd);
1651
+ const expectedCost = Math.max(this.lastLlmCost, this.lastToolCost);
1652
+ const estCallsRemaining = expectedCost > 0 ? Math.floor(remainingUsd / expectedCost + 1e-9) : null;
1653
+ let tokenUsedBps = null;
1654
+ let remainingTokens = null;
1655
+ let nearToken = false;
1656
+ if (this.tokenLimit !== null) {
1657
+ tokenUsedBps = this.tokenLimit <= 0 ? 1e4 : Math.max(
1658
+ 0,
1659
+ Math.min(1e4, Math.floor(this.spentTokens / this.tokenLimit * 1e4 + 1e-9))
1660
+ );
1661
+ remainingTokens = Math.max(0, this.tokenLimit - this.spentTokens);
1662
+ nearToken = tokenUsedBps >= this.nearLimitBps;
1663
+ }
1664
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1665
+ let stepRemainingUsd = null;
1666
+ let stepRemainingTokens = null;
1667
+ let nearStep = false;
1668
+ if (step !== null) {
1669
+ if (step.maxUsd !== null) {
1670
+ stepRemainingUsd = Math.max(0, step.maxUsd - step.spentUsd);
1671
+ if (step.maxUsd <= 0) {
1672
+ nearStep = true;
1673
+ } else {
1674
+ const sBps = Math.floor(step.spentUsd / step.maxUsd * 1e4 + 1e-9);
1675
+ nearStep = nearStep || sBps >= this.nearLimitBps;
1676
+ }
1677
+ }
1678
+ if (step.maxTokens !== null) {
1679
+ stepRemainingTokens = Math.max(0, step.maxTokens - step.spentTokens);
1680
+ if (step.maxTokens <= 0) {
1681
+ nearStep = true;
1682
+ } else {
1683
+ const sTBps = Math.floor(step.spentTokens / step.maxTokens * 1e4 + 1e-9);
1684
+ nearStep = nearStep || sTBps >= this.nearLimitBps;
1685
+ }
1686
+ }
1687
+ }
1454
1688
  return {
1455
- nearLimit: usedBps >= this.nearLimitBps,
1689
+ // nearLimit now also flips when a token ceiling or the active step is near
1690
+ // its cap, so a router can downshift before ANY hard-stop.
1691
+ nearLimit: usedBps >= this.nearLimitBps || nearToken || nearStep,
1456
1692
  usedBps,
1457
1693
  // Settled budget: limit minus accrued spend, deliberately NOT net of
1458
1694
  // in-flight reservations. Unlike the remainingUsd getter (which subtracts
1459
1695
  // `reserved`), the advisory is a soft utilization signal about money already
1460
1696
  // spent, while the getter reports what a new call can still claim.
1461
- remainingUsd: Math.max(0, this.limitUsd - this.spentUsd),
1697
+ remainingUsd,
1462
1698
  limitUsd: this.limitUsd,
1463
1699
  spentUsd: this.spentUsd,
1464
- scope: "local"
1700
+ scope: "local",
1701
+ expectedCost,
1702
+ estCallsRemaining,
1703
+ tokenUsedBps,
1704
+ remainingTokens,
1705
+ stepRemainingUsd,
1706
+ stepRemainingTokens
1465
1707
  };
1466
1708
  }
1467
1709
  };
@@ -1666,6 +1908,7 @@ export {
1666
1908
  DeadlineExceeded,
1667
1909
  FloeGuardError,
1668
1910
  LatencyBudget,
1911
+ TokenBudgetExceeded,
1669
1912
  UnpriceableModelError,
1670
1913
  budgetGuardMiddleware,
1671
1914
  priceTokens,