floe-guard 0.1.0 → 0.2.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 +32 -4
- package/dist/index.cjs +219 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +128 -13
- package/dist/index.d.ts +128 -13
- package/dist/index.js +219 -29
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/cost_map.json +30 -0
package/README.md
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
# floe-guard (Vercel AI SDK)
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/floe-guard)
|
|
4
|
+
[](../LICENSE)
|
|
5
|
+
|
|
3
6
|
**A local budget guardrail for AI agents** — the TypeScript counterpart to the
|
|
4
7
|
[Python `floe-guard`](../README.md). It hard-stops your agent *before its next LLM
|
|
5
8
|
call* when it would cross a USD spend ceiling. No account, no signup, no network.
|
|
6
9
|
Runs in your process.
|
|
7
10
|
|
|
11
|
+
Works with both **AI SDK v4 and v5** (`ai@4` / `ai@5`).
|
|
12
|
+
|
|
8
13
|
```bash
|
|
9
|
-
npm i floe-guard ai
|
|
14
|
+
npm i floe-guard ai @ai-sdk/openai
|
|
10
15
|
```
|
|
11
16
|
|
|
12
17
|
```ts
|
|
@@ -44,10 +49,33 @@ const guard = new BudgetGuard(5.0, {
|
|
|
44
49
|
});
|
|
45
50
|
```
|
|
46
51
|
|
|
47
|
-
##
|
|
52
|
+
## Context-aware budgeting
|
|
53
|
+
|
|
54
|
+
`guard.advisory()` returns a soft signal you can act on before a call — `nearLimit`,
|
|
55
|
+
`usedBps`, `remainingUsd` — so the agent can taper near the cap instead of being
|
|
56
|
+
cut off. The hard-stop (`check`) is still the guarantee. This is the same shape
|
|
57
|
+
the Python package exposes and that hosted Floe returns on every proxied call
|
|
58
|
+
(`X-Floe-Budget-Advisory`), so the logic ports unchanged to the hosted path.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const guard = new BudgetGuard(0.1, { nearLimitBps: 7000 }); // flag at 70% used
|
|
62
|
+
const adv = guard.advisory();
|
|
63
|
+
const model = adv.nearLimit ? openai("gpt-4o-mini") : openai("gpt-4o");
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Compatibility
|
|
67
|
+
|
|
68
|
+
`ai` is declared as a peer dependency with the range `>=4.0.0 <6.0.0`:
|
|
69
|
+
|
|
70
|
+
- **`ai@4`** — `LanguageModelV1Middleware` via `wrapLanguageModel` /
|
|
71
|
+
`experimental_wrapLanguageModel`; usage read from
|
|
72
|
+
`promptTokens`/`completionTokens`.
|
|
73
|
+
- **`ai@5`** — `LanguageModelV2Middleware` via `wrapLanguageModel`; usage read
|
|
74
|
+
from `inputTokens`/`outputTokens`.
|
|
48
75
|
|
|
49
|
-
`ai
|
|
50
|
-
|
|
76
|
+
The middleware imports nothing from `ai` at runtime or in its types, so one
|
|
77
|
+
build serves both majors. If a provider reports no usable token counts, the
|
|
78
|
+
call is rejected (fail-closed) rather than metered as $0.
|
|
51
79
|
|
|
52
80
|
## Development
|
|
53
81
|
|
package/dist/index.cjs
CHANGED
|
@@ -107,6 +107,12 @@ var cost_map_default = {
|
|
|
107
107
|
litellm_provider: "anthropic",
|
|
108
108
|
mode: "chat"
|
|
109
109
|
},
|
|
110
|
+
"claude-fable-5": {
|
|
111
|
+
input_cost_per_token: 1e-5,
|
|
112
|
+
output_cost_per_token: 5e-5,
|
|
113
|
+
litellm_provider: "anthropic",
|
|
114
|
+
mode: "chat"
|
|
115
|
+
},
|
|
110
116
|
"claude-haiku-4-5": {
|
|
111
117
|
input_cost_per_token: 1e-6,
|
|
112
118
|
output_cost_per_token: 5e-6,
|
|
@@ -731,6 +737,24 @@ var cost_map_default = {
|
|
|
731
737
|
litellm_provider: "openai",
|
|
732
738
|
mode: "chat"
|
|
733
739
|
},
|
|
740
|
+
"llama-3.1-8b-instant": {
|
|
741
|
+
input_cost_per_token: 5e-8,
|
|
742
|
+
output_cost_per_token: 8e-8,
|
|
743
|
+
litellm_provider: "groq",
|
|
744
|
+
mode: "chat"
|
|
745
|
+
},
|
|
746
|
+
"llama-3.3-70b-versatile": {
|
|
747
|
+
input_cost_per_token: 59e-8,
|
|
748
|
+
output_cost_per_token: 79e-8,
|
|
749
|
+
litellm_provider: "groq",
|
|
750
|
+
mode: "chat"
|
|
751
|
+
},
|
|
752
|
+
"meta-llama/llama-4-scout-17b-16e-instruct": {
|
|
753
|
+
input_cost_per_token: 11e-8,
|
|
754
|
+
output_cost_per_token: 34e-8,
|
|
755
|
+
litellm_provider: "groq",
|
|
756
|
+
mode: "chat"
|
|
757
|
+
},
|
|
734
758
|
o1: {
|
|
735
759
|
input_cost_per_token: 15e-6,
|
|
736
760
|
output_cost_per_token: 6e-5,
|
|
@@ -779,6 +803,12 @@ var cost_map_default = {
|
|
|
779
803
|
litellm_provider: "openai",
|
|
780
804
|
mode: "chat"
|
|
781
805
|
},
|
|
806
|
+
"qwen/qwen3-32b": {
|
|
807
|
+
input_cost_per_token: 29e-8,
|
|
808
|
+
output_cost_per_token: 59e-8,
|
|
809
|
+
litellm_provider: "groq",
|
|
810
|
+
mode: "chat"
|
|
811
|
+
},
|
|
782
812
|
"text-embedding-3-large": {
|
|
783
813
|
input_cost_per_token: 13e-8,
|
|
784
814
|
output_cost_per_token: 0,
|
|
@@ -858,9 +888,12 @@ var BudgetGuard = class {
|
|
|
858
888
|
spentUsd = 0;
|
|
859
889
|
priceOverrides;
|
|
860
890
|
failClosed;
|
|
891
|
+
nearLimitBps;
|
|
861
892
|
onBlock;
|
|
862
893
|
/** Cost of the most recent priced call, used to predict the next one. */
|
|
863
894
|
lastCost = 0;
|
|
895
|
+
/** USD held for in-flight calls (reserved, not yet settled). Counts toward the ceiling. */
|
|
896
|
+
reserved = 0;
|
|
864
897
|
/**
|
|
865
898
|
* @param limitUsd the spend ceiling, in USD. `0` blocks the very first call.
|
|
866
899
|
*/
|
|
@@ -870,35 +903,81 @@ var BudgetGuard = class {
|
|
|
870
903
|
`limitUsd must be a finite, non-negative number, got ${limitUsd}`
|
|
871
904
|
);
|
|
872
905
|
}
|
|
906
|
+
const nearLimitBps = options.nearLimitBps === void 0 ? 8e3 : options.nearLimitBps;
|
|
907
|
+
if (!Number.isInteger(nearLimitBps) || nearLimitBps < 0 || nearLimitBps > 1e4) {
|
|
908
|
+
throw new RangeError(
|
|
909
|
+
`nearLimitBps must be an integer in 0..10000, got ${nearLimitBps}`
|
|
910
|
+
);
|
|
911
|
+
}
|
|
873
912
|
this.limitUsd = limitUsd;
|
|
874
913
|
this.priceOverrides = options.priceOverrides;
|
|
875
914
|
this.failClosed = options.failClosed ?? true;
|
|
876
915
|
this.onBlock = options.onBlock ?? defaultOnBlock;
|
|
916
|
+
this.nearLimitBps = nearLimitBps;
|
|
877
917
|
}
|
|
878
918
|
/**
|
|
879
919
|
* Throw {@link BudgetExceeded} if the next call would cross the ceiling.
|
|
880
920
|
*
|
|
881
921
|
* Call this immediately before each LLM request. The "next call" is estimated
|
|
882
922
|
* from the last recorded call's cost (override with `estimatedNextCost`); the
|
|
883
|
-
* first call is always allowed unless the ceiling is already met.
|
|
884
|
-
*
|
|
923
|
+
* first call is always allowed unless the ceiling is already met. In-flight
|
|
924
|
+
* reservations count toward the total, so this stays correct alongside
|
|
925
|
+
* {@link BudgetGuard.reserve}.
|
|
926
|
+
*
|
|
927
|
+
* Note: `check` is a non-binding peek. For parallel calls, use `reserve()` /
|
|
928
|
+
* `settle()`, which hold the estimate across the await.
|
|
885
929
|
*/
|
|
886
930
|
check(estimatedNextCost) {
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
|
|
931
|
+
const rawEstimate = estimatedNextCost === void 0 ? this.lastCost : estimatedNextCost;
|
|
932
|
+
if (!Number.isFinite(rawEstimate)) {
|
|
933
|
+
throw new RangeError(
|
|
934
|
+
`estimatedNextCost must be a finite number, got ${rawEstimate}`
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
const estimate = Math.max(0, rawEstimate);
|
|
938
|
+
const committed = this.spentUsd + this.reserved;
|
|
939
|
+
if (committed > this.limitUsd - EPS || committed + estimate > this.limitUsd + EPS) {
|
|
890
940
|
this.onBlock(this.spentUsd, this.limitUsd);
|
|
891
941
|
throw new BudgetExceeded(this.spentUsd, this.limitUsd);
|
|
892
942
|
}
|
|
893
943
|
}
|
|
894
944
|
/**
|
|
895
|
-
*
|
|
945
|
+
* Atomically check the ceiling AND hold the estimated cost in flight.
|
|
896
946
|
*
|
|
897
|
-
*
|
|
898
|
-
*
|
|
899
|
-
*
|
|
947
|
+
* The concurrency-safe enforcement path: call before the request and hold the
|
|
948
|
+
* returned reservation across the await, so parallel callers can't all clear
|
|
949
|
+
* the same stale total. Throws {@link BudgetExceeded} (without reserving) if
|
|
950
|
+
* the reservation would cross the ceiling. Returns the reservation handle to
|
|
951
|
+
* pass to {@link BudgetGuard.settle} (or {@link BudgetGuard.release} on error).
|
|
952
|
+
* `estimatedCost` defaults to the last call's cost.
|
|
900
953
|
*/
|
|
901
|
-
|
|
954
|
+
reserve(estimatedCost) {
|
|
955
|
+
const rawEstimate = estimatedCost === void 0 ? this.lastCost : estimatedCost;
|
|
956
|
+
if (!Number.isFinite(rawEstimate)) {
|
|
957
|
+
throw new RangeError(
|
|
958
|
+
`estimatedCost must be a finite number, got ${rawEstimate}`
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
const estimate = Math.max(0, rawEstimate);
|
|
962
|
+
const committed = this.spentUsd + this.reserved;
|
|
963
|
+
if (committed > this.limitUsd - EPS || committed + estimate > this.limitUsd + EPS) {
|
|
964
|
+
this.onBlock(this.spentUsd, this.limitUsd);
|
|
965
|
+
throw new BudgetExceeded(this.spentUsd, this.limitUsd);
|
|
966
|
+
}
|
|
967
|
+
this.reserved += estimate;
|
|
968
|
+
return estimate;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Release a reservation and record the actual cost. `record` is `settle` with
|
|
972
|
+
* no reservation. Returns the USD cost of this call; unpriceable-model handling
|
|
973
|
+
* matches {@link BudgetGuard.record}, and any held reservation is released even
|
|
974
|
+
* on the warn-and-skip path.
|
|
975
|
+
*/
|
|
976
|
+
settle(model, promptTokens, completionTokens, options = {}) {
|
|
977
|
+
const reserved = options.reserved ?? 0;
|
|
978
|
+
if (!Number.isFinite(reserved) || reserved < 0) {
|
|
979
|
+
throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
|
|
980
|
+
}
|
|
902
981
|
let overrides = this.priceOverrides;
|
|
903
982
|
if (options.price !== void 0) {
|
|
904
983
|
overrides = { ...overrides ?? {}, [model]: options.price };
|
|
@@ -908,12 +987,22 @@ var BudgetGuard = class {
|
|
|
908
987
|
console.warn(
|
|
909
988
|
`Cannot price model '${model}': not in the bundled cost map and no manual price given. The budget guard cannot enforce a ceiling on spend it cannot measure \u2014 pass { price } or set it in priceOverrides.`
|
|
910
989
|
);
|
|
990
|
+
this.release(reserved);
|
|
911
991
|
if (this.failClosed) {
|
|
912
992
|
throw new UnpriceableModelError(model);
|
|
913
993
|
}
|
|
914
994
|
return 0;
|
|
915
995
|
}
|
|
916
|
-
|
|
996
|
+
let cost;
|
|
997
|
+
try {
|
|
998
|
+
cost = priceTokens(priced, promptTokens, completionTokens);
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
this.release(reserved);
|
|
1001
|
+
throw err;
|
|
1002
|
+
}
|
|
1003
|
+
if (reserved) {
|
|
1004
|
+
this.reserved = Math.max(0, this.reserved - reserved);
|
|
1005
|
+
}
|
|
917
1006
|
this.spentUsd += cost;
|
|
918
1007
|
if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
|
|
919
1008
|
this.spentUsd = this.limitUsd;
|
|
@@ -921,9 +1010,55 @@ var BudgetGuard = class {
|
|
|
921
1010
|
this.lastCost = cost;
|
|
922
1011
|
return cost;
|
|
923
1012
|
}
|
|
924
|
-
/**
|
|
1013
|
+
/**
|
|
1014
|
+
* Price one response's tokens offline and add the cost to the total.
|
|
1015
|
+
*
|
|
1016
|
+
* Returns the USD cost of this call. If the model is unpriceable and no `price`
|
|
1017
|
+
* is given, behaviour depends on `failClosed`: warn + throw (default), or
|
|
1018
|
+
* warn + skip accrual.
|
|
1019
|
+
*/
|
|
1020
|
+
record(model, promptTokens, completionTokens, options = {}) {
|
|
1021
|
+
return this.settle(model, promptTokens, completionTokens, {
|
|
1022
|
+
reserved: 0,
|
|
1023
|
+
price: options.price
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Drop an in-flight reservation without recording spend (e.g. the call failed
|
|
1028
|
+
* before producing usage). Safe to call with `0`.
|
|
1029
|
+
*/
|
|
1030
|
+
release(reserved) {
|
|
1031
|
+
if (!Number.isFinite(reserved) || reserved < 0) {
|
|
1032
|
+
throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
|
|
1033
|
+
}
|
|
1034
|
+
if (!reserved) return;
|
|
1035
|
+
this.reserved = Math.max(0, this.reserved - reserved);
|
|
1036
|
+
}
|
|
1037
|
+
/** USD left before the ceiling, net of in-flight reservations (never negative). */
|
|
925
1038
|
get remainingUsd() {
|
|
926
|
-
return Math.max(0, this.limitUsd - this.spentUsd);
|
|
1039
|
+
return Math.max(0, this.limitUsd - this.spentUsd - this.reserved);
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Context-aware spend advisory for this budget — see {@link BudgetAdvisory}.
|
|
1043
|
+
*
|
|
1044
|
+
* `nearLimit` flips once utilization reaches `nearLimitBps` (default 80%), so an
|
|
1045
|
+
* agent can taper *before* the hard-stop. Advisory only: read it to adapt;
|
|
1046
|
+
* {@link BudgetGuard.check} is what enforces the ceiling.
|
|
1047
|
+
*/
|
|
1048
|
+
advisory() {
|
|
1049
|
+
const usedBps = this.limitUsd <= 0 ? 1e4 : Math.max(0, Math.min(1e4, Math.floor(this.spentUsd / this.limitUsd * 1e4 + 1e-9)));
|
|
1050
|
+
return {
|
|
1051
|
+
nearLimit: usedBps >= this.nearLimitBps,
|
|
1052
|
+
usedBps,
|
|
1053
|
+
// Settled budget: limit minus accrued spend, deliberately NOT net of
|
|
1054
|
+
// in-flight reservations. Unlike the remainingUsd getter (which subtracts
|
|
1055
|
+
// `reserved`), the advisory is a soft utilization signal about money already
|
|
1056
|
+
// spent, while the getter reports what a new call can still claim.
|
|
1057
|
+
remainingUsd: Math.max(0, this.limitUsd - this.spentUsd),
|
|
1058
|
+
limitUsd: this.limitUsd,
|
|
1059
|
+
spentUsd: this.spentUsd,
|
|
1060
|
+
scope: "local"
|
|
1061
|
+
};
|
|
927
1062
|
}
|
|
928
1063
|
};
|
|
929
1064
|
function defaultOnBlock(spentUsd, limitUsd) {
|
|
@@ -935,33 +1070,88 @@ function defaultOnBlock(spentUsd, limitUsd) {
|
|
|
935
1070
|
}
|
|
936
1071
|
|
|
937
1072
|
// src/middleware.ts
|
|
1073
|
+
function usageTokens(modelId, usage) {
|
|
1074
|
+
const u = usage;
|
|
1075
|
+
const promptTokens = u?.promptTokens ?? u?.inputTokens;
|
|
1076
|
+
const completionTokens = u?.completionTokens ?? u?.outputTokens;
|
|
1077
|
+
if (typeof promptTokens !== "number" || typeof completionTokens !== "number") {
|
|
1078
|
+
throw new Error(
|
|
1079
|
+
`Model '${modelId}' reported no token usage \u2014 the budget guard cannot meter spend it cannot see, so this call is rejected rather than treated as free.`
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
return { promptTokens, completionTokens };
|
|
1083
|
+
}
|
|
938
1084
|
function budgetGuardMiddleware(guard) {
|
|
939
1085
|
return {
|
|
940
1086
|
async wrapGenerate({ doGenerate, model }) {
|
|
941
|
-
guard.
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1087
|
+
const reserved = guard.reserve();
|
|
1088
|
+
let result;
|
|
1089
|
+
try {
|
|
1090
|
+
result = await doGenerate();
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
guard.release(reserved);
|
|
1093
|
+
throw err;
|
|
1094
|
+
}
|
|
1095
|
+
let handled = false;
|
|
1096
|
+
try {
|
|
1097
|
+
const { promptTokens, completionTokens } = usageTokens(
|
|
1098
|
+
model.modelId,
|
|
1099
|
+
result?.usage
|
|
1100
|
+
);
|
|
1101
|
+
handled = true;
|
|
1102
|
+
guard.settle(model.modelId, promptTokens, completionTokens, { reserved });
|
|
1103
|
+
return result;
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
if (!handled) guard.release(reserved);
|
|
1106
|
+
throw err;
|
|
1107
|
+
}
|
|
949
1108
|
},
|
|
950
1109
|
async wrapStream({ doStream, model }) {
|
|
951
|
-
guard.
|
|
952
|
-
|
|
1110
|
+
const reserved = guard.reserve();
|
|
1111
|
+
let streamResult;
|
|
1112
|
+
try {
|
|
1113
|
+
streamResult = await doStream();
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
guard.release(reserved);
|
|
1116
|
+
throw err;
|
|
1117
|
+
}
|
|
1118
|
+
const { stream, ...rest } = streamResult;
|
|
1119
|
+
let handled = false;
|
|
953
1120
|
const guarded = stream.pipeThrough(
|
|
954
1121
|
new TransformStream({
|
|
955
1122
|
transform(chunk, controller) {
|
|
956
|
-
if (chunk
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1123
|
+
if (chunk?.type === "finish" && !handled) {
|
|
1124
|
+
try {
|
|
1125
|
+
const { promptTokens, completionTokens } = usageTokens(
|
|
1126
|
+
model.modelId,
|
|
1127
|
+
chunk.usage
|
|
1128
|
+
);
|
|
1129
|
+
handled = true;
|
|
1130
|
+
guard.settle(model.modelId, promptTokens, completionTokens, { reserved });
|
|
1131
|
+
} catch (err) {
|
|
1132
|
+
if (!handled) {
|
|
1133
|
+
handled = true;
|
|
1134
|
+
guard.release(reserved);
|
|
1135
|
+
}
|
|
1136
|
+
throw err;
|
|
1137
|
+
}
|
|
962
1138
|
}
|
|
963
1139
|
controller.enqueue(chunk);
|
|
1140
|
+
},
|
|
1141
|
+
flush() {
|
|
1142
|
+
if (!handled) {
|
|
1143
|
+
handled = true;
|
|
1144
|
+
guard.release(reserved);
|
|
1145
|
+
}
|
|
1146
|
+
},
|
|
1147
|
+
cancel() {
|
|
1148
|
+
if (!handled) {
|
|
1149
|
+
handled = true;
|
|
1150
|
+
guard.release(reserved);
|
|
1151
|
+
}
|
|
964
1152
|
}
|
|
1153
|
+
// `cancel` is valid per the Streams spec and supported in Node 18+, but
|
|
1154
|
+
// TS's Transformer lib type lags and omits it — cast to keep the type check.
|
|
965
1155
|
})
|
|
966
1156
|
);
|
|
967
1157
|
return { stream: guarded, ...rest };
|