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/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
- /** Cost of the most recent priced call, used to predict the next one. */
952
- lastCost = 0;
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
- * from the last recorded call's cost (override with `estimatedNextCost`); the
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.lastCost : estimatedNextCost;
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's cost.
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.lastCost : estimatedCost;
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.reserved = Math.max(0, this.reserved - reserved);
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.lastCost = cost;
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
- * Accrue a non-LLM cost (a paid tool/API call) against the same ceiling.
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
- * Tools with direct dollar costs — search APIs, scrapers, sandboxes — spend the
1113
- * same budget the LLM calls do; `recordTool` folds them into `spentUsd` (so
1114
- * `check()` / `reserve()` see them) and appends a `kind: "tool"`
1115
- * {@link SpendEvent} to {@link BudgetGuard.spendLog}. The caller supplies the
1116
- * cost: tools have no token usage to price. Deliberately does NOT update the
1117
- * next-call estimate — that predicts the next *LLM* call, and a tool's price
1118
- * would skew it. Returns `costUsd`.
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
- recordTool(tool, costUsd, options = {}) {
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.reserved = Math.max(0, this.reserved - reserved);
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 snapshot copy: mutating
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,