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/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
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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 (${
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|