floe-guard 0.3.0 → 0.5.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 +19 -3
- package/dist/index.cjs +206 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +166 -15
- package/dist/index.d.ts +166 -15
- package/dist/index.js +204 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -34,6 +34,21 @@ var UnpriceableModelError = class extends FloeGuardError {
|
|
|
34
34
|
this.model = model;
|
|
35
35
|
}
|
|
36
36
|
};
|
|
37
|
+
function roundHalfUp(ms) {
|
|
38
|
+
return Math.floor(ms + 0.5);
|
|
39
|
+
}
|
|
40
|
+
var DeadlineExceeded = class extends FloeGuardError {
|
|
41
|
+
elapsedMs;
|
|
42
|
+
slaMs;
|
|
43
|
+
constructor(elapsedMs, slaMs) {
|
|
44
|
+
super(
|
|
45
|
+
`DEADLINE EXCEEDED \u2014 call blocked (elapsed ${roundHalfUp(elapsedMs)}ms of ${roundHalfUp(slaMs)}ms SLA)`
|
|
46
|
+
);
|
|
47
|
+
this.name = "DeadlineExceeded";
|
|
48
|
+
this.elapsedMs = elapsedMs;
|
|
49
|
+
this.slaMs = slaMs;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
37
52
|
|
|
38
53
|
// src/pricing.ts
|
|
39
54
|
var pricing_exports = {};
|
|
@@ -948,13 +963,26 @@ var BudgetGuard = class {
|
|
|
948
963
|
failClosed;
|
|
949
964
|
nearLimitBps;
|
|
950
965
|
onBlock;
|
|
951
|
-
/**
|
|
952
|
-
|
|
966
|
+
/**
|
|
967
|
+
* Costs of the most recent priced LLM call and tool call, tracked
|
|
968
|
+
* SEPARATELY: the default next-call prediction is the max of the two, so a
|
|
969
|
+
* cheap tool call can't shrink the estimate right before an expensive LLM
|
|
970
|
+
* call (or vice versa) — conservative beats one-call-too-late.
|
|
971
|
+
*/
|
|
972
|
+
lastLlmCost = 0;
|
|
973
|
+
lastToolCost = 0;
|
|
953
974
|
/** USD held for in-flight calls (reserved, not yet settled). Counts toward the ceiling. */
|
|
954
975
|
reserved = 0;
|
|
955
976
|
/** Per-call ledger, oldest first; a ring buffer when maxLogEvents is set. */
|
|
956
977
|
spendEvents = [];
|
|
957
978
|
maxLogEvents;
|
|
979
|
+
/**
|
|
980
|
+
* Per-tool running totals (settleTool/recordTool) — the tool side of the one
|
|
981
|
+
* shared ceiling, exposed via the toolCosts getter. null-prototype: tool
|
|
982
|
+
* names are caller-supplied strings, so a "__proto__" name is stored as
|
|
983
|
+
* plain data instead of mutating the object's prototype.
|
|
984
|
+
*/
|
|
985
|
+
toolCostTotals = /* @__PURE__ */ Object.create(null);
|
|
958
986
|
/**
|
|
959
987
|
* @param limitUsd the spend ceiling, in USD. `0` blocks the very first call.
|
|
960
988
|
*/
|
|
@@ -986,7 +1014,8 @@ var BudgetGuard = class {
|
|
|
986
1014
|
* Throw {@link BudgetExceeded} if the next call would cross the ceiling.
|
|
987
1015
|
*
|
|
988
1016
|
* Call this immediately before each LLM request. The "next call" is estimated
|
|
989
|
-
*
|
|
1017
|
+
* conservatively as the costlier of the last LLM call and the last tool call
|
|
1018
|
+
* (override with `estimatedNextCost`); the
|
|
990
1019
|
* first call is always allowed unless the ceiling is already met. In-flight
|
|
991
1020
|
* reservations count toward the total, so this stays correct alongside
|
|
992
1021
|
* {@link BudgetGuard.reserve}.
|
|
@@ -995,7 +1024,7 @@ var BudgetGuard = class {
|
|
|
995
1024
|
* `settle()`, which hold the estimate across the await.
|
|
996
1025
|
*/
|
|
997
1026
|
check(estimatedNextCost) {
|
|
998
|
-
const rawEstimate = estimatedNextCost === void 0 ? this.
|
|
1027
|
+
const rawEstimate = estimatedNextCost === void 0 ? this.defaultEstimate() : estimatedNextCost;
|
|
999
1028
|
if (!Number.isFinite(rawEstimate)) {
|
|
1000
1029
|
throw new RangeError(
|
|
1001
1030
|
`estimatedNextCost must be a finite number, got ${rawEstimate}`
|
|
@@ -1016,10 +1045,11 @@ var BudgetGuard = class {
|
|
|
1016
1045
|
* the same stale total. Throws {@link BudgetExceeded} (without reserving) if
|
|
1017
1046
|
* the reservation would cross the ceiling. Returns the reservation handle to
|
|
1018
1047
|
* pass to {@link BudgetGuard.settle} (or {@link BudgetGuard.release} on error).
|
|
1019
|
-
* `estimatedCost` defaults to the last call
|
|
1048
|
+
* `estimatedCost` defaults to the costlier of the last LLM call and the last
|
|
1049
|
+
* tool call.
|
|
1020
1050
|
*/
|
|
1021
1051
|
reserve(estimatedCost) {
|
|
1022
|
-
const rawEstimate = estimatedCost === void 0 ? this.
|
|
1052
|
+
const rawEstimate = estimatedCost === void 0 ? this.defaultEstimate() : estimatedCost;
|
|
1023
1053
|
if (!Number.isFinite(rawEstimate)) {
|
|
1024
1054
|
throw new RangeError(
|
|
1025
1055
|
`estimatedCost must be a finite number, got ${rawEstimate}`
|
|
@@ -1071,13 +1101,13 @@ var BudgetGuard = class {
|
|
|
1071
1101
|
throw err;
|
|
1072
1102
|
}
|
|
1073
1103
|
if (reserved) {
|
|
1074
|
-
this.
|
|
1104
|
+
this.consumeReservation(reserved);
|
|
1075
1105
|
}
|
|
1076
1106
|
this.spentUsd += cost;
|
|
1077
1107
|
if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
|
|
1078
1108
|
this.spentUsd = this.limitUsd;
|
|
1079
1109
|
}
|
|
1080
|
-
this.
|
|
1110
|
+
this.lastLlmCost = cost;
|
|
1081
1111
|
this.appendEvent({
|
|
1082
1112
|
timestamp: Date.now() / 1e3,
|
|
1083
1113
|
kind: "llm",
|
|
@@ -1107,24 +1137,64 @@ var BudgetGuard = class {
|
|
|
1107
1137
|
});
|
|
1108
1138
|
}
|
|
1109
1139
|
/**
|
|
1110
|
-
*
|
|
1140
|
+
* Atomically check the ceiling AND hold a tool call's cost in flight.
|
|
1141
|
+
*
|
|
1142
|
+
* The tool-spend counterpart of {@link BudgetGuard.reserve} — and STRONGER
|
|
1143
|
+
* than the LLM path, because a paid tool's price is usually known exactly
|
|
1144
|
+
* before the call, so the pre-call hard-stop is precise rather than
|
|
1145
|
+
* estimated:
|
|
1111
1146
|
*
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1147
|
+
* const handle = guard.reserveTool(0.02); // throws BEFORE Apollo runs
|
|
1148
|
+
* const result = await apollo.peopleLookup(...);
|
|
1149
|
+
* guard.settleTool("apollo.people_lookup", 0.02, { reserved: handle });
|
|
1150
|
+
*
|
|
1151
|
+
* Throws {@link BudgetExceeded} (without reserving) if the call would cross
|
|
1152
|
+
* the ceiling. The estimate is required — tools have no last-cost prediction
|
|
1153
|
+
* worth falling back to. Pass the returned handle to
|
|
1154
|
+
* {@link BudgetGuard.settleTool}, or {@link BudgetGuard.release} on failure.
|
|
1119
1155
|
*/
|
|
1120
|
-
|
|
1156
|
+
reserveTool(estimatedCost) {
|
|
1157
|
+
if (estimatedCost === void 0) {
|
|
1158
|
+
throw new RangeError("reserveTool requires an estimated cost, got undefined");
|
|
1159
|
+
}
|
|
1160
|
+
if (!Number.isFinite(estimatedCost) || estimatedCost < 0) {
|
|
1161
|
+
throw new RangeError(
|
|
1162
|
+
`estimatedCost must be a finite, non-negative number, got ${estimatedCost}`
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
return this.reserve(estimatedCost);
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Release a reservation and record a tool call's actual cost.
|
|
1169
|
+
*
|
|
1170
|
+
* `recordTool` is `settleTool` with no reservation. The caller supplies the
|
|
1171
|
+
* cost — tools have no token usage to price. Accrues into the same
|
|
1172
|
+
* `spentUsd` ceiling as tokens, tallies the per-tool total
|
|
1173
|
+
* ({@link BudgetGuard.toolCosts}), updates the tool side of the next-call
|
|
1174
|
+
* estimate (tracked separately from the LLM side; the default prediction is
|
|
1175
|
+
* the max of the two, so a tool-hammering loop's plain `check()` stops
|
|
1176
|
+
* BEFORE the crossing call without a cheap tool shrinking the LLM
|
|
1177
|
+
* prediction), and appends
|
|
1178
|
+
* a `kind: "tool"` {@link SpendEvent} to {@link BudgetGuard.spendLog}.
|
|
1179
|
+
* Returns `costUsd`.
|
|
1180
|
+
*/
|
|
1181
|
+
settleTool(tool, costUsd, options = {}) {
|
|
1121
1182
|
if (!Number.isFinite(costUsd) || costUsd < 0) {
|
|
1122
1183
|
throw new RangeError(`costUsd must be a finite, non-negative number, got ${costUsd}`);
|
|
1123
1184
|
}
|
|
1185
|
+
const reserved = options.reserved ?? 0;
|
|
1186
|
+
if (!Number.isFinite(reserved) || reserved < 0) {
|
|
1187
|
+
throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
|
|
1188
|
+
}
|
|
1189
|
+
if (reserved) {
|
|
1190
|
+
this.consumeReservation(reserved);
|
|
1191
|
+
}
|
|
1124
1192
|
this.spentUsd += costUsd;
|
|
1125
1193
|
if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
|
|
1126
1194
|
this.spentUsd = this.limitUsd;
|
|
1127
1195
|
}
|
|
1196
|
+
this.lastToolCost = costUsd;
|
|
1197
|
+
this.toolCostTotals[tool] = (this.toolCostTotals[tool] ?? 0) + costUsd;
|
|
1128
1198
|
this.appendEvent({
|
|
1129
1199
|
timestamp: Date.now() / 1e3,
|
|
1130
1200
|
kind: "tool",
|
|
@@ -1132,10 +1202,22 @@ var BudgetGuard = class {
|
|
|
1132
1202
|
promptTokens: null,
|
|
1133
1203
|
completionTokens: null,
|
|
1134
1204
|
costUsd,
|
|
1135
|
-
...options.label !== void 0 ? { label: options.label } : {}
|
|
1205
|
+
...options.label !== void 0 ? { label: options.label } : {},
|
|
1206
|
+
...reserved ? { reserved } : {}
|
|
1136
1207
|
});
|
|
1137
1208
|
return costUsd;
|
|
1138
1209
|
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Accrue a non-LLM cost (a paid tool/API call) against the same ceiling.
|
|
1212
|
+
*
|
|
1213
|
+
* Post-hoc accrual for costs only known after the call (metered APIs); when
|
|
1214
|
+
* the price is known up front, {@link BudgetGuard.reserveTool} /
|
|
1215
|
+
* {@link BudgetGuard.settleTool} give the stronger pre-call hard-stop. See
|
|
1216
|
+
* `settleTool` for the full contract. Returns `costUsd`.
|
|
1217
|
+
*/
|
|
1218
|
+
recordTool(tool, costUsd, options = {}) {
|
|
1219
|
+
return this.settleTool(tool, costUsd, { reserved: 0, label: options.label });
|
|
1220
|
+
}
|
|
1139
1221
|
/**
|
|
1140
1222
|
* Drop an in-flight reservation without recording spend (e.g. the call failed
|
|
1141
1223
|
* before producing usage). Safe to call with `0`.
|
|
@@ -1145,16 +1227,25 @@ var BudgetGuard = class {
|
|
|
1145
1227
|
throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
|
|
1146
1228
|
}
|
|
1147
1229
|
if (!reserved) return;
|
|
1148
|
-
this.
|
|
1230
|
+
this.consumeReservation(reserved);
|
|
1149
1231
|
}
|
|
1150
1232
|
/** USD left before the ceiling, net of in-flight reservations (never negative). */
|
|
1151
1233
|
get remainingUsd() {
|
|
1152
1234
|
return Math.max(0, this.limitUsd - this.spentUsd - this.reserved);
|
|
1153
1235
|
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Per-tool running USD totals, keyed by the name given to `settleTool()` /
|
|
1238
|
+
* `recordTool()` — e.g. `{"apollo.people_lookup": 0.42, "exa.search": 0.11}`.
|
|
1239
|
+
* Makes the token/tool split of the one shared ceiling inspectable
|
|
1240
|
+
* (`spentUsd - sum of toolCosts` is the token side). Returns a snapshot copy.
|
|
1241
|
+
*/
|
|
1242
|
+
get toolCosts() {
|
|
1243
|
+
return { ...this.toolCostTotals };
|
|
1244
|
+
}
|
|
1154
1245
|
/**
|
|
1155
1246
|
* The per-call spend ledger, oldest first — one {@link SpendEvent} per priced
|
|
1156
|
-
* `record()` / `settle()` / `recordTool()`. Returns a
|
|
1157
|
-
* it cannot corrupt the ledger.
|
|
1247
|
+
* `record()` / `settle()` / `recordTool()` / `settleTool()`. Returns a
|
|
1248
|
+
* snapshot copy: mutating it cannot corrupt the ledger.
|
|
1158
1249
|
*/
|
|
1159
1250
|
get spendLog() {
|
|
1160
1251
|
return [...this.spendEvents];
|
|
@@ -1185,6 +1276,34 @@ var BudgetGuard = class {
|
|
|
1185
1276
|
`;
|
|
1186
1277
|
}).join("");
|
|
1187
1278
|
}
|
|
1279
|
+
/**
|
|
1280
|
+
* The default next-call prediction when the caller supplies no estimate.
|
|
1281
|
+
* Conservative: the costlier of the last LLM call and the last tool call — a
|
|
1282
|
+
* mixed loop predicts the pricier kind, which at worst blocks one call early
|
|
1283
|
+
* (fail-closed) rather than letting a crossing call through because the LAST
|
|
1284
|
+
* event happened to be cheap.
|
|
1285
|
+
*/
|
|
1286
|
+
defaultEstimate() {
|
|
1287
|
+
return Math.max(this.lastLlmCost, this.lastToolCost);
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Subtract a settled/released hold from the in-flight tally. A handle larger
|
|
1291
|
+
* than EVERYTHING currently held cannot have come from a matching
|
|
1292
|
+
* `reserve()` — throwing beats silently clamping, which would free OTHER
|
|
1293
|
+
* callers' holds and fail the ceiling open. The epsilon absorbs float dust
|
|
1294
|
+
* from accumulating and draining many holds; per-caller over-release (a
|
|
1295
|
+
* handle within the total but larger than the caller's own hold) is
|
|
1296
|
+
* undetectable without per-handle tracking and remains the caller's
|
|
1297
|
+
* responsibility.
|
|
1298
|
+
*/
|
|
1299
|
+
consumeReservation(reserved) {
|
|
1300
|
+
if (reserved > this.reserved + EPS) {
|
|
1301
|
+
throw new RangeError(
|
|
1302
|
+
`reserved handle (${reserved}) exceeds total in-flight reservations (${this.reserved}) \u2014 a handle must come from a matching reserve()`
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
this.reserved = Math.max(0, this.reserved - reserved);
|
|
1306
|
+
}
|
|
1188
1307
|
appendEvent(event) {
|
|
1189
1308
|
this.spendEvents.push(Object.freeze(event));
|
|
1190
1309
|
if (this.maxLogEvents !== void 0 && this.spendEvents.length > this.maxLogEvents) {
|
|
@@ -1222,6 +1341,68 @@ function defaultOnBlock(spentUsd, limitUsd) {
|
|
|
1222
1341
|
);
|
|
1223
1342
|
}
|
|
1224
1343
|
|
|
1344
|
+
// src/latency.ts
|
|
1345
|
+
var LatencyBudget = class {
|
|
1346
|
+
slaMs;
|
|
1347
|
+
nearDeadlineBps;
|
|
1348
|
+
onBlock;
|
|
1349
|
+
clock;
|
|
1350
|
+
startedAt;
|
|
1351
|
+
/** The budget starts counting at construction — build it when the request
|
|
1352
|
+
* (and its SLA) starts. */
|
|
1353
|
+
constructor(slaMs, options = {}) {
|
|
1354
|
+
if (!(Number.isFinite(slaMs) && slaMs > 0)) {
|
|
1355
|
+
throw new RangeError("slaMs must be > 0");
|
|
1356
|
+
}
|
|
1357
|
+
const nearDeadlineBps = options.nearDeadlineBps ?? 8e3;
|
|
1358
|
+
if (!Number.isInteger(nearDeadlineBps) || nearDeadlineBps < 0 || nearDeadlineBps > 1e4) {
|
|
1359
|
+
throw new RangeError("nearDeadlineBps must be an integer 0..10000");
|
|
1360
|
+
}
|
|
1361
|
+
this.slaMs = slaMs;
|
|
1362
|
+
this.nearDeadlineBps = nearDeadlineBps;
|
|
1363
|
+
this.onBlock = options.onBlock;
|
|
1364
|
+
this.clock = options.clock ?? (() => performance.now());
|
|
1365
|
+
this.startedAt = this.clock();
|
|
1366
|
+
}
|
|
1367
|
+
/** Milliseconds since construction (monotonic). */
|
|
1368
|
+
get elapsedMs() {
|
|
1369
|
+
return this.clock() - this.startedAt;
|
|
1370
|
+
}
|
|
1371
|
+
/** Milliseconds left before the SLA, floored at 0 — the readable signal a
|
|
1372
|
+
* router uses to pick a faster fallback or truncate work mid-chain. */
|
|
1373
|
+
get remainingMs() {
|
|
1374
|
+
return Math.max(0, this.slaMs - this.elapsedMs);
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Throw {@link DeadlineExceeded} when the projected elapsed time (now +
|
|
1378
|
+
* `expectedMs` for the upcoming call) would blow the SLA. Call it
|
|
1379
|
+
* immediately before each tool/model call; pass 0 to only gate on time
|
|
1380
|
+
* already spent.
|
|
1381
|
+
*/
|
|
1382
|
+
check(expectedMs = 0) {
|
|
1383
|
+
if (!(Number.isFinite(expectedMs) && expectedMs >= 0)) {
|
|
1384
|
+
throw new RangeError("expectedMs must be >= 0");
|
|
1385
|
+
}
|
|
1386
|
+
const elapsed = this.elapsedMs;
|
|
1387
|
+
if (elapsed + expectedMs > this.slaMs) {
|
|
1388
|
+
this.onBlock?.(elapsed, this.slaMs);
|
|
1389
|
+
throw new DeadlineExceeded(elapsed, this.slaMs);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
/** The soft near-deadline signal — symmetric to `BudgetGuard.advisory()`. */
|
|
1393
|
+
advisory() {
|
|
1394
|
+
const elapsed = this.elapsedMs;
|
|
1395
|
+
const usedBps = elapsed > 0 ? Math.min(1e4, Math.round(elapsed * 1e4 / this.slaMs)) : 0;
|
|
1396
|
+
return {
|
|
1397
|
+
nearDeadline: usedBps >= this.nearDeadlineBps,
|
|
1398
|
+
usedBps,
|
|
1399
|
+
remainingMs: Math.max(0, this.slaMs - elapsed),
|
|
1400
|
+
slaMs: this.slaMs,
|
|
1401
|
+
elapsedMs: elapsed
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1225
1406
|
// src/middleware.ts
|
|
1226
1407
|
function usageTokens(modelId, usage) {
|
|
1227
1408
|
const u = usage;
|
|
@@ -1314,7 +1495,9 @@ function budgetGuardMiddleware(guard) {
|
|
|
1314
1495
|
export {
|
|
1315
1496
|
BudgetExceeded,
|
|
1316
1497
|
BudgetGuard,
|
|
1498
|
+
DeadlineExceeded,
|
|
1317
1499
|
FloeGuardError,
|
|
1500
|
+
LatencyBudget,
|
|
1318
1501
|
UnpriceableModelError,
|
|
1319
1502
|
budgetGuardMiddleware,
|
|
1320
1503
|
priceTokens,
|