floe-guard 0.4.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 CHANGED
@@ -4,9 +4,10 @@
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](../LICENSE)
5
5
 
6
6
  **A local budget guardrail for AI agents** — the TypeScript counterpart to the
7
- [Python `floe-guard`](../README.md). It hard-stops your agent *before its next LLM
8
- call* when it would cross a USD spend ceiling. No account, no signup, no network.
9
- Runs in your process.
7
+ [Python `floe-guard`](../README.md). It hard-stops your agent *before its next
8
+ LLM or paid tool call* when it would cross a USD spend ceiling tokens and
9
+ tool calls under one local ceiling. No account, no signup, no network. Runs in
10
+ your process.
10
11
 
11
12
  Works with both **AI SDK v4 and v5** (`ai@4` / `ai@5`).
12
13
 
@@ -63,6 +64,21 @@ const adv = guard.advisory();
63
64
  const model = adv.nearLimit ? openai("gpt-4o-mini") : openai("gpt-4o");
64
65
  ```
65
66
 
67
+ ## Tool spend under the same ceiling
68
+
69
+ Paid tool calls (Apollo, Exa, scrapers) burn the same budget as tokens. The
70
+ full reserve/settle contract applies — and the price is known *before* the
71
+ call, so the pre-call hard-stop is exact:
72
+
73
+ ```ts
74
+ const handle = guard.reserveTool(0.02); // throws BudgetExceeded BEFORE the call
75
+ const result = await apollo.peopleLookup(...);
76
+ guard.settleTool("apollo.people_lookup", 0.02, { reserved: handle });
77
+
78
+ guard.recordTool("exa.search", 0.004); // post-hoc, for metered APIs
79
+ guard.toolCosts; // { "apollo.people_lookup": 0.42, "exa.search": 0.11 }
80
+ ```
81
+
66
82
  ## Per-call spend log
67
83
 
68
84
  The guard keeps a typed, in-memory ledger of everything it priced: each
package/dist/index.cjs CHANGED
@@ -992,13 +992,26 @@ var BudgetGuard = class {
992
992
  failClosed;
993
993
  nearLimitBps;
994
994
  onBlock;
995
- /** Cost of the most recent priced call, used to predict the next one. */
996
- lastCost = 0;
995
+ /**
996
+ * Costs of the most recent priced LLM call and tool call, tracked
997
+ * SEPARATELY: the default next-call prediction is the max of the two, so a
998
+ * cheap tool call can't shrink the estimate right before an expensive LLM
999
+ * call (or vice versa) — conservative beats one-call-too-late.
1000
+ */
1001
+ lastLlmCost = 0;
1002
+ lastToolCost = 0;
997
1003
  /** USD held for in-flight calls (reserved, not yet settled). Counts toward the ceiling. */
998
1004
  reserved = 0;
999
1005
  /** Per-call ledger, oldest first; a ring buffer when maxLogEvents is set. */
1000
1006
  spendEvents = [];
1001
1007
  maxLogEvents;
1008
+ /**
1009
+ * Per-tool running totals (settleTool/recordTool) — the tool side of the one
1010
+ * shared ceiling, exposed via the toolCosts getter. null-prototype: tool
1011
+ * names are caller-supplied strings, so a "__proto__" name is stored as
1012
+ * plain data instead of mutating the object's prototype.
1013
+ */
1014
+ toolCostTotals = /* @__PURE__ */ Object.create(null);
1002
1015
  /**
1003
1016
  * @param limitUsd the spend ceiling, in USD. `0` blocks the very first call.
1004
1017
  */
@@ -1030,7 +1043,8 @@ var BudgetGuard = class {
1030
1043
  * Throw {@link BudgetExceeded} if the next call would cross the ceiling.
1031
1044
  *
1032
1045
  * Call this immediately before each LLM request. The "next call" is estimated
1033
- * from the last recorded call's cost (override with `estimatedNextCost`); the
1046
+ * conservatively as the costlier of the last LLM call and the last tool call
1047
+ * (override with `estimatedNextCost`); the
1034
1048
  * first call is always allowed unless the ceiling is already met. In-flight
1035
1049
  * reservations count toward the total, so this stays correct alongside
1036
1050
  * {@link BudgetGuard.reserve}.
@@ -1039,7 +1053,7 @@ var BudgetGuard = class {
1039
1053
  * `settle()`, which hold the estimate across the await.
1040
1054
  */
1041
1055
  check(estimatedNextCost) {
1042
- const rawEstimate = estimatedNextCost === void 0 ? this.lastCost : estimatedNextCost;
1056
+ const rawEstimate = estimatedNextCost === void 0 ? this.defaultEstimate() : estimatedNextCost;
1043
1057
  if (!Number.isFinite(rawEstimate)) {
1044
1058
  throw new RangeError(
1045
1059
  `estimatedNextCost must be a finite number, got ${rawEstimate}`
@@ -1060,10 +1074,11 @@ var BudgetGuard = class {
1060
1074
  * the same stale total. Throws {@link BudgetExceeded} (without reserving) if
1061
1075
  * the reservation would cross the ceiling. Returns the reservation handle to
1062
1076
  * pass to {@link BudgetGuard.settle} (or {@link BudgetGuard.release} on error).
1063
- * `estimatedCost` defaults to the last call's cost.
1077
+ * `estimatedCost` defaults to the costlier of the last LLM call and the last
1078
+ * tool call.
1064
1079
  */
1065
1080
  reserve(estimatedCost) {
1066
- const rawEstimate = estimatedCost === void 0 ? this.lastCost : estimatedCost;
1081
+ const rawEstimate = estimatedCost === void 0 ? this.defaultEstimate() : estimatedCost;
1067
1082
  if (!Number.isFinite(rawEstimate)) {
1068
1083
  throw new RangeError(
1069
1084
  `estimatedCost must be a finite number, got ${rawEstimate}`
@@ -1115,13 +1130,13 @@ var BudgetGuard = class {
1115
1130
  throw err;
1116
1131
  }
1117
1132
  if (reserved) {
1118
- this.reserved = Math.max(0, this.reserved - reserved);
1133
+ this.consumeReservation(reserved);
1119
1134
  }
1120
1135
  this.spentUsd += cost;
1121
1136
  if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
1122
1137
  this.spentUsd = this.limitUsd;
1123
1138
  }
1124
- this.lastCost = cost;
1139
+ this.lastLlmCost = cost;
1125
1140
  this.appendEvent({
1126
1141
  timestamp: Date.now() / 1e3,
1127
1142
  kind: "llm",
@@ -1151,24 +1166,64 @@ var BudgetGuard = class {
1151
1166
  });
1152
1167
  }
1153
1168
  /**
1154
- * Accrue a non-LLM cost (a paid tool/API call) against the same ceiling.
1169
+ * Atomically check the ceiling AND hold a tool call's cost in flight.
1170
+ *
1171
+ * The tool-spend counterpart of {@link BudgetGuard.reserve} — and STRONGER
1172
+ * than the LLM path, because a paid tool's price is usually known exactly
1173
+ * before the call, so the pre-call hard-stop is precise rather than
1174
+ * estimated:
1155
1175
  *
1156
- * Tools with direct dollar costs search APIs, scrapers, sandboxes — spend the
1157
- * same budget the LLM calls do; `recordTool` folds them into `spentUsd` (so
1158
- * `check()` / `reserve()` see them) and appends a `kind: "tool"`
1159
- * {@link SpendEvent} to {@link BudgetGuard.spendLog}. The caller supplies the
1160
- * cost: tools have no token usage to price. Deliberately does NOT update the
1161
- * next-call estimate that predicts the next *LLM* call, and a tool's price
1162
- * would skew it. Returns `costUsd`.
1176
+ * const handle = guard.reserveTool(0.02); // throws BEFORE Apollo runs
1177
+ * const result = await apollo.peopleLookup(...);
1178
+ * guard.settleTool("apollo.people_lookup", 0.02, { reserved: handle });
1179
+ *
1180
+ * Throws {@link BudgetExceeded} (without reserving) if the call would cross
1181
+ * the ceiling. The estimate is required tools have no last-cost prediction
1182
+ * worth falling back to. Pass the returned handle to
1183
+ * {@link BudgetGuard.settleTool}, or {@link BudgetGuard.release} on failure.
1163
1184
  */
1164
- recordTool(tool, costUsd, options = {}) {
1185
+ reserveTool(estimatedCost) {
1186
+ if (estimatedCost === void 0) {
1187
+ throw new RangeError("reserveTool requires an estimated cost, got undefined");
1188
+ }
1189
+ if (!Number.isFinite(estimatedCost) || estimatedCost < 0) {
1190
+ throw new RangeError(
1191
+ `estimatedCost must be a finite, non-negative number, got ${estimatedCost}`
1192
+ );
1193
+ }
1194
+ return this.reserve(estimatedCost);
1195
+ }
1196
+ /**
1197
+ * Release a reservation and record a tool call's actual cost.
1198
+ *
1199
+ * `recordTool` is `settleTool` with no reservation. The caller supplies the
1200
+ * cost — tools have no token usage to price. Accrues into the same
1201
+ * `spentUsd` ceiling as tokens, tallies the per-tool total
1202
+ * ({@link BudgetGuard.toolCosts}), updates the tool side of the next-call
1203
+ * estimate (tracked separately from the LLM side; the default prediction is
1204
+ * the max of the two, so a tool-hammering loop's plain `check()` stops
1205
+ * BEFORE the crossing call without a cheap tool shrinking the LLM
1206
+ * prediction), and appends
1207
+ * a `kind: "tool"` {@link SpendEvent} to {@link BudgetGuard.spendLog}.
1208
+ * Returns `costUsd`.
1209
+ */
1210
+ settleTool(tool, costUsd, options = {}) {
1165
1211
  if (!Number.isFinite(costUsd) || costUsd < 0) {
1166
1212
  throw new RangeError(`costUsd must be a finite, non-negative number, got ${costUsd}`);
1167
1213
  }
1214
+ const reserved = options.reserved ?? 0;
1215
+ if (!Number.isFinite(reserved) || reserved < 0) {
1216
+ throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
1217
+ }
1218
+ if (reserved) {
1219
+ this.consumeReservation(reserved);
1220
+ }
1168
1221
  this.spentUsd += costUsd;
1169
1222
  if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
1170
1223
  this.spentUsd = this.limitUsd;
1171
1224
  }
1225
+ this.lastToolCost = costUsd;
1226
+ this.toolCostTotals[tool] = (this.toolCostTotals[tool] ?? 0) + costUsd;
1172
1227
  this.appendEvent({
1173
1228
  timestamp: Date.now() / 1e3,
1174
1229
  kind: "tool",
@@ -1176,10 +1231,22 @@ var BudgetGuard = class {
1176
1231
  promptTokens: null,
1177
1232
  completionTokens: null,
1178
1233
  costUsd,
1179
- ...options.label !== void 0 ? { label: options.label } : {}
1234
+ ...options.label !== void 0 ? { label: options.label } : {},
1235
+ ...reserved ? { reserved } : {}
1180
1236
  });
1181
1237
  return costUsd;
1182
1238
  }
1239
+ /**
1240
+ * Accrue a non-LLM cost (a paid tool/API call) against the same ceiling.
1241
+ *
1242
+ * Post-hoc accrual for costs only known after the call (metered APIs); when
1243
+ * the price is known up front, {@link BudgetGuard.reserveTool} /
1244
+ * {@link BudgetGuard.settleTool} give the stronger pre-call hard-stop. See
1245
+ * `settleTool` for the full contract. Returns `costUsd`.
1246
+ */
1247
+ recordTool(tool, costUsd, options = {}) {
1248
+ return this.settleTool(tool, costUsd, { reserved: 0, label: options.label });
1249
+ }
1183
1250
  /**
1184
1251
  * Drop an in-flight reservation without recording spend (e.g. the call failed
1185
1252
  * before producing usage). Safe to call with `0`.
@@ -1189,16 +1256,25 @@ var BudgetGuard = class {
1189
1256
  throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
1190
1257
  }
1191
1258
  if (!reserved) return;
1192
- this.reserved = Math.max(0, this.reserved - reserved);
1259
+ this.consumeReservation(reserved);
1193
1260
  }
1194
1261
  /** USD left before the ceiling, net of in-flight reservations (never negative). */
1195
1262
  get remainingUsd() {
1196
1263
  return Math.max(0, this.limitUsd - this.spentUsd - this.reserved);
1197
1264
  }
1265
+ /**
1266
+ * Per-tool running USD totals, keyed by the name given to `settleTool()` /
1267
+ * `recordTool()` — e.g. `{"apollo.people_lookup": 0.42, "exa.search": 0.11}`.
1268
+ * Makes the token/tool split of the one shared ceiling inspectable
1269
+ * (`spentUsd - sum of toolCosts` is the token side). Returns a snapshot copy.
1270
+ */
1271
+ get toolCosts() {
1272
+ return { ...this.toolCostTotals };
1273
+ }
1198
1274
  /**
1199
1275
  * The per-call spend ledger, oldest first — one {@link SpendEvent} per priced
1200
- * `record()` / `settle()` / `recordTool()`. Returns a snapshot copy: mutating
1201
- * it cannot corrupt the ledger.
1276
+ * `record()` / `settle()` / `recordTool()` / `settleTool()`. Returns a
1277
+ * snapshot copy: mutating it cannot corrupt the ledger.
1202
1278
  */
1203
1279
  get spendLog() {
1204
1280
  return [...this.spendEvents];
@@ -1229,6 +1305,34 @@ var BudgetGuard = class {
1229
1305
  `;
