floe-guard 0.2.1 → 0.4.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 +21 -0
- package/dist/index.cjs +178 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +151 -2
- package/dist/index.d.ts +151 -2
- package/dist/index.js +176 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,6 +63,27 @@ const adv = guard.advisory();
|
|
|
63
63
|
const model = adv.nearLimit ? openai("gpt-4o-mini") : openai("gpt-4o");
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
## Per-call spend log
|
|
67
|
+
|
|
68
|
+
The guard keeps a typed, in-memory ledger of everything it priced: each
|
|
69
|
+
`record()` / `settle()` appends one `SpendEvent`, and `recordTool()` lets paid
|
|
70
|
+
non-LLM calls spend the same budget and land in the same log. The events sum to
|
|
71
|
+
`spentUsd` (unless a `maxLogEvents` ring buffer has evicted old ones).
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
const guard = new BudgetGuard(1.0); // { maxLogEvents: N } caps memory
|
|
75
|
+
guard.record("gpt-4o", 1_200, 350, { label: "researcher" });
|
|
76
|
+
guard.recordTool("serpapi.search", 0.01, { label: "researcher" });
|
|
77
|
+
|
|
78
|
+
guard.spendLog; // [{ timestamp, kind: "llm", modelOrTool: "gpt-4o", … }, …]
|
|
79
|
+
process.stdout.write(guard.exportLog()); // JSONL, one event per line
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`exportLog()` emits a stable snake_case schema —
|
|
83
|
+
`{timestamp, kind: llm|tool, model_or_tool, prompt_tokens, completion_tokens,
|
|
84
|
+
cost_usd, label?, reserved?}` — identical to the Python package's
|
|
85
|
+
`export_log()`, so every agent produces the same shape regardless of stack.
|
|
86
|
+
|
|
66
87
|
## Compatibility
|
|
67
88
|
|
|
68
89
|
`ai` is declared as a peer dependency with the range `>=4.0.0 <6.0.0`:
|
package/dist/index.cjs
CHANGED
|
@@ -22,7 +22,9 @@ var index_exports = {};
|
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
BudgetExceeded: () => BudgetExceeded,
|
|
24
24
|
BudgetGuard: () => BudgetGuard,
|
|
25
|
+
DeadlineExceeded: () => DeadlineExceeded,
|
|
25
26
|
FloeGuardError: () => FloeGuardError,
|
|
27
|
+
LatencyBudget: () => LatencyBudget,
|
|
26
28
|
UnpriceableModelError: () => UnpriceableModelError,
|
|
27
29
|
budgetGuardMiddleware: () => budgetGuardMiddleware,
|
|
28
30
|
priceTokens: () => priceTokens,
|
|
@@ -61,6 +63,21 @@ var UnpriceableModelError = class extends FloeGuardError {
|
|
|
61
63
|
this.model = model;
|
|
62
64
|
}
|
|
63
65
|
};
|
|
66
|
+
function roundHalfUp(ms) {
|
|
67
|
+
return Math.floor(ms + 0.5);
|
|
68
|
+
}
|
|
69
|
+
var DeadlineExceeded = class extends FloeGuardError {
|
|
70
|
+
elapsedMs;
|
|
71
|
+
slaMs;
|
|
72
|
+
constructor(elapsedMs, slaMs) {
|
|
73
|
+
super(
|
|
74
|
+
`DEADLINE EXCEEDED \u2014 call blocked (elapsed ${roundHalfUp(elapsedMs)}ms of ${roundHalfUp(slaMs)}ms SLA)`
|
|
75
|
+
);
|
|
76
|
+
this.name = "DeadlineExceeded";
|
|
77
|
+
this.elapsedMs = elapsedMs;
|
|
78
|
+
this.slaMs = slaMs;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
64
81
|
|
|
65
82
|
// src/pricing.ts
|
|
66
83
|
var pricing_exports = {};
|
|
@@ -979,6 +996,9 @@ var BudgetGuard = class {
|
|
|
979
996
|
lastCost = 0;
|
|
980
997
|
/** USD held for in-flight calls (reserved, not yet settled). Counts toward the ceiling. */
|
|
981
998
|
reserved = 0;
|
|
999
|
+
/** Per-call ledger, oldest first; a ring buffer when maxLogEvents is set. */
|
|
1000
|
+
spendEvents = [];
|
|
1001
|
+
maxLogEvents;
|
|
982
1002
|
/**
|
|
983
1003
|
* @param limitUsd the spend ceiling, in USD. `0` blocks the very first call.
|
|
984
1004
|
*/
|
|
@@ -994,7 +1014,13 @@ var BudgetGuard = class {
|
|
|
994
1014
|
`nearLimitBps must be an integer in 0..10000, got ${nearLimitBps}`
|
|
995
1015
|
);
|
|
996
1016
|
}
|
|
1017
|
+
if (options.maxLogEvents !== void 0 && (!Number.isInteger(options.maxLogEvents) || options.maxLogEvents < 0)) {
|
|
1018
|
+
throw new RangeError(
|
|
1019
|
+
`maxLogEvents must be a non-negative integer, got ${options.maxLogEvents}`
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
997
1022
|
this.limitUsd = limitUsd;
|
|
1023
|
+
this.maxLogEvents = options.maxLogEvents;
|
|
998
1024
|
this.priceOverrides = options.priceOverrides;
|
|
999
1025
|
this.failClosed = options.failClosed ?? true;
|
|
1000
1026
|
this.onBlock = options.onBlock ?? defaultOnBlock;
|
|
@@ -1056,7 +1082,10 @@ var BudgetGuard = class {
|
|
|
1056
1082
|
* Release a reservation and record the actual cost. `record` is `settle` with
|
|
1057
1083
|
* no reservation. Returns the USD cost of this call; unpriceable-model handling
|
|
1058
1084
|
* matches {@link BudgetGuard.record}, and any held reservation is released even
|
|
1059
|
-
* on the warn-and-skip path.
|
|
1085
|
+
* on the warn-and-skip path. A priced call appends one {@link SpendEvent} to
|
|
1086
|
+
* {@link BudgetGuard.spendLog} (`label` tags it, e.g. with an agent/task name);
|
|
1087
|
+
* the warn-and-skip path accrues nothing and logs nothing, so the ledger stays
|
|
1088
|
+
* in lockstep with `spentUsd`.
|
|
1060
1089
|
*/
|
|
1061
1090
|
settle(model, promptTokens, completionTokens, options = {}) {
|
|
1062
1091
|
const reserved = options.reserved ?? 0;
|
|
@@ -1093,6 +1122,18 @@ var BudgetGuard = class {
|
|
|
1093
1122
|
this.spentUsd = this.limitUsd;
|
|
1094
1123
|
}
|
|
1095
1124
|
this.lastCost = cost;
|
|
1125
|
+
this.appendEvent({
|
|
1126
|
+
timestamp: Date.now() / 1e3,
|
|
1127
|
+
kind: "llm",
|
|
1128
|
+
modelOrTool: model,
|
|
1129
|
+
promptTokens,
|
|
1130
|
+
completionTokens,
|
|
1131
|
+
costUsd: cost,
|
|
1132
|
+
...options.label !== void 0 ? { label: options.label } : {},
|
|
1133
|
+
// 0 means "no reservation" (the plain record() path) — omit rather than
|
|
1134
|
+
// log a meaningless zero.
|
|
1135
|
+
...reserved ? { reserved } : {}
|
|
1136
|
+
});
|
|
1096
1137
|
return cost;
|
|
1097
1138
|
}
|
|
1098
1139
|
/**
|
|
@@ -1105,9 +1146,40 @@ var BudgetGuard = class {
|
|
|
1105
1146
|
record(model, promptTokens, completionTokens, options = {}) {
|
|
1106
1147
|
return this.settle(model, promptTokens, completionTokens, {
|
|
1107
1148
|
reserved: 0,
|
|
1108
|
-
price: options.price
|
|
1149
|
+
price: options.price,
|
|
1150
|
+
label: options.label
|
|
1109
1151
|
});
|
|
1110
1152
|
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Accrue a non-LLM cost (a paid tool/API call) against the same ceiling.
|
|
1155
|
+
*
|
|
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`.
|
|
1163
|
+
*/
|
|
1164
|
+
recordTool(tool, costUsd, options = {}) {
|
|
1165
|
+
if (!Number.isFinite(costUsd) || costUsd < 0) {
|
|
1166
|
+
throw new RangeError(`costUsd must be a finite, non-negative number, got ${costUsd}`);
|
|
1167
|
+
}
|
|
1168
|
+
this.spentUsd += costUsd;
|
|
1169
|
+
if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
|
|
1170
|
+
this.spentUsd = this.limitUsd;
|
|
1171
|
+
}
|
|
1172
|
+
this.appendEvent({
|
|
1173
|
+
timestamp: Date.now() / 1e3,
|
|
1174
|
+
kind: "tool",
|
|
1175
|
+
modelOrTool: tool,
|
|
1176
|
+
promptTokens: null,
|
|
1177
|
+
completionTokens: null,
|
|
1178
|
+
costUsd,
|
|
1179
|
+
...options.label !== void 0 ? { label: options.label } : {}
|
|
1180
|
+
});
|
|
1181
|
+
return costUsd;
|
|
1182
|
+
}
|
|
1111
1183
|
/**
|
|
1112
1184
|
* Drop an in-flight reservation without recording spend (e.g. the call failed
|
|
1113
1185
|
* before producing usage). Safe to call with `0`.
|
|
@@ -1123,6 +1195,46 @@ var BudgetGuard = class {
|
|
|
1123
1195
|
get remainingUsd() {
|
|
1124
1196
|
return Math.max(0, this.limitUsd - this.spentUsd - this.reserved);
|
|
1125
1197
|
}
|
|
1198
|
+
/**
|
|
1199
|
+
* 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.
|
|
1202
|
+
*/
|
|
1203
|
+
get spendLog() {
|
|
1204
|
+
return [...this.spendEvents];
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* The spend ledger as JSONL — one event per line, newline-terminated.
|
|
1208
|
+
*
|
|
1209
|
+
* The schema is stable and language-independent (snake_case keys, fixed order;
|
|
1210
|
+
* optional fields omitted when absent), identical to the Python package's
|
|
1211
|
+
* `export_log()`, so heterogeneous agents produce logs you can concatenate and
|
|
1212
|
+
* analyse as one stream. (The *schema* is the contract, not the bytes: the two
|
|
1213
|
+
* runtimes may render the same float differently, e.g. JS `0.0000025` vs
|
|
1214
|
+
* Python `2.5e-06`.) Empty ledger yields `""`.
|
|
1215
|
+
*/
|
|
1216
|
+
exportLog() {
|
|
1217
|
+
return this.spendEvents.map((e) => {
|
|
1218
|
+
const row = {
|
|
1219
|
+
timestamp: e.timestamp,
|
|
1220
|
+
kind: e.kind,
|
|
1221
|
+
model_or_tool: e.modelOrTool,
|
|
1222
|
+
prompt_tokens: e.promptTokens,
|
|
1223
|
+
completion_tokens: e.completionTokens,
|
|
1224
|
+
cost_usd: e.costUsd
|
|
1225
|
+
};
|
|
1226
|
+
if (e.label !== void 0) row.label = e.label;
|
|
1227
|
+
if (e.reserved !== void 0) row.reserved = e.reserved;
|
|
1228
|
+
return `${JSON.stringify(row)}
|
|
1229
|
+
`;
|
|
1230
|
+
}).join("");
|
|
1231
|
+
}
|
|
1232
|
+
appendEvent(event) {
|
|
1233
|
+
this.spendEvents.push(Object.freeze(event));
|
|
1234
|
+
if (this.maxLogEvents !== void 0 && this.spendEvents.length > this.maxLogEvents) {
|
|
1235
|
+
this.spendEvents.splice(0, this.spendEvents.length - this.maxLogEvents);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1126
1238
|
/**
|
|
1127
1239
|
* Context-aware spend advisory for this budget — see {@link BudgetAdvisory}.
|
|
1128
1240
|
*
|
|
@@ -1154,6 +1266,68 @@ function defaultOnBlock(spentUsd, limitUsd) {
|
|
|
1154
1266
|
);
|
|
1155
1267
|
}
|
|
1156
1268
|
|
|
1269
|
+
// src/latency.ts
|
|
1270
|
+
var LatencyBudget = class {
|
|
1271
|
+
slaMs;
|
|
1272
|
+
nearDeadlineBps;
|
|
1273
|
+
onBlock;
|
|
1274
|
+
clock;
|
|
1275
|
+
startedAt;
|
|
1276
|
+
/** The budget starts counting at construction — build it when the request
|
|
1277
|
+
* (and its SLA) starts. */
|
|
1278
|
+
constructor(slaMs, options = {}) {
|
|
1279
|
+
if (!(Number.isFinite(slaMs) && slaMs > 0)) {
|
|
1280
|
+
throw new RangeError("slaMs must be > 0");
|
|
1281
|
+
}
|
|
1282
|
+
const nearDeadlineBps = options.nearDeadlineBps ?? 8e3;
|
|
1283
|
+
if (!Number.isInteger(nearDeadlineBps) || nearDeadlineBps < 0 || nearDeadlineBps > 1e4) {
|
|
1284
|
+
throw new RangeError("nearDeadlineBps must be an integer 0..10000");
|
|
1285
|
+
}
|
|
1286
|
+
this.slaMs = slaMs;
|
|
1287
|
+
this.nearDeadlineBps = nearDeadlineBps;
|
|
1288
|
+
this.onBlock = options.onBlock;
|
|
1289
|
+
this.clock = options.clock ?? (() => performance.now());
|
|
1290
|
+
this.startedAt = this.clock();
|
|
1291
|
+
}
|
|
1292
|
+
/** Milliseconds since construction (monotonic). */
|
|
1293
|
+
get elapsedMs() {
|
|
1294
|
+
return this.clock() - this.startedAt;
|
|
1295
|
+
}
|
|
1296
|
+
/** Milliseconds left before the SLA, floored at 0 — the readable signal a
|
|
1297
|
+
* router uses to pick a faster fallback or truncate work mid-chain. */
|
|
1298
|
+
get remainingMs() {
|
|
1299
|
+
return Math.max(0, this.slaMs - this.elapsedMs);
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Throw {@link DeadlineExceeded} when the projected elapsed time (now +
|
|
1303
|
+
* `expectedMs` for the upcoming call) would blow the SLA. Call it
|
|
1304
|
+
* immediately before each tool/model call; pass 0 to only gate on time
|
|
1305
|
+
* already spent.
|
|
1306
|
+
*/
|
|
1307
|
+
check(expectedMs = 0) {
|
|
1308
|
+
if (!(Number.isFinite(expectedMs) && expectedMs >= 0)) {
|
|
1309
|
+
throw new RangeError("expectedMs must be >= 0");
|
|
1310
|
+
}
|
|
1311
|
+
const elapsed = this.elapsedMs;
|
|
1312
|
+
if (elapsed + expectedMs > this.slaMs) {
|
|
1313
|
+
this.onBlock?.(elapsed, this.slaMs);
|
|
1314
|
+
throw new DeadlineExceeded(elapsed, this.slaMs);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
/** The soft near-deadline signal — symmetric to `BudgetGuard.advisory()`. */
|
|
1318
|
+
advisory() {
|
|
1319
|
+
const elapsed = this.elapsedMs;
|
|
1320
|
+
const usedBps = elapsed > 0 ? Math.min(1e4, Math.round(elapsed * 1e4 / this.slaMs)) : 0;
|
|
1321
|
+
return {
|
|
1322
|
+
nearDeadline: usedBps >= this.nearDeadlineBps,
|
|
1323
|
+
usedBps,
|
|
1324
|
+
remainingMs: Math.max(0, this.slaMs - elapsed),
|
|
1325
|
+
slaMs: this.slaMs,
|
|
1326
|
+
elapsedMs: elapsed
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
|
|
1157
1331
|
// src/middleware.ts
|
|
1158
1332
|
function usageTokens(modelId, usage) {
|
|
1159
1333
|
const u = usage;
|
|
@@ -1247,7 +1421,9 @@ function budgetGuardMiddleware(guard) {
|
|
|
1247
1421
|
0 && (module.exports = {
|
|
1248
1422
|
BudgetExceeded,
|
|
1249
1423
|
BudgetGuard,
|
|
1424
|
+
DeadlineExceeded,
|
|
1250
1425
|
FloeGuardError,
|
|
1426
|
+
LatencyBudget,
|
|
1251
1427
|
UnpriceableModelError,
|
|
1252
1428
|
budgetGuardMiddleware,
|
|
1253
1429
|
priceTokens,
|