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 +34 -0
- package/dist/index.cjs +277 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +136 -9
- package/dist/index.d.ts +136 -9
- package/dist/index.js +276 -33
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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 (${
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|