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/README.md CHANGED
@@ -79,6 +79,40 @@ guard.recordTool("exa.search", 0.004); // post-hoc, for metered APIs
79
79
  guard.toolCosts; // { "apollo.people_lookup": 0.42, "exa.search": 0.11 }
80
80
  ```
81
81
 
82
+ ## Token ceilings and per-step budgets
83
+
84
+ Cap *total token usage — every bucket the guard counts: prompt, completion, and
85
+ cache* (a token ceiling) and keep one step of a sequential loop from starving the
86
+ rest (a per-step cap) — a second dimension on the same reserve/settle machinery,
87
+ not a second guard:
88
+
89
+ ```ts
90
+ import { BudgetGuard, TokenBudgetExceeded } from "floe-guard";
91
+
92
+ // aggregate token ceiling alongside the USD one
93
+ const guard = new BudgetGuard(100, { tokenLimit: 20_000 });
94
+ guard.check(undefined, { estimatedTokens: 1_200 }); // throws TokenBudgetExceeded if it'd cross
95
+ guard.record("gpt-4o", 800, 400); // tokens accrue for free from the counts
96
+
97
+ // a per-step cap for one step of a sequential loop (callback — g IS guard)
98
+ guard.step({ maxTokens: 5_000 }, (g) => {
99
+ g.record("gpt-4o", 3_000, 1_500);
100
+ g.check(undefined, { estimatedTokens: 1_000 }); // 4_500 + 1_000 > 5_000 → scope "step"
101
+ });
102
+
103
+ const adv = guard.advisory();
104
+ adv.tokenUsedBps; // aggregate token utilization (null if no tokenLimit)
105
+ adv.remainingTokens; // tokens left before the ceiling (null if no tokenLimit)
106
+ adv.stepRemainingTokens; // active step's headroom (null if no step, or its token cap is unset)
107
+ ```
108
+
109
+ `TokenBudgetExceeded` extends `BudgetExceeded`, so budget-aware retry treats a
110
+ token block as terminal automatically. With no `tokenLimit` and no `step()`, USD
111
+ enforcement is unchanged and `reserve()` still returns a plain `number` — a
112
+ `BudgetReservation` handle appears only when tokens are actually reserved or a
113
+ step is active. (`advisory()` gains the token/step fields above; additive and
114
+ `null` when their dimension is unused.)
115
+
82
116
  ## Per-call spend log
83
117
 
84
118
  The guard keeps a typed, in-memory ledger of everything it priced: each
package/dist/index.cjs CHANGED
@@ -25,6 +25,7 @@ __export(index_exports, {
25
25
  DeadlineExceeded: () => DeadlineExceeded,
26
26
  FloeGuardError: () => FloeGuardError,
27
27
  LatencyBudget: () => LatencyBudget,
28
+ TokenBudgetExceeded: () => TokenBudgetExceeded,
28
29
  UnpriceableModelError: () => UnpriceableModelError,
29
30
  budgetGuardMiddleware: () => budgetGuardMiddleware,
30
31
  priceTokens: () => priceTokens,
@@ -45,15 +46,31 @@ var FloeGuardError = class extends Error {
45
46
  var BudgetExceeded = class extends FloeGuardError {
46
47
  spentUsd;
47
48
  limitUsd;
48
- constructor(spentUsd, limitUsd) {
49
+ constructor(spentUsd, limitUsd, message) {
49
50
  super(
50
- `BUDGET EXCEEDED \u2014 call blocked (spent $${spentUsd.toFixed(6)} of $${limitUsd.toFixed(6)} ceiling)`
51
+ message ?? `BUDGET EXCEEDED \u2014 call blocked (spent $${spentUsd.toFixed(6)} of $${limitUsd.toFixed(6)} ceiling)`
51
52
  );
52
53
  this.name = "BudgetExceeded";
53
54
  this.spentUsd = spentUsd;
54
55
  this.limitUsd = limitUsd;
55
56
  }
56
57
  };
58
+ var TokenBudgetExceeded = class extends BudgetExceeded {
59
+ spentTokens;
60
+ limitTokens;
61
+ scope;
62
+ constructor(spentTokens, limitTokens, scope) {
63
+ super(
64
+ 0,
65
+ 0,
66
+ `TOKEN BUDGET EXCEEDED \u2014 call blocked (${scope}: ${spentTokens} of ${limitTokens} token ceiling)`
67
+ );
68
+ this.name = "TokenBudgetExceeded";
69
+ this.spentTokens = spentTokens;
70
+ this.limitTokens = limitTokens;
71
+ this.scope = scope;
72
+ }
73
+ };
57
74
  var UnpriceableModelError = class extends FloeGuardError {
58
75
  model;
59
76
  constructor(model) {
@@ -1118,12 +1135,23 @@ function priceTokens(priced, promptTokens, completionTokens) {
1118
1135
 
1119
1136
  // src/guard.ts
1120
1137
  var EPS = 1e-12;
1138
+ function isReservation(h) {
1139
+ return typeof h === "object" && h !== null && typeof h.usd === "number" && typeof h.tokens === "number";
1140
+ }
1121
1141
  var BudgetGuard = class {
1122
1142
  limitUsd;
1123
1143
  spentUsd = 0;
1124
1144
  priceOverrides;
1125
1145
  failClosed;
1126
1146
  nearLimitBps;
1147
+ /** Aggregate token ceiling, or null when the token dimension is disabled. */
1148
+ tokenLimit;
1149
+ /** Aggregate tokens accrued (prompt + completion + cache buckets). */
1150
+ spentTokens = 0;
1151
+ /** Tokens held in-flight — the token twin of `reserved`. */
1152
+ reservedTokens = 0;
1153
+ /** Step stack: innermost step is last. Empty when no step() is active. */
1154
+ steps = [];
1127
1155
  onBlock;
1128
1156
  /**
1129
1157
  * Costs of the most recent priced LLM call and tool call, tracked
@@ -1165,7 +1193,13 @@ var BudgetGuard = class {
1165
1193
  `maxLogEvents must be a non-negative integer, got ${options.maxLogEvents}`
1166
1194
  );
1167
1195
  }
1196
+ if (options.tokenLimit !== void 0 && (!Number.isInteger(options.tokenLimit) || options.tokenLimit < 0)) {
1197
+ throw new RangeError(
1198
+ `tokenLimit must be a non-negative integer, got ${options.tokenLimit}`
1199
+ );
1200
+ }
1168
1201
  this.limitUsd = limitUsd;
1202
+ this.tokenLimit = options.tokenLimit ?? null;
1169
1203
  this.maxLogEvents = options.maxLogEvents;
1170
1204
  this.priceOverrides = options.priceOverrides;
1171
1205
  this.failClosed = options.failClosed ?? true;
@@ -1185,7 +1219,7 @@ var BudgetGuard = class {
1185
1219
  * Note: `check` is a non-binding peek. For parallel calls, use `reserve()` /
1186
1220
  * `settle()`, which hold the estimate across the await.
1187
1221
  */
1188
- check(estimatedNextCost) {
1222
+ check(estimatedNextCost, options = {}) {
1189
1223
  const rawEstimate = estimatedNextCost === void 0 ? this.defaultEstimate() : estimatedNextCost;
1190
1224
  if (!Number.isFinite(rawEstimate)) {
1191
1225
  throw new RangeError(
@@ -1193,11 +1227,10 @@ var BudgetGuard = class {
1193
1227
  );
1194
1228
  }
1195
1229
  const estimate = Math.max(0, rawEstimate);
1196
- const committed = this.spentUsd + this.reserved;
1197
- if (committed > this.limitUsd - EPS || committed + estimate > this.limitUsd + EPS) {
1198
- this.onBlock(this.spentUsd, this.limitUsd);
1199
- throw new BudgetExceeded(this.spentUsd, this.limitUsd);
1200
- }
1230
+ this.validateTokenEstimate(options.estimatedTokens);
1231
+ const tokens = Math.max(0, options.estimatedTokens ?? 0);
1232
+ const blocked = this.blockingCross(estimate, tokens);
1233
+ if (blocked !== null) this.raiseBlock(blocked);
1201
1234
  }
1202
1235
  /**
1203
1236
  * Atomically check the ceiling AND hold the estimated cost in flight.
@@ -1210,7 +1243,7 @@ var BudgetGuard = class {
1210
1243
  * `estimatedCost` defaults to the costlier of the last LLM call and the last
1211
1244
  * tool call.
1212
1245
  */
1213
- reserve(estimatedCost) {
1246
+ reserve(estimatedCost, options = {}) {
1214
1247
  const rawEstimate = estimatedCost === void 0 ? this.defaultEstimate() : estimatedCost;
1215
1248
  if (!Number.isFinite(rawEstimate)) {
1216
1249
  throw new RangeError(
@@ -1218,13 +1251,19 @@ var BudgetGuard = class {
1218
1251
  );
1219
1252
  }
1220
1253
  const estimate = Math.max(0, rawEstimate);
1221
- const committed = this.spentUsd + this.reserved;
1222
- if (committed > this.limitUsd - EPS || committed + estimate > this.limitUsd + EPS) {
1223
- this.onBlock(this.spentUsd, this.limitUsd);
1224
- throw new BudgetExceeded(this.spentUsd, this.limitUsd);
1225
- }
1254
+ this.validateTokenEstimate(options.estimatedTokens);
1255
+ const tokens = Math.max(0, options.estimatedTokens ?? 0);
1256
+ const blocked = this.blockingCross(estimate, tokens);
1257
+ if (blocked !== null) this.raiseBlock(blocked);
1226
1258
  this.reserved += estimate;
1227
- return estimate;
1259
+ this.reservedTokens += tokens;
1260
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1261
+ if (step !== null) {
1262
+ step.reservedUsd += estimate;
1263
+ step.reservedTokens += tokens;
1264
+ }
1265
+ if (tokens === 0 && step === null) return estimate;
1266
+ return { usd: estimate, tokens };
1228
1267
  }
1229
1268
  /**
1230
1269
  * Release a reservation and record the actual cost. `record` is `settle` with
@@ -1237,9 +1276,7 @@ var BudgetGuard = class {
1237
1276
  */
1238
1277
  settle(model, promptTokens, completionTokens, options = {}) {
1239
1278
  const reserved = options.reserved ?? 0;
1240
- if (!Number.isFinite(reserved) || reserved < 0) {
1241
- throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
1242
- }
1279
+ const reservedUsd = this.reservedUsdOf(reserved);
1243
1280
  let overrides = this.priceOverrides;
1244
1281
  if (options.price !== void 0) {
1245
1282
  overrides = { ...overrides ?? {}, [model]: options.price };
@@ -1265,7 +1302,10 @@ var BudgetGuard = class {
1265
1302
  if (reserved) {
1266
1303
  this.consumeReservation(reserved);
1267
1304
  }
1305
+ const accruedTokens = Math.max(0, promptTokens) + Math.max(0, completionTokens);
1268
1306
  this.spentUsd += cost;
1307
+ this.spentTokens += accruedTokens;
1308
+ this.accrueStep(cost, accruedTokens);
1269
1309
  if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
1270
1310
  this.spentUsd = this.limitUsd;
1271
1311
  }
@@ -1279,8 +1319,9 @@ var BudgetGuard = class {
1279
1319
  costUsd: cost,
1280
1320
  ...options.label !== void 0 ? { label: options.label } : {},
1281
1321
  // 0 means "no reservation" (the plain record() path) — omit rather than
1282
- // log a meaningless zero.
1283
- ...reserved ? { reserved } : {}
1322
+ // log a meaningless zero. Log the USD amount so the ledger schema stays
1323
+ // number-shaped even for a BudgetReservation.
1324
+ ...reservedUsd ? { reserved: reservedUsd } : {}
1284
1325
  });
1285
1326
  return cost;
1286
1327
  }
@@ -1345,13 +1386,12 @@ var BudgetGuard = class {
1345
1386
  throw new RangeError(`costUsd must be a finite, non-negative number, got ${costUsd}`);
1346
1387
  }
1347
1388
  const reserved = options.reserved ?? 0;
1348
- if (!Number.isFinite(reserved) || reserved < 0) {
1349
- throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
1350
- }
1389
+ const reservedUsd = this.reservedUsdOf(reserved);
1351
1390
  if (reserved) {
1352
1391
  this.consumeReservation(reserved);
1353
1392
  }
1354
1393
  this.spentUsd += costUsd;
1394
+ this.accrueStep(costUsd, 0);
1355
1395
  if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
1356
1396
  this.spentUsd = this.limitUsd;
1357
1397
  }
@@ -1365,7 +1405,7 @@ var BudgetGuard = class {
1365
1405
  completionTokens: null,
1366
1406
  costUsd,
1367
1407
  ...options.label !== void 0 ? { label: options.label } : {},
1368
- ...reserved ? { reserved } : {}
1408
+ ...reservedUsd ? { reserved: reservedUsd } : {}
1369
1409
  });
1370
1410
  return costUsd;
1371
1411
  }
@@ -1385,9 +1425,7 @@ var BudgetGuard = class {
1385
1425
  * before producing usage). Safe to call with `0`.
1386
1426
  */
1387
1427
  release(reserved) {
1388
- if (!Number.isFinite(reserved) || reserved < 0) {
1389
- throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
1390
- }
1428
+ this.reservedUsdOf(reserved);
1391
1429
  if (!reserved) return;
1392
1430
  this.consumeReservation(reserved);
1393
1431
  }
@@ -1459,12 +1497,171 @@ var BudgetGuard = class {
1459
1497
  * responsibility.
1460
1498
  */
1461
1499
  consumeReservation(reserved) {
1462
- if (reserved > this.reserved + EPS) {
1500
+ const usd = isReservation(reserved) ? reserved.usd : reserved;
1501
+ const tokens = isReservation(reserved) ? reserved.tokens : 0;
1502
+ if (usd > this.reserved + EPS) {
1463
1503
  throw new RangeError(
1464
- `reserved handle (${reserved}) exceeds total in-flight reservations (${this.reserved}) \u2014 a handle must come from a matching reserve()`
1504
+ `reserved handle (${usd}) exceeds total in-flight reservations (${this.reserved}) \u2014 a handle must come from a matching reserve()`
1465
1505
  );
1466
1506
  }
1467
- this.reserved = Math.max(0, this.reserved - reserved);
1507
+ if (tokens > this.reservedTokens) {
1508
+ throw new RangeError(
1509
+ `reserved token handle (${tokens}) exceeds total in-flight token reservations (${this.reservedTokens}) \u2014 a handle must come from a matching reserve()`
1510
+ );
1511
+ }
1512
+ this.reserved = Math.max(0, this.reserved - usd);
1513
+ this.reservedTokens = Math.max(0, this.reservedTokens - tokens);
1514
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1515
+ if (step !== null) {
1516
+ step.reservedUsd = Math.max(0, step.reservedUsd - usd);
1517
+ step.reservedTokens = Math.max(0, step.reservedTokens - tokens);
1518
+ }
1519
+ }
1520
+ /**
1521
+ * The USD amount of a handle, validated. Both shapes are checked — a raw
1522
+ * number and a {@link BudgetReservation}'s `usd`/`tokens` fields — so a bad
1523
+ * hand-rolled handle can't corrupt the in-flight tally.
1524
+ */
1525
+ /**
1526
+ * Validate a caller-supplied token estimate. Rejects a fraction, NaN,
1527
+ * Infinity, or boolean (via `Number.isInteger`) so it can't disable the
1528
+ * integer token hard-stops or corrupt the in-flight token tally. `undefined`
1529
+ * (the default) is fine; a negative integer is clamped by `Math.max(0, ...)`,
1530
+ * matching the lenient USD estimate. Mirrors Python's `_validate_token_estimate`.
1531
+ */
1532
+ validateTokenEstimate(estimatedTokens) {
1533
+ if (estimatedTokens !== void 0 && !Number.isInteger(estimatedTokens)) {
1534
+ throw new RangeError(`estimatedTokens must be an integer, got ${estimatedTokens}`);
1535
+ }
1536
+ }
1537
+ reservedUsdOf(reserved) {
1538
+ if (isReservation(reserved)) {
1539
+ if (!Number.isFinite(reserved.usd) || reserved.usd < 0) {
1540
+ throw new RangeError(
1541
+ `reserved.usd must be a finite, non-negative number, got ${reserved.usd}`
1542
+ );
1543
+ }
1544
+ if (!Number.isInteger(reserved.tokens) || reserved.tokens < 0) {
1545
+ throw new RangeError(
1546
+ `reserved.tokens must be a non-negative integer, got ${reserved.tokens}`
1547
+ );
1548
+ }
1549
+ return reserved.usd;
1550
+ }
1551
+ if (!Number.isFinite(reserved) || reserved < 0) {
1552
+ throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
1553
+ }
1554
+ return reserved;
1555
+ }
1556
+ /**
1557
+ * Accrue a settled call into the innermost active step (no-op when none).
1558
+ * Sequential-loop contract: the innermost step owns the call.
1559
+ */
1560
+ accrueStep(cost, tokens) {
1561
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1562
+ if (step !== null) {
1563
+ step.spentUsd += cost;
1564
+ step.spentTokens += tokens;
1565
+ }
1566
+ }
1567
+ /**
1568
+ * The ONE choke point, across two dimensions and two scopes. Returns the first
1569
+ * ceiling that blocks as `[dimension, scope]` — dimension `"usd" | "tokens"`,
1570
+ * scope `"aggregate" | "step"` — or `null` if the call fits everywhere.
1571
+ * Aggregate is checked before step (the guard-wide ceiling is the hard limit).
1572
+ */
1573
+ blockingCross(estimateUsd, estimateTokens) {
1574
+ const committed = this.spentUsd + this.reserved;
1575
+ if (committed > this.limitUsd - EPS || committed + estimateUsd > this.limitUsd + EPS) {
1576
+ return ["usd", "aggregate"];
1577
+ }
1578
+ if (this.tokenLimit !== null) {
1579
+ const committedT = this.spentTokens + this.reservedTokens;
1580
+ if (committedT >= this.tokenLimit || committedT + estimateTokens > this.tokenLimit) {
1581
+ return ["tokens", "aggregate"];
1582
+ }
1583
+ }
1584
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1585
+ if (step !== null) {
1586
+ if (step.maxUsd !== null) {
1587
+ const sCommitted = step.spentUsd + step.reservedUsd;
1588
+ if (sCommitted > step.maxUsd - EPS || sCommitted + estimateUsd > step.maxUsd + EPS) {
1589
+ return ["usd", "step"];
1590
+ }
1591
+ }
1592
+ if (step.maxTokens !== null) {
1593
+ const sCommittedT = step.spentTokens + step.reservedTokens;
1594
+ if (sCommittedT >= step.maxTokens || sCommittedT + estimateTokens > step.maxTokens) {
1595
+ return ["tokens", "step"];
1596
+ }
1597
+ }
1598
+ }
1599
+ return null;
1600
+ }
1601
+ /** Notify + throw the right error for a [dimension, scope] block. */
1602
+ raiseBlock(blocked) {
1603
+ const [dimension, scope] = blocked;
1604
+ if (dimension === "usd") {
1605
+ this.onBlock(this.spentUsd, this.limitUsd);
1606
+ throw new BudgetExceeded(this.spentUsd, this.limitUsd);
1607
+ }
1608
+ let spentT;
1609
+ let limitT;
1610
+ if (scope === "step") {
1611
+ const step = this.steps[this.steps.length - 1];
1612
+ spentT = step.spentTokens + step.reservedTokens;
1613
+ limitT = step.maxTokens ?? 0;
1614
+ } else {
1615
+ spentT = this.spentTokens + this.reservedTokens;
1616
+ limitT = this.tokenLimit ?? 0;
1617
+ }
1618
+ throw new TokenBudgetExceeded(spentT, limitT, scope);
1619
+ }
1620
+ /**
1621
+ * Scope a per-step USD and/or token cap for a **sequential agent loop**.
1622
+ *
1623
+ * Runs `fn` with a step pushed onto the guard's stack; the
1624
+ * enforcement/accrual path honours the innermost active step *on top of* the
1625
+ * aggregate ceilings. A call that would cross the step's `maxUsd` / `maxTokens`
1626
+ * is hard-blocked ({@link BudgetExceeded} / {@link TokenBudgetExceeded} with
1627
+ * `scope: "step"`) even if the aggregate budget has room. `fn` receives the
1628
+ * SAME guard, so no adapter needs to know about steps. The step is popped when
1629
+ * `fn` settles or throws. **Not for concurrent parallel steps on one guard** —
1630
+ * that's out of scope for issue #46; use one guard per parallel branch.
1631
+ */
1632
+ step(options, fn) {
1633
+ const { maxUsd, maxTokens } = options;
1634
+ if (maxUsd !== void 0 && (!Number.isFinite(maxUsd) || maxUsd < 0)) {
1635
+ throw new RangeError(`maxUsd must be a finite, non-negative number, got ${maxUsd}`);
1636
+ }
1637
+ if (maxTokens !== void 0 && (!Number.isInteger(maxTokens) || maxTokens < 0)) {
1638
+ throw new RangeError(`maxTokens must be a non-negative integer, got ${maxTokens}`);
1639
+ }
1640
+ const state = {
1641
+ maxUsd: maxUsd ?? null,
1642
+ maxTokens: maxTokens ?? null,
1643
+ spentUsd: 0,
1644
+ spentTokens: 0,
1645
+ reservedUsd: 0,
1646
+ reservedTokens: 0
1647
+ };
1648
+ this.steps.push(state);
1649
+ const pop = () => {
1650
+ const idx = this.steps.lastIndexOf(state);
1651
+ if (idx !== -1) this.steps.splice(idx, 1);
1652
+ };
1653
+ let result;
1654
+ try {
1655
+ result = fn(this);
1656
+ } catch (err) {
1657
+ pop();
1658
+ throw err;
1659
+ }
1660
+ if (result != null && typeof result.then === "function") {
1661
+ return result.finally(pop);
1662
+ }
1663
+ pop();
1664
+ return result;
1468
1665
  }
1469
1666
  appendEvent(event) {
1470
1667
  this.spendEvents.push(Object.freeze(event));
@@ -1481,17 +1678,63 @@ var BudgetGuard = class {
1481
1678
  */
1482
1679
  advisory() {
1483
1680
  const usedBps = this.limitUsd <= 0 ? 1e4 : Math.max(0, Math.min(1e4, Math.floor(this.spentUsd / this.limitUsd * 1e4 + 1e-9)));
1681
+ const remainingUsd = Math.max(0, this.limitUsd - this.spentUsd);
1682
+ const expectedCost = Math.max(this.lastLlmCost, this.lastToolCost);
1683
+ const estCallsRemaining = expectedCost > 0 ? Math.floor(remainingUsd / expectedCost + 1e-9) : null;
1684
+ let tokenUsedBps = null;
1685
+ let remainingTokens = null;
1686
+ let nearToken = false;
1687
+ if (this.tokenLimit !== null) {
1688
+ tokenUsedBps = this.tokenLimit <= 0 ? 1e4 : Math.max(
1689
+ 0,
1690
+ Math.min(1e4, Math.floor(this.spentTokens / this.tokenLimit * 1e4 + 1e-9))
1691
+ );
1692
+ remainingTokens = Math.max(0, this.tokenLimit - this.spentTokens);
1693
+ nearToken = tokenUsedBps >= this.nearLimitBps;
1694
+ }
1695
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1696
+ let stepRemainingUsd = null;
1697
+ let stepRemainingTokens = null;
1698
+ let nearStep = false;
1699
+ if (step !== null) {
1700
+ if (step.maxUsd !== null) {
1701
+ stepRemainingUsd = Math.max(0, step.maxUsd - step.spentUsd);
1702
+ if (step.maxUsd <= 0) {
1703
+ nearStep = true;
1704
+ } else {
1705
+ const sBps = Math.floor(step.spentUsd / step.maxUsd * 1e4 + 1e-9);
1706
+ nearStep = nearStep || sBps >= this.nearLimitBps;
1707
+ }
1708
+ }
1709
+ if (step.maxTokens !== null) {
1710
+ stepRemainingTokens = Math.max(0, step.maxTokens - step.spentTokens);
1711
+ if (step.maxTokens <= 0) {
1712
+ nearStep = true;
1713
+ } else {
1714
+ const sTBps = Math.floor(step.spentTokens / step.maxTokens * 1e4 + 1e-9);
1715
+ nearStep = nearStep || sTBps >= this.nearLimitBps;
1716
+ }
1717
+ }
1718
+ }
1484
1719
  return {
1485
- nearLimit: usedBps >= this.nearLimitBps,
1720
+ // nearLimit now also flips when a token ceiling or the active step is near
1721
+ // its cap, so a router can downshift before ANY hard-stop.
1722
+ nearLimit: usedBps >= this.nearLimitBps || nearToken || nearStep,
1486
1723
  usedBps,
1487
1724
  // Settled budget: limit minus accrued spend, deliberately NOT net of
1488
1725
  // in-flight reservations. Unlike the remainingUsd getter (which subtracts
1489
1726
  // `reserved`), the advisory is a soft utilization signal about money already
1490
1727
  // spent, while the getter reports what a new call can still claim.
1491
- remainingUsd: Math.max(0, this.limitUsd - this.spentUsd),
1728
+ remainingUsd,
1492
1729
  limitUsd: this.limitUsd,
1493
1730
  spentUsd: this.spentUsd,
1494
- scope: "local"
1731
+ scope: "local",
1732
+ expectedCost,
1733
+ estCallsRemaining,
1734
+ tokenUsedBps,
1735
+ remainingTokens,
1736
+ stepRemainingUsd,
1737
+ stepRemainingTokens
1495
1738
  };
1496
1739
  }
1497
1740
  };
@@ -1697,6 +1940,7 @@ async function nextPlan(guard, error, current, onDegrade) {
1697
1940
  DeadlineExceeded,
1698
1941
  FloeGuardError,
1699
1942
  LatencyBudget,
1943
+ TokenBudgetExceeded,
1700
1944
  UnpriceableModelError,
1701
1945
  budgetGuardMiddleware,
1702
1946
  priceTokens,