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 +34 -0
- package/dist/index.cjs +373 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +120 -9
- package/dist/index.d.ts +120 -9
- package/dist/index.js +372 -36
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
- package/src/cost_map.json +102 -4
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:
|
|
880
|
-
output_cost_per_token:
|
|
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:
|
|
892
|
-
output_cost_per_token:
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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 (${
|
|
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 -
|
|
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
|
|
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,
|