floe-guard 0.8.0 → 0.10.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) {
@@ -203,6 +220,12 @@ var cost_map_default = {
203
220
  litellm_provider: "anthropic",
204
221
  mode: "chat"
205
222
  },
223
+ "claude-opus-5": {
224
+ input_cost_per_token: 5e-6,
225
+ output_cost_per_token: 25e-6,
226
+ litellm_provider: "anthropic",
227
+ mode: "chat"
228
+ },
206
229
  "claude-sonnet-4-20250514": {
207
230
  input_cost_per_token: 3e-6,
208
231
  output_cost_per_token: 15e-6,
@@ -521,6 +544,18 @@ var cost_map_default = {
521
544
  litellm_provider: "gemini",
522
545
  mode: "chat"
523
546
  },
547
+ "gemini-robotics-er-1.6-preview": {
548
+ input_cost_per_token: 1e-6,
549
+ output_cost_per_token: 5e-6,
550
+ litellm_provider: "gemini",
551
+ mode: "chat"
552
+ },
553
+ "gemini-robotics-er-2-preview": {
554
+ input_cost_per_token: 2e-6,
555
+ output_cost_per_token: 1e-5,
556
+ litellm_provider: "gemini",
557
+ mode: "chat"
558
+ },
524
559
  "gpt-3.5-turbo": {
525
560
  input_cost_per_token: 5e-7,
526
561
  output_cost_per_token: 15e-7,
@@ -876,8 +911,8 @@ var cost_map_default = {
876
911
  mode: "chat"
877
912
  },
878
913
  "gpt-5.6-luna": {
879
- input_cost_per_token: 1e-6,
880
- output_cost_per_token: 6e-6,
914
+ input_cost_per_token: 2e-7,
915
+ output_cost_per_token: 12e-7,
881
916
  litellm_provider: "openai",
882
917
  mode: "chat"
883
918
  },
@@ -888,8 +923,8 @@ var cost_map_default = {
888
923
  mode: "chat"
889
924
  },
890
925
  "gpt-5.6-terra": {
891
- input_cost_per_token: 25e-7,
892
- output_cost_per_token: 15e-6,
926
+ input_cost_per_token: 2e-6,
927
+ output_cost_per_token: 12e-6,
893
928
  litellm_provider: "openai",
894
929
  mode: "chat"
895
930
  },
@@ -1042,6 +1077,86 @@ var cost_map_default = {
1042
1077
  output_cost_per_token: 0,
1043
1078
  litellm_provider: "openai",
1044
1079
  mode: "embedding"
1080
+ },
1081
+ __voice__: {
1082
+ "deepgram-nova-3": {
1083
+ mode: "stt",
1084
+ unit: "usd_per_second",
1085
+ rate: 1283333e-10,
1086
+ provider: "deepgram"
1087
+ },
1088
+ "deepgram-nova-3-multilingual": {
1089
+ mode: "stt",
1090
+ unit: "usd_per_second",
1091
+ rate: 1533333e-10,
1092
+ provider: "deepgram"
1093
+ },
1094
+ "deepgram-nova-3-base": {
1095
+ mode: "stt",
1096
+ unit: "usd_per_second",
1097
+ rate: 2416667e-10,
1098
+ provider: "deepgram"
1099
+ },
1100
+ "assemblyai-universal-streaming": {
1101
+ mode: "stt",
1102
+ unit: "usd_per_second",
1103
+ rate: 416667e-10,
1104
+ provider: "assemblyai"
1105
+ },
1106
+ "elevenlabs-multilingual-v2": {
1107
+ mode: "tts",
1108
+ unit: "usd_per_1k_chars",
1109
+ rate: 0.1,
1110
+ provider: "elevenlabs"
1111
+ },
1112
+ "elevenlabs-flash-v2.5": {
1113
+ mode: "tts",
1114
+ unit: "usd_per_1k_chars",
1115
+ rate: 0.05,
1116
+ provider: "elevenlabs"
1117
+ },
1118
+ "elevenlabs-turbo-v2.5": {
1119
+ mode: "tts",
1120
+ unit: "usd_per_1k_chars",
1121
+ rate: 0.05,
1122
+ provider: "elevenlabs"
1123
+ },
1124
+ "cartesia-sonic": {
1125
+ mode: "tts",
1126
+ unit: "usd_per_1k_chars",
1127
+ rate: 0.03,
1128
+ provider: "cartesia"
1129
+ },
1130
+ "cartesia-line": {
1131
+ mode: "telephony",
1132
+ unit: "usd_per_minute",
1133
+ rate: 0.06,
1134
+ provider: "cartesia"
1135
+ },
1136
+ "rime-mist-v2": {
1137
+ mode: "tts",
1138
+ unit: "usd_per_1k_chars",
1139
+ rate: 0.03,
1140
+ provider: "rime"
1141
+ },
1142
+ "twilio-us-inbound-local": {
1143
+ mode: "telephony",
1144
+ unit: "usd_per_minute",
1145
+ rate: 85e-4,
1146
+ provider: "twilio"
1147
+ },
1148
+ "twilio-us-outbound-local": {
1149
+ mode: "telephony",
1150
+ unit: "usd_per_minute",
1151
+ rate: 0.014,
1152
+ provider: "twilio"
1153
+ },
1154
+ "twilio-us-sip-inbound": {
1155
+ mode: "telephony",
1156
+ unit: "usd_per_minute",
1157
+ rate: 4e-3,
1158
+ provider: "twilio"
1159
+ }
1045
1160
  }
1046
1161
  };
1047
1162
 
@@ -1118,12 +1233,23 @@ function priceTokens(priced, promptTokens, completionTokens) {
1118
1233
 
1119
1234
  // src/guard.ts
1120
1235
  var EPS = 1e-12;
1236
+ function isReservation(h) {
1237
+ return typeof h === "object" && h !== null && typeof h.usd === "number" && typeof h.tokens === "number";
1238
+ }
1121
1239
  var BudgetGuard = class {
1122
1240
  limitUsd;
1123
1241
  spentUsd = 0;
1124
1242
  priceOverrides;
1125
1243
  failClosed;
1126
1244
  nearLimitBps;
1245
+ /** Aggregate token ceiling, or null when the token dimension is disabled. */
1246
+ tokenLimit;
1247
+ /** Aggregate tokens accrued (prompt + completion + cache buckets). */
1248
+ spentTokens = 0;
1249
+ /** Tokens held in-flight — the token twin of `reserved`. */
1250
+ reservedTokens = 0;
1251
+ /** Step stack: innermost step is last. Empty when no step() is active. */
1252
+ steps = [];
1127
1253
  onBlock;
1128
1254
  /**
1129
1255
  * Costs of the most recent priced LLM call and tool call, tracked
@@ -1165,7 +1291,13 @@ var BudgetGuard = class {
1165
1291
  `maxLogEvents must be a non-negative integer, got ${options.maxLogEvents}`
1166
1292
  );
1167
1293
  }
1294
+ if (options.tokenLimit !== void 0 && (!Number.isInteger(options.tokenLimit) || options.tokenLimit < 0)) {
1295
+ throw new RangeError(
1296
+ `tokenLimit must be a non-negative integer, got ${options.tokenLimit}`
1297
+ );
1298
+ }
1168
1299
  this.limitUsd = limitUsd;
1300
+ this.tokenLimit = options.tokenLimit ?? null;
1169
1301
  this.maxLogEvents = options.maxLogEvents;
1170
1302
  this.priceOverrides = options.priceOverrides;
1171
1303
  this.failClosed = options.failClosed ?? true;
@@ -1185,7 +1317,7 @@ var BudgetGuard = class {
1185
1317
  * Note: `check` is a non-binding peek. For parallel calls, use `reserve()` /
1186
1318
  * `settle()`, which hold the estimate across the await.
1187
1319
  */
1188
- check(estimatedNextCost) {
1320
+ check(estimatedNextCost, options = {}) {
1189
1321
  const rawEstimate = estimatedNextCost === void 0 ? this.defaultEstimate() : estimatedNextCost;
1190
1322
  if (!Number.isFinite(rawEstimate)) {
1191
1323
  throw new RangeError(
@@ -1193,11 +1325,10 @@ var BudgetGuard = class {
1193
1325
  );
1194
1326
  }
1195
1327
  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
- }
1328
+ this.validateTokenEstimate(options.estimatedTokens);
1329
+ const tokens = Math.max(0, options.estimatedTokens ?? 0);
1330
+ const blocked = this.blockingCross(estimate, tokens);
1331
+ if (blocked !== null) this.raiseBlock(blocked);
1201
1332
  }
1202
1333
  /**
1203
1334
  * Atomically check the ceiling AND hold the estimated cost in flight.
@@ -1210,7 +1341,7 @@ var BudgetGuard = class {
1210
1341
  * `estimatedCost` defaults to the costlier of the last LLM call and the last
1211
1342
  * tool call.
1212
1343
  */
1213
- reserve(estimatedCost) {
1344
+ reserve(estimatedCost, options = {}) {
1214
1345
  const rawEstimate = estimatedCost === void 0 ? this.defaultEstimate() : estimatedCost;
1215
1346
  if (!Number.isFinite(rawEstimate)) {
1216
1347
  throw new RangeError(
@@ -1218,13 +1349,19 @@ var BudgetGuard = class {
1218
1349
  );
1219
1350
  }
1220
1351
  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
- }
1352
+ this.validateTokenEstimate(options.estimatedTokens);
1353
+ const tokens = Math.max(0, options.estimatedTokens ?? 0);
1354
+ const blocked = this.blockingCross(estimate, tokens);
1355
+ if (blocked !== null) this.raiseBlock(blocked);
1226
1356
  this.reserved += estimate;
1227
- return estimate;
1357
+ this.reservedTokens += tokens;
1358
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1359
+ if (step !== null) {
1360
+ step.reservedUsd += estimate;
1361
+ step.reservedTokens += tokens;
1362
+ }
1363
+ if (tokens === 0 && step === null) return estimate;
1364
+ return { usd: estimate, tokens };
1228
1365
  }
1229
1366
  /**
1230
1367
  * Release a reservation and record the actual cost. `record` is `settle` with
@@ -1237,9 +1374,7 @@ var BudgetGuard = class {
1237
1374
  */
1238
1375
  settle(model, promptTokens, completionTokens, options = {}) {
1239
1376
  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
- }
1377
+ const reservedUsd = this.reservedUsdOf(reserved);
1243
1378
  let overrides = this.priceOverrides;
1244
1379
  if (options.price !== void 0) {
1245
1380
  overrides = { ...overrides ?? {}, [model]: options.price };
@@ -1265,7 +1400,10 @@ var BudgetGuard = class {
1265
1400
  if (reserved) {
1266
1401
  this.consumeReservation(reserved);
1267
1402
  }
1403
+ const accruedTokens = Math.max(0, promptTokens) + Math.max(0, completionTokens);
1268
1404
  this.spentUsd += cost;
1405
+ this.spentTokens += accruedTokens;
1406
+ this.accrueStep(cost, accruedTokens);
1269
1407
  if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
1270
1408
  this.spentUsd = this.limitUsd;
1271
1409
  }
@@ -1279,8 +1417,9 @@ var BudgetGuard = class {
1279
1417
  costUsd: cost,
1280
1418
  ...options.label !== void 0 ? { label: options.label } : {},
1281
1419
  // 0 means "no reservation" (the plain record() path) — omit rather than
1282
- // log a meaningless zero.
1283
- ...reserved ? { reserved } : {}
1420
+ // log a meaningless zero. Log the USD amount so the ledger schema stays
1421
+ // number-shaped even for a BudgetReservation.
1422
+ ...reservedUsd ? { reserved: reservedUsd } : {}
1284
1423
  });
1285
1424
  return cost;
1286
1425
  }
@@ -1345,13 +1484,12 @@ var BudgetGuard = class {
1345
1484
  throw new RangeError(`costUsd must be a finite, non-negative number, got ${costUsd}`);
1346
1485
  }
1347
1486
  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
- }
1487
+ const reservedUsd = this.reservedUsdOf(reserved);
1351
1488
  if (reserved) {
1352
1489
  this.consumeReservation(reserved);
1353
1490
  }
1354
1491
  this.spentUsd += costUsd;
1492
+ this.accrueStep(costUsd, 0);
1355
1493
  if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
1356
1494
  this.spentUsd = this.limitUsd;
1357
1495
  }
@@ -1365,7 +1503,7 @@ var BudgetGuard = class {
1365
1503
  completionTokens: null,
1366
1504
  costUsd,
1367
1505
  ...options.label !== void 0 ? { label: options.label } : {},
1368
- ...reserved ? { reserved } : {}
1506
+ ...reservedUsd ? { reserved: reservedUsd } : {}
1369
1507
  });
1370
1508
  return costUsd;
1371
1509
  }
@@ -1385,9 +1523,7 @@ var BudgetGuard = class {
1385
1523
  * before producing usage). Safe to call with `0`.
1386
1524
  */
1387
1525
  release(reserved) {
1388
- if (!Number.isFinite(reserved) || reserved < 0) {
1389
- throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
1390
- }
1526
+ this.reservedUsdOf(reserved);
1391
1527
  if (!reserved) return;
1392
1528
  this.consumeReservation(reserved);
1393
1529
  }
@@ -1459,12 +1595,171 @@ var BudgetGuard = class {
1459
1595
  * responsibility.
1460
1596
  */
1461
1597
  consumeReservation(reserved) {
1462
- if (reserved > this.reserved + EPS) {
1598
+ const usd = isReservation(reserved) ? reserved.usd : reserved;
1599
+ const tokens = isReservation(reserved) ? reserved.tokens : 0;
1600
+ if (usd > this.reserved + EPS) {
1601
+ throw new RangeError(
1602
+ `reserved handle (${usd}) exceeds total in-flight reservations (${this.reserved}) \u2014 a handle must come from a matching reserve()`
1603
+ );
1604
+ }
1605
+ if (tokens > this.reservedTokens) {
1463
1606
  throw new RangeError(
1464
- `reserved handle (${reserved}) exceeds total in-flight reservations (${this.reserved}) \u2014 a handle must come from a matching reserve()`
1607
+ `reserved token handle (${tokens}) exceeds total in-flight token reservations (${this.reservedTokens}) \u2014 a handle must come from a matching reserve()`
1465
1608
  );
1466
1609
  }
1467
- this.reserved = Math.max(0, this.reserved - reserved);
1610
+ this.reserved = Math.max(0, this.reserved - usd);
1611
+ this.reservedTokens = Math.max(0, this.reservedTokens - tokens);
1612
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1613
+ if (step !== null) {
1614
+ step.reservedUsd = Math.max(0, step.reservedUsd - usd);
1615
+ step.reservedTokens = Math.max(0, step.reservedTokens - tokens);
1616
+ }
1617
+ }
1618
+ /**
1619
+ * The USD amount of a handle, validated. Both shapes are checked — a raw
1620
+ * number and a {@link BudgetReservation}'s `usd`/`tokens` fields — so a bad
1621
+ * hand-rolled handle can't corrupt the in-flight tally.
1622
+ */
1623
+ /**
1624
+ * Validate a caller-supplied token estimate. Rejects a fraction, NaN,
1625
+ * Infinity, or boolean (via `Number.isInteger`) so it can't disable the
1626
+ * integer token hard-stops or corrupt the in-flight token tally. `undefined`
1627
+ * (the default) is fine; a negative integer is clamped by `Math.max(0, ...)`,
1628
+ * matching the lenient USD estimate. Mirrors Python's `_validate_token_estimate`.
1629
+ */
1630
+ validateTokenEstimate(estimatedTokens) {
1631
+ if (estimatedTokens !== void 0 && !Number.isInteger(estimatedTokens)) {
1632
+ throw new RangeError(`estimatedTokens must be an integer, got ${estimatedTokens}`);
1633
+ }
1634
+ }
1635
+ reservedUsdOf(reserved) {
1636
+ if (isReservation(reserved)) {
1637
+ if (!Number.isFinite(reserved.usd) || reserved.usd < 0) {
1638
+ throw new RangeError(
1639
+ `reserved.usd must be a finite, non-negative number, got ${reserved.usd}`
1640
+ );
1641
+ }
1642
+ if (!Number.isInteger(reserved.tokens) || reserved.tokens < 0) {
1643
+ throw new RangeError(
1644
+ `reserved.tokens must be a non-negative integer, got ${reserved.tokens}`
1645
+ );
1646
+ }
1647
+ return reserved.usd;
1648
+ }
1649
+ if (!Number.isFinite(reserved) || reserved < 0) {
1650
+ throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
1651
+ }
1652
+ return reserved;
1653
+ }
1654
+ /**
1655
+ * Accrue a settled call into the innermost active step (no-op when none).
1656
+ * Sequential-loop contract: the innermost step owns the call.
1657
+ */
1658
+ accrueStep(cost, tokens) {
1659
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1660
+ if (step !== null) {
1661
+ step.spentUsd += cost;
1662
+ step.spentTokens += tokens;
1663
+ }
1664
+ }
1665
+ /**
1666
+ * The ONE choke point, across two dimensions and two scopes. Returns the first
1667
+ * ceiling that blocks as `[dimension, scope]` — dimension `"usd" | "tokens"`,
1668
+ * scope `"aggregate" | "step"` — or `null` if the call fits everywhere.
1669
+ * Aggregate is checked before step (the guard-wide ceiling is the hard limit).
1670
+ */
1671
+ blockingCross(estimateUsd, estimateTokens) {
1672
+ const committed = this.spentUsd + this.reserved;
1673
+ if (committed > this.limitUsd - EPS || committed + estimateUsd > this.limitUsd + EPS) {
1674
+ return ["usd", "aggregate"];
1675
+ }
1676
+ if (this.tokenLimit !== null) {
1677
+ const committedT = this.spentTokens + this.reservedTokens;
1678
+ if (committedT >= this.tokenLimit || committedT + estimateTokens > this.tokenLimit) {
1679
+ return ["tokens", "aggregate"];
1680
+ }
1681
+ }
1682
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1683
+ if (step !== null) {
1684
+ if (step.maxUsd !== null) {
1685
+ const sCommitted = step.spentUsd + step.reservedUsd;
1686
+ if (sCommitted > step.maxUsd - EPS || sCommitted + estimateUsd > step.maxUsd + EPS) {
1687
+ return ["usd", "step"];
1688
+ }
1689
+ }
1690
+ if (step.maxTokens !== null) {
1691
+ const sCommittedT = step.spentTokens + step.reservedTokens;
1692
+ if (sCommittedT >= step.maxTokens || sCommittedT + estimateTokens > step.maxTokens) {
1693
+ return ["tokens", "step"];
1694
+ }
1695
+ }
1696
+ }
1697
+ return null;
1698
+ }
1699
+ /** Notify + throw the right error for a [dimension, scope] block. */
1700
+ raiseBlock(blocked) {
1701
+ const [dimension, scope] = blocked;
1702
+ if (dimension === "usd") {
1703
+ this.onBlock(this.spentUsd, this.limitUsd);
1704
+ throw new BudgetExceeded(this.spentUsd, this.limitUsd);
1705
+ }
1706
+ let spentT;
1707
+ let limitT;
1708
+ if (scope === "step") {
1709
+ const step = this.steps[this.steps.length - 1];
1710
+ spentT = step.spentTokens + step.reservedTokens;
1711
+ limitT = step.maxTokens ?? 0;
1712
+ } else {
1713
+ spentT = this.spentTokens + this.reservedTokens;
1714
+ limitT = this.tokenLimit ?? 0;
1715
+ }
1716
+ throw new TokenBudgetExceeded(spentT, limitT, scope);
1717
+ }
1718
+ /**
1719
+ * Scope a per-step USD and/or token cap for a **sequential agent loop**.
1720
+ *
1721
+ * Runs `fn` with a step pushed onto the guard's stack; the
1722
+ * enforcement/accrual path honours the innermost active step *on top of* the
1723
+ * aggregate ceilings. A call that would cross the step's `maxUsd` / `maxTokens`
1724
+ * is hard-blocked ({@link BudgetExceeded} / {@link TokenBudgetExceeded} with
1725
+ * `scope: "step"`) even if the aggregate budget has room. `fn` receives the
1726
+ * SAME guard, so no adapter needs to know about steps. The step is popped when
1727
+ * `fn` settles or throws. **Not for concurrent parallel steps on one guard** —
1728
+ * that's out of scope for issue #46; use one guard per parallel branch.
1729
+ */
1730
+ step(options, fn) {
1731
+ const { maxUsd, maxTokens } = options;
1732
+ if (maxUsd !== void 0 && (!Number.isFinite(maxUsd) || maxUsd < 0)) {
1733
+ throw new RangeError(`maxUsd must be a finite, non-negative number, got ${maxUsd}`);
1734
+ }
1735
+ if (maxTokens !== void 0 && (!Number.isInteger(maxTokens) || maxTokens < 0)) {
1736
+ throw new RangeError(`maxTokens must be a non-negative integer, got ${maxTokens}`);
1737
+ }
1738
+ const state = {
1739
+ maxUsd: maxUsd ?? null,
1740
+ maxTokens: maxTokens ?? null,
1741
+ spentUsd: 0,
1742
+ spentTokens: 0,
1743
+ reservedUsd: 0,
1744
+ reservedTokens: 0
1745
+ };
1746
+ this.steps.push(state);
1747
+ const pop = () => {
1748
+ const idx = this.steps.lastIndexOf(state);
1749
+ if (idx !== -1) this.steps.splice(idx, 1);
1750
+ };
1751
+ let result;
1752
+ try {
1753
+ result = fn(this);
1754
+ } catch (err) {
1755
+ pop();
1756
+ throw err;
1757
+ }
1758
+ if (result != null && typeof result.then === "function") {
1759
+ return result.finally(pop);
1760
+ }
1761
+ pop();
1762
+ return result;
1468
1763
  }
1469
1764
  appendEvent(event) {
1470
1765
  this.spendEvents.push(Object.freeze(event));
@@ -1484,8 +1779,45 @@ var BudgetGuard = class {
1484
1779
  const remainingUsd = Math.max(0, this.limitUsd - this.spentUsd);
1485
1780
  const expectedCost = Math.max(this.lastLlmCost, this.lastToolCost);
1486
1781
  const estCallsRemaining = expectedCost > 0 ? Math.floor(remainingUsd / expectedCost + 1e-9) : null;
1782
+ let tokenUsedBps = null;
1783
+ let remainingTokens = null;
1784
+ let nearToken = false;
1785
+ if (this.tokenLimit !== null) {
1786
+ tokenUsedBps = this.tokenLimit <= 0 ? 1e4 : Math.max(
1787
+ 0,
1788
+ Math.min(1e4, Math.floor(this.spentTokens / this.tokenLimit * 1e4 + 1e-9))
1789
+ );
1790
+ remainingTokens = Math.max(0, this.tokenLimit - this.spentTokens);
1791
+ nearToken = tokenUsedBps >= this.nearLimitBps;
1792
+ }
1793
+ const step = this.steps.length ? this.steps[this.steps.length - 1] : null;
1794
+ let stepRemainingUsd = null;
1795
+ let stepRemainingTokens = null;
1796
+ let nearStep = false;
1797
+ if (step !== null) {
1798
+ if (step.maxUsd !== null) {
1799
+ stepRemainingUsd = Math.max(0, step.maxUsd - step.spentUsd);
1800
+ if (step.maxUsd <= 0) {
1801
+ nearStep = true;
1802
+ } else {
1803
+ const sBps = Math.floor(step.spentUsd / step.maxUsd * 1e4 + 1e-9);
1804
+ nearStep = nearStep || sBps >= this.nearLimitBps;
1805
+ }
1806
+ }
1807
+ if (step.maxTokens !== null) {
1808
+ stepRemainingTokens = Math.max(0, step.maxTokens - step.spentTokens);
1809
+ if (step.maxTokens <= 0) {
1810
+ nearStep = true;
1811
+ } else {
1812
+ const sTBps = Math.floor(step.spentTokens / step.maxTokens * 1e4 + 1e-9);
1813
+ nearStep = nearStep || sTBps >= this.nearLimitBps;
1814
+ }
1815
+ }
1816
+ }
1487
1817
  return {
1488
- nearLimit: usedBps >= this.nearLimitBps,
1818
+ // nearLimit now also flips when a token ceiling or the active step is near
1819
+ // its cap, so a router can downshift before ANY hard-stop.
1820
+ nearLimit: usedBps >= this.nearLimitBps || nearToken || nearStep,
1489
1821
  usedBps,
1490
1822
  // Settled budget: limit minus accrued spend, deliberately NOT net of
1491
1823
  // in-flight reservations. Unlike the remainingUsd getter (which subtracts
@@ -1496,7 +1828,11 @@ var BudgetGuard = class {
1496
1828
  spentUsd: this.spentUsd,
1497
1829
  scope: "local",
1498
1830
  expectedCost,
1499
- estCallsRemaining
1831
+ estCallsRemaining,
1832
+ tokenUsedBps,
1833
+ remainingTokens,
1834
+ stepRemainingUsd,
1835
+ stepRemainingTokens
1500
1836
  };
1501
1837
  }
1502
1838
  };
@@ -1702,6 +2038,7 @@ async function nextPlan(guard, error, current, onDegrade) {
1702
2038
  DeadlineExceeded,
1703
2039
  FloeGuardError,
1704
2040
  LatencyBudget,
2041
+ TokenBudgetExceeded,
1705
2042
  UnpriceableModelError,
1706
2043
  budgetGuardMiddleware,
1707
2044
  priceTokens,