1230
1306
  }).join("");
1231
1307
  }
1308
+ /**
1309
+ * The default next-call prediction when the caller supplies no estimate.
1310
+ * Conservative: the costlier of the last LLM call and the last tool call — a
1311
+ * mixed loop predicts the pricier kind, which at worst blocks one call early
1312
+ * (fail-closed) rather than letting a crossing call through because the LAST
1313
+ * event happened to be cheap.
1314
+ */
1315
+ defaultEstimate() {
1316
+ return Math.max(this.lastLlmCost, this.lastToolCost);
1317
+ }
1318
+ /**
1319
+ * Subtract a settled/released hold from the in-flight tally. A handle larger
1320
+ * than EVERYTHING currently held cannot have come from a matching
1321
+ * `reserve()` — throwing beats silently clamping, which would free OTHER
1322
+ * callers' holds and fail the ceiling open. The epsilon absorbs float dust
1323
+ * from accumulating and draining many holds; per-caller over-release (a
1324
+ * handle within the total but larger than the caller's own hold) is
1325
+ * undetectable without per-handle tracking and remains the caller's
1326
+ * responsibility.
1327
+ */
1328
+ consumeReservation(reserved) {
1329
+ if (reserved > this.reserved + EPS) {
1330
+ throw new RangeError(
1331
+ `reserved handle (${reserved}) exceeds total in-flight reservations (${this.reserved}) \u2014 a handle must come from a matching reserve()`
1332
+ );
1333
+ }
1334
+ this.reserved = Math.max(0, this.reserved - reserved);
1335
+ }
1232
1336
  appendEvent(event) {
1233
1337
  this.spendEvents.push(Object.freeze(event));
1234
1338
  if (this.maxLogEvents !== void 0 && this.spendEvents.length > this.maxLogEvents) {