floe-guard 0.1.0 → 0.2.1
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 +328 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +131 -15
- package/dist/index.d.ts +131 -15
- package/dist/index.js +328 -53
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/cost_map.json +90 -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,
|
|
@@ -203,6 +209,12 @@ var cost_map_default = {
|
|
|
203
209
|
litellm_provider: "anthropic",
|
|
204
210
|
mode: "chat"
|
|
205
211
|
},
|
|
212
|
+
"claude-sonnet-5": {
|
|
213
|
+
input_cost_per_token: 2e-6,
|
|
214
|
+
output_cost_per_token: 1e-5,
|
|
215
|
+
litellm_provider: "anthropic",
|
|
216
|
+
mode: "chat"
|
|
217
|
+
},
|
|
206
218
|
"ft:gpt-3.5-turbo": {
|
|
207
219
|
input_cost_per_token: 3e-6,
|
|
208
220
|
output_cost_per_token: 6e-6,
|
|
@@ -653,6 +665,30 @@ var cost_map_default = {
|
|
|
653
665
|
litellm_provider: "openai",
|
|
654
666
|
mode: "chat"
|
|
655
667
|
},
|
|
668
|
+
"gpt-5.6": {
|
|
669
|
+
input_cost_per_token: 5e-6,
|
|
670
|
+
output_cost_per_token: 3e-5,
|
|
671
|
+
litellm_provider: "openai",
|
|
672
|
+
mode: "chat"
|
|
673
|
+
},
|
|
674
|
+
"gpt-5.6-luna": {
|
|
675
|
+
input_cost_per_token: 1e-6,
|
|
676
|
+
output_cost_per_token: 6e-6,
|
|
677
|
+
litellm_provider: "openai",
|
|
678
|
+
mode: "chat"
|
|
679
|
+
},
|
|
680
|
+
"gpt-5.6-sol": {
|
|
681
|
+
input_cost_per_token: 5e-6,
|
|
682
|
+
output_cost_per_token: 3e-5,
|
|
683
|
+
litellm_provider: "openai",
|
|
684
|
+
mode: "chat"
|
|
685
|
+
},
|
|
686
|
+
"gpt-5.6-terra": {
|
|
687
|
+
input_cost_per_token: 25e-7,
|
|
688
|
+
output_cost_per_token: 15e-6,
|
|
689
|
+
litellm_provider: "openai",
|
|
690
|
+
mode: "chat"
|
|
691
|
+
},
|
|
656
692
|
"gpt-audio": {
|
|
657
693
|
input_cost_per_token: 25e-7,
|
|
658
694
|
output_cost_per_token: 1e-5,
|
|
@@ -707,6 +743,18 @@ var cost_map_default = {
|
|
|
707
743
|
litellm_provider: "openai",
|
|
708
744
|
mode: "chat"
|
|
709
745
|
},
|
|
746
|
+
"gpt-realtime-2.1": {
|
|
747
|
+
input_cost_per_token: 4e-6,
|
|
748
|
+
output_cost_per_token: 24e-6,
|
|
749
|
+
litellm_provider: "openai",
|
|
750
|
+
mode: "chat"
|
|
751
|
+
},
|
|
752
|
+
"gpt-realtime-2.1-mini": {
|
|
753
|
+
input_cost_per_token: 6e-7,
|
|
754
|
+
output_cost_per_token: 24e-7,
|
|
755
|
+
litellm_provider: "openai",
|
|
756
|
+
mode: "chat"
|
|
757
|
+
},
|
|
710
758
|
"gpt-realtime-2025-08-28": {
|
|
711
759
|
input_cost_per_token: 4e-6,
|
|
712
760
|
output_cost_per_token: 16e-6,
|
|
@@ -731,6 +779,24 @@ var cost_map_default = {
|
|
|
731
779
|
litellm_provider: "openai",
|
|
732
780
|
mode: "chat"
|
|
733
781
|
},
|
|
782
|
+
"llama-3.1-8b-instant": {
|
|
783
|
+
input_cost_per_token: 5e-8,
|
|
784
|
+
output_cost_per_token: 8e-8,
|
|
785
|
+
litellm_provider: "groq",
|
|
786
|
+
mode: "chat"
|
|
787
|
+
},
|
|
788
|
+
"llama-3.3-70b-versatile": {
|
|
789
|
+
input_cost_per_token: 59e-8,
|
|
790
|
+
output_cost_per_token: 79e-8,
|
|
791
|
+
litellm_provider: "groq",
|
|
792
|
+
mode: "chat"
|
|
793
|
+
},
|
|
794
|
+
"meta-llama/llama-4-scout-17b-16e-instruct": {
|
|
795
|
+
input_cost_per_token: 11e-8,
|
|
796
|
+
output_cost_per_token: 34e-8,
|
|
797
|
+
litellm_provider: "groq",
|
|
798
|
+
mode: "chat"
|
|
799
|
+
},
|
|
734
800
|
o1: {
|
|
735
801
|
input_cost_per_token: 15e-6,
|
|
736
802
|
output_cost_per_token: 6e-5,
|
|
@@ -779,6 +845,30 @@ var cost_map_default = {
|
|
|
779
845
|
litellm_provider: "openai",
|
|
780
846
|
mode: "chat"
|
|
781
847
|
},
|
|
848
|
+
"openai/gpt-oss-120b": {
|
|
849
|
+
input_cost_per_token: 15e-8,
|
|
850
|
+
output_cost_per_token: 6e-7,
|
|
851
|
+
litellm_provider: "groq",
|
|
852
|
+
mode: "chat"
|
|
853
|
+
},
|
|
854
|
+
"openai/gpt-oss-20b": {
|
|
855
|
+
input_cost_per_token: 75e-9,
|
|
856
|
+
output_cost_per_token: 3e-7,
|
|
857
|
+
litellm_provider: "groq",
|
|
858
|
+
mode: "chat"
|
|
859
|
+
},
|
|
860
|
+
"openai/gpt-oss-safeguard-20b": {
|
|
861
|
+
input_cost_per_token: 75e-9,
|
|
862
|
+
output_cost_per_token: 3e-7,
|
|
863
|
+
litellm_provider: "groq",
|
|
864
|
+
mode: "chat"
|
|
865
|
+
},
|
|
866
|
+
"qwen/qwen3-32b": {
|
|
867
|
+
input_cost_per_token: 29e-8,
|
|
868
|
+
output_cost_per_token: 59e-8,
|
|
869
|
+
litellm_provider: "groq",
|
|
870
|
+
mode: "chat"
|
|
871
|
+
},
|
|
782
872
|
"text-embedding-3-large": {
|
|
783
873
|
input_cost_per_token: 13e-8,
|
|
784
874
|
output_cost_per_token: 0,
|
|
@@ -807,39 +897,64 @@ var cost_map_default = {
|
|
|
807
897
|
|
|
808
898
|
// src/pricing.ts
|
|
809
899
|
var COST_MAP = cost_map_default;
|
|
810
|
-
|
|
900
|
+
var PROVIDER_PREFIXES = /* @__PURE__ */ new Set(["groq"]);
|
|
901
|
+
var DATE_SUFFIX = /-(?:\d{8}|\d{4}-\d{2}-\d{2})$/;
|
|
902
|
+
function candidateGroups(model) {
|
|
811
903
|
const m = model.trim();
|
|
812
|
-
const
|
|
813
|
-
|
|
904
|
+
const base = [m];
|
|
905
|
+
const firstSlash = m.indexOf("/");
|
|
906
|
+
if (firstSlash !== -1 && PROVIDER_PREFIXES.has(m.slice(0, firstSlash))) {
|
|
907
|
+
base.push(m.slice(firstSlash + 1));
|
|
908
|
+
}
|
|
909
|
+
const lastSlash = m.lastIndexOf("/");
|
|
910
|
+
if (lastSlash !== -1) {
|
|
911
|
+
base.push(m.slice(lastSlash + 1));
|
|
912
|
+
}
|
|
913
|
+
const exact = [];
|
|
914
|
+
for (const cand of base) {
|
|
915
|
+
if (cand && !exact.includes(cand)) exact.push(cand);
|
|
916
|
+
}
|
|
917
|
+
const stripped = [];
|
|
918
|
+
for (const cand of exact) {
|
|
919
|
+
const c = cand.replace(DATE_SUFFIX, "");
|
|
920
|
+
if (c && !exact.includes(c) && !stripped.includes(c)) stripped.push(c);
|
|
921
|
+
}
|
|
922
|
+
return [exact, stripped];
|
|
814
923
|
}
|
|
815
924
|
function bothFinite(a, b) {
|
|
816
925
|
return typeof a === "number" && typeof b === "number" && Number.isFinite(a) && Number.isFinite(b);
|
|
817
926
|
}
|
|
818
927
|
function resolvePrice(model, overrides) {
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
928
|
+
for (const cands of candidateGroups(model)) {
|
|
929
|
+
if (overrides) {
|
|
930
|
+
for (const cand of cands) {
|
|
931
|
+
const ov = Object.prototype.hasOwnProperty.call(overrides, cand) ? overrides[cand] : void 0;
|
|
932
|
+
if (ov !== void 0) {
|
|
933
|
+
if (bothFinite(ov.inputCostPerToken, ov.outputCostPerToken)) {
|
|
934
|
+
return {
|
|
935
|
+
inputCostPerToken: ov.inputCostPerToken,
|
|
936
|
+
outputCostPerToken: ov.outputCostPerToken,
|
|
937
|
+
source: "override"
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
829
942
|
}
|
|
830
|
-
|
|
943
|
+
}
|
|
944
|
+
for (const cand of cands) {
|
|
945
|
+
const entry = Object.prototype.hasOwnProperty.call(COST_MAP, cand) ? COST_MAP[cand] : void 0;
|
|
946
|
+
if (!entry) continue;
|
|
947
|
+
const input = entry.input_cost_per_token;
|
|
948
|
+
const output = entry.output_cost_per_token;
|
|
949
|
+
if (!bothFinite(input, output)) return null;
|
|
950
|
+
return {
|
|
951
|
+
inputCostPerToken: input,
|
|
952
|
+
outputCostPerToken: output,
|
|
953
|
+
source: "cost_map"
|
|
954
|
+
};
|
|
831
955
|
}
|
|
832
956
|
}
|
|
833
|
-
|
|
834
|
-
if (!entry) return null;
|
|
835
|
-
const input = entry.input_cost_per_token;
|
|
836
|
-
const output = entry.output_cost_per_token;
|
|
837
|
-
if (!bothFinite(input, output)) return null;
|
|
838
|
-
return {
|
|
839
|
-
inputCostPerToken: input,
|
|
840
|
-
outputCostPerToken: output,
|
|
841
|
-
source: "cost_map"
|
|
842
|
-
};
|
|
957
|
+
return null;
|
|
843
958
|
}
|
|
844
959
|
function priceTokens(priced, promptTokens, completionTokens) {
|
|
845
960
|
const p = Math.max(0, promptTokens);
|
|
@@ -858,9 +973,12 @@ var BudgetGuard = class {
|
|
|
858
973
|
spentUsd = 0;
|
|
859
974
|
priceOverrides;
|
|
860
975
|
failClosed;
|
|
976
|
+
nearLimitBps;
|
|
861
977
|
onBlock;
|
|
862
978
|
/** Cost of the most recent priced call, used to predict the next one. */
|
|
863
979
|
lastCost = 0;
|
|
980
|
+
/** USD held for in-flight calls (reserved, not yet settled). Counts toward the ceiling. */
|
|
981
|
+
reserved = 0;
|
|
864
982
|
/**
|
|
865
983
|
* @param limitUsd the spend ceiling, in USD. `0` blocks the very first call.
|
|
866
984
|
*/
|
|
@@ -870,35 +988,81 @@ var BudgetGuard = class {
|
|
|
870
988
|
`limitUsd must be a finite, non-negative number, got ${limitUsd}`
|
|
871
989
|
);
|
|
872
990
|
}
|
|
991
|
+
const nearLimitBps = options.nearLimitBps === void 0 ? 8e3 : options.nearLimitBps;
|
|
992
|
+
if (!Number.isInteger(nearLimitBps) || nearLimitBps < 0 || nearLimitBps > 1e4) {
|
|
993
|
+
throw new RangeError(
|
|
994
|
+
`nearLimitBps must be an integer in 0..10000, got ${nearLimitBps}`
|
|
995
|
+
);
|
|
996
|
+
}
|
|
873
997
|
this.limitUsd = limitUsd;
|
|
874
998
|
this.priceOverrides = options.priceOverrides;
|
|
875
999
|
this.failClosed = options.failClosed ?? true;
|
|
876
1000
|
this.onBlock = options.onBlock ?? defaultOnBlock;
|
|
1001
|
+
this.nearLimitBps = nearLimitBps;
|
|
877
1002
|
}
|
|
878
1003
|
/**
|
|
879
1004
|
* Throw {@link BudgetExceeded} if the next call would cross the ceiling.
|
|
880
1005
|
*
|
|
881
1006
|
* Call this immediately before each LLM request. The "next call" is estimated
|
|
882
1007
|
* from the last recorded call's cost (override with `estimatedNextCost`); the
|
|
883
|
-
* first call is always allowed unless the ceiling is already met.
|
|
884
|
-
*
|
|
1008
|
+
* first call is always allowed unless the ceiling is already met. In-flight
|
|
1009
|
+
* reservations count toward the total, so this stays correct alongside
|
|
1010
|
+
* {@link BudgetGuard.reserve}.
|
|
1011
|
+
*
|
|
1012
|
+
* Note: `check` is a non-binding peek. For parallel calls, use `reserve()` /
|
|
1013
|
+
* `settle()`, which hold the estimate across the await.
|
|
885
1014
|
*/
|
|
886
1015
|
check(estimatedNextCost) {
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
|
|
1016
|
+
const rawEstimate = estimatedNextCost === void 0 ? this.lastCost : estimatedNextCost;
|
|
1017
|
+
if (!Number.isFinite(rawEstimate)) {
|
|
1018
|
+
throw new RangeError(
|
|
1019
|
+
`estimatedNextCost must be a finite number, got ${rawEstimate}`
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
const estimate = Math.max(0, rawEstimate);
|
|
1023
|
+
const committed = this.spentUsd + this.reserved;
|
|
1024
|
+
if (committed > this.limitUsd - EPS || committed + estimate > this.limitUsd + EPS) {
|
|
890
1025
|
this.onBlock(this.spentUsd, this.limitUsd);
|
|
891
1026
|
throw new BudgetExceeded(this.spentUsd, this.limitUsd);
|
|
892
1027
|
}
|
|
893
1028
|
}
|
|
894
1029
|
/**
|
|
895
|
-
*
|
|
1030
|
+
* Atomically check the ceiling AND hold the estimated cost in flight.
|
|
896
1031
|
*
|
|
897
|
-
*
|
|
898
|
-
*
|
|
899
|
-
*
|
|
1032
|
+
* The concurrency-safe enforcement path: call before the request and hold the
|
|
1033
|
+
* returned reservation across the await, so parallel callers can't all clear
|
|
1034
|
+
* the same stale total. Throws {@link BudgetExceeded} (without reserving) if
|
|
1035
|
+
* the reservation would cross the ceiling. Returns the reservation handle to
|
|
1036
|
+
* pass to {@link BudgetGuard.settle} (or {@link BudgetGuard.release} on error).
|
|
1037
|
+
* `estimatedCost` defaults to the last call's cost.
|
|
900
1038
|
*/
|
|
901
|
-
|
|
1039
|
+
reserve(estimatedCost) {
|
|
1040
|
+
const rawEstimate = estimatedCost === void 0 ? this.lastCost : estimatedCost;
|
|
1041
|
+
if (!Number.isFinite(rawEstimate)) {
|
|
1042
|
+
throw new RangeError(
|
|
1043
|
+
`estimatedCost must be a finite number, got ${rawEstimate}`
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
const estimate = Math.max(0, rawEstimate);
|
|
1047
|
+
const committed = this.spentUsd + this.reserved;
|
|
1048
|
+
if (committed > this.limitUsd - EPS || committed + estimate > this.limitUsd + EPS) {
|
|
1049
|
+
this.onBlock(this.spentUsd, this.limitUsd);
|
|
1050
|
+
throw new BudgetExceeded(this.spentUsd, this.limitUsd);
|
|
1051
|
+
}
|
|
1052
|
+
this.reserved += estimate;
|
|
1053
|
+
return estimate;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Release a reservation and record the actual cost. `record` is `settle` with
|
|
1057
|
+
* no reservation. Returns the USD cost of this call; unpriceable-model handling
|
|
1058
|
+
* matches {@link BudgetGuard.record}, and any held reservation is released even
|
|
1059
|
+
* on the warn-and-skip path.
|
|
1060
|
+
*/
|
|
1061
|
+
settle(model, promptTokens, completionTokens, options = {}) {
|
|
1062
|
+
const reserved = options.reserved ?? 0;
|
|
1063
|
+
if (!Number.isFinite(reserved) || reserved < 0) {
|
|
1064
|
+
throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
|
|
1065
|
+
}
|
|
902
1066
|
let overrides = this.priceOverrides;
|
|
903
1067
|
if (options.price !== void 0) {
|
|
904
1068
|
overrides = { ...overrides ?? {}, [model]: options.price };
|
|
@@ -908,12 +1072,22 @@ var BudgetGuard = class {
|
|
|
908
1072
|
console.warn(
|
|
909
1073
|
`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
1074
|
);
|
|
1075
|
+
this.release(reserved);
|
|
911
1076
|
if (this.failClosed) {
|
|
912
1077
|
throw new UnpriceableModelError(model);
|
|
913
1078
|
}
|
|
914
1079
|
return 0;
|
|
915
1080
|
}
|
|
916
|
-
|
|
1081
|
+
let cost;
|
|
1082
|
+
try {
|
|
1083
|
+
cost = priceTokens(priced, promptTokens, completionTokens);
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
this.release(reserved);
|
|
1086
|
+
throw err;
|
|
1087
|
+
}
|
|
1088
|
+
if (reserved) {
|
|
1089
|
+
this.reserved = Math.max(0, this.reserved - reserved);
|
|
1090
|
+
}
|
|
917
1091
|
this.spentUsd += cost;
|
|
918
1092
|
if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
|
|
919
1093
|
this.spentUsd = this.limitUsd;
|
|
@@ -921,9 +1095,55 @@ var BudgetGuard = class {
|
|
|
921
1095
|
this.lastCost = cost;
|
|
922
1096
|
return cost;
|
|
923
1097
|
}
|
|
924
|
-
/**
|
|
1098
|
+
/**
|
|
1099
|
+
* Price one response's tokens offline and add the cost to the total.
|
|
1100
|
+
*
|
|
1101
|
+
* Returns the USD cost of this call. If the model is unpriceable and no `price`
|
|
1102
|
+
* is given, behaviour depends on `failClosed`: warn + throw (default), or
|
|
1103
|
+
* warn + skip accrual.
|
|
1104
|
+
*/
|
|
1105
|
+
record(model, promptTokens, completionTokens, options = {}) {
|
|
1106
|
+
return this.settle(model, promptTokens, completionTokens, {
|
|
1107
|
+
reserved: 0,
|
|
1108
|
+
price: options.price
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Drop an in-flight reservation without recording spend (e.g. the call failed
|
|
1113
|
+
* before producing usage). Safe to call with `0`.
|
|
1114
|
+
*/
|
|
1115
|
+
release(reserved) {
|
|
1116
|
+
if (!Number.isFinite(reserved) || reserved < 0) {
|
|
1117
|
+
throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
|
|
1118
|
+
}
|
|
1119
|
+
if (!reserved) return;
|
|
1120
|
+
this.reserved = Math.max(0, this.reserved - reserved);
|
|
1121
|
+
}
|
|
1122
|
+
/** USD left before the ceiling, net of in-flight reservations (never negative). */
|
|
925
1123
|
get remainingUsd() {
|
|
926
|
-
return Math.max(0, this.limitUsd - this.spentUsd);
|
|
1124
|
+
return Math.max(0, this.limitUsd - this.spentUsd - this.reserved);
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Context-aware spend advisory for this budget — see {@link BudgetAdvisory}.
|
|
1128
|
+
*
|
|
1129
|
+
* `nearLimit` flips once utilization reaches `nearLimitBps` (default 80%), so an
|
|
1130
|
+
* agent can taper *before* the hard-stop. Advisory only: read it to adapt;
|
|
1131
|
+
* {@link BudgetGuard.check} is what enforces the ceiling.
|
|
1132
|
+
*/
|
|
1133
|
+
advisory() {
|
|
1134
|
+
const usedBps = this.limitUsd <= 0 ? 1e4 : Math.max(0, Math.min(1e4, Math.floor(this.spentUsd / this.limitUsd * 1e4 + 1e-9)));
|
|
1135
|
+
return {
|
|
1136
|
+
nearLimit: usedBps >= this.nearLimitBps,
|
|
1137
|
+
usedBps,
|
|
1138
|
+
// Settled budget: limit minus accrued spend, deliberately NOT net of
|
|
1139
|
+
// in-flight reservations. Unlike the remainingUsd getter (which subtracts
|
|
1140
|
+
// `reserved`), the advisory is a soft utilization signal about money already
|
|
1141
|
+
// spent, while the getter reports what a new call can still claim.
|
|
1142
|
+
remainingUsd: Math.max(0, this.limitUsd - this.spentUsd),
|
|
1143
|
+
limitUsd: this.limitUsd,
|
|
1144
|
+
spentUsd: this.spentUsd,
|
|
1145
|
+
scope: "local"
|
|
1146
|
+
};
|
|
927
1147
|
}
|
|
928
1148
|
};
|
|
929
1149
|
function defaultOnBlock(spentUsd, limitUsd) {
|
|
@@ -935,33 +1155,88 @@ function defaultOnBlock(spentUsd, limitUsd) {
|
|
|
935
1155
|
}
|
|
936
1156
|
|
|
937
1157
|
// src/middleware.ts
|
|
1158
|
+
function usageTokens(modelId, usage) {
|
|
1159
|
+
const u = usage;
|
|
1160
|
+
const promptTokens = u?.promptTokens ?? u?.inputTokens;
|
|
1161
|
+
const completionTokens = u?.completionTokens ?? u?.outputTokens;
|
|
1162
|
+
if (typeof promptTokens !== "number" || typeof completionTokens !== "number") {
|
|
1163
|
+
throw new Error(
|
|
1164
|
+
`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.`
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
return { promptTokens, completionTokens };
|
|
1168
|
+
}
|
|
938
1169
|
function budgetGuardMiddleware(guard) {
|
|
939
1170
|
return {
|
|
940
1171
|
async wrapGenerate({ doGenerate, model }) {
|
|
941
|
-
guard.
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1172
|
+
const reserved = guard.reserve();
|
|
1173
|
+
let result;
|
|
1174
|
+
try {
|
|
1175
|
+
result = await doGenerate();
|
|
1176
|
+
} catch (err) {
|
|
1177
|
+
guard.release(reserved);
|
|
1178
|
+
throw err;
|
|
1179
|
+
}
|
|
1180
|
+
let handled = false;
|
|
1181
|
+
try {
|
|
1182
|
+
const { promptTokens, completionTokens } = usageTokens(
|
|
1183
|
+
model.modelId,
|
|
1184
|
+
result?.usage
|
|
1185
|
+
);
|
|
1186
|
+
handled = true;
|
|
1187
|
+
guard.settle(model.modelId, promptTokens, completionTokens, { reserved });
|
|
1188
|
+
return result;
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
if (!handled) guard.release(reserved);
|
|
1191
|
+
throw err;
|
|
1192
|
+
}
|
|
949
1193
|
},
|
|
950
1194
|
async wrapStream({ doStream, model }) {
|
|
951
|
-
guard.
|
|
952
|
-
|
|
1195
|
+
const reserved = guard.reserve();
|
|
1196
|
+
let streamResult;
|
|
1197
|
+
try {
|
|
1198
|
+
streamResult = await doStream();
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
guard.release(reserved);
|
|
1201
|
+
throw err;
|
|
1202
|
+
}
|
|
1203
|
+
const { stream, ...rest } = streamResult;
|
|
1204
|
+
let handled = false;
|
|
953
1205
|
const guarded = stream.pipeThrough(
|
|
954
1206
|
new TransformStream({
|
|
955
1207
|
transform(chunk, controller) {
|
|
956
|
-
if (chunk
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1208
|
+
if (chunk?.type === "finish" && !handled) {
|
|
1209
|
+
try {
|
|
1210
|
+
const { promptTokens, completionTokens } = usageTokens(
|
|
1211
|
+
model.modelId,
|
|
1212
|
+
chunk.usage
|
|
1213
|
+
);
|
|
1214
|
+
handled = true;
|
|
1215
|
+
guard.settle(model.modelId, promptTokens, completionTokens, { reserved });
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
if (!handled) {
|
|
1218
|
+
handled = true;
|
|
1219
|
+
guard.release(reserved);
|
|
1220
|
+
}
|
|
1221
|
+
throw err;
|
|
1222
|
+
}
|
|
962
1223
|
}
|
|
963
1224
|
controller.enqueue(chunk);
|
|
1225
|
+
},
|
|
1226
|
+
flush() {
|
|
1227
|
+
if (!handled) {
|
|
1228
|
+
handled = true;
|
|
1229
|
+
guard.release(reserved);
|
|
1230
|
+
}
|
|
1231
|
+
},
|
|
1232
|
+
cancel() {
|
|
1233
|
+
if (!handled) {
|
|
1234
|
+
handled = true;
|
|
1235
|
+
guard.release(reserved);
|
|
1236
|
+
}
|
|
964
1237
|
}
|
|
1238
|
+
// `cancel` is valid per the Streams spec and supported in Node 18+, but
|
|
1239
|
+
// TS's Transformer lib type lags and omits it — cast to keep the type check.
|
|
965
1240
|
})
|
|
966
1241
|
);
|
|
967
1242
|
return { stream: guarded, ...rest };
|