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/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { LanguageModelV1Middleware } from 'ai';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Offline token pricing from a vendored LiteLLM cost map.
|
|
5
3
|
*
|
|
@@ -51,6 +49,16 @@ declare namespace pricing {
|
|
|
51
49
|
* 2. Call {@link BudgetGuard.record} AFTER every response, with the token usage.
|
|
52
50
|
* It prices the tokens offline and accrues the USD into a running total.
|
|
53
51
|
*
|
|
52
|
+
* **Concurrency.** `check()` then `record()` is a check-then-act with an `await`
|
|
53
|
+
* in between. Fire several model calls at once (e.g. `Promise.all`) and they all
|
|
54
|
+
* `check()` against the same under-limit total before any `record()` lands, so
|
|
55
|
+
* the ceiling is blown (see issue #18). {@link BudgetGuard.reserve} /
|
|
56
|
+
* {@link BudgetGuard.settle} close that gap: `reserve()` holds the estimated cost
|
|
57
|
+
* in flight (synchronously, before the await), so parallel callers each take
|
|
58
|
+
* their own slice of the ceiling. JS is single-threaded, so an in-flight counter
|
|
59
|
+
* is enough — no lock needed. The middleware uses it; `check`/`record` are
|
|
60
|
+
* unchanged.
|
|
61
|
+
*
|
|
54
62
|
* This is a faithful port of `src/floe_guard/guard.py` — same prediction logic,
|
|
55
63
|
* same epsilon handling, same fail-closed default.
|
|
56
64
|
*/
|
|
@@ -70,15 +78,47 @@ interface BudgetGuardOptions {
|
|
|
70
78
|
* `BUDGET EXCEEDED — call blocked` banner to stderr.
|
|
71
79
|
*/
|
|
72
80
|
onBlock?: (spentUsd: number, limitUsd: number) => void;
|
|
81
|
+
/**
|
|
82
|
+
* Utilization (basis points, 0..10000) at which {@link BudgetGuard.advisory}
|
|
83
|
+
* flags `nearLimit` so an agent can taper before the hard-stop. Default 8000.
|
|
84
|
+
*/
|
|
85
|
+
nearLimitBps?: number;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A context-aware spend signal for the single local budget.
|
|
89
|
+
*
|
|
90
|
+
* Mirrors the core fields of hosted Floe's `X-Floe-Budget-Advisory` header, so
|
|
91
|
+
* agent logic that reads it (taper as you approach the cap, stop at it) ports
|
|
92
|
+
* unchanged to the hosted path. Hosted adds what a local, single-budget guard
|
|
93
|
+
* cannot know: which of several caps is tightest (`scope` across
|
|
94
|
+
* `credit_line | session | task | api | vendor`), cross-vendor reasoning,
|
|
95
|
+
* server-truth balances, and rolling-window reset timing.
|
|
96
|
+
*
|
|
97
|
+
* This is a **soft** signal — the model may ignore it. The hard-stop
|
|
98
|
+
* ({@link BudgetGuard.check}) is what enforces the ceiling; the advisory is
|
|
99
|
+
* upside (let the agent finish on budget rather than be cut off).
|
|
100
|
+
*/
|
|
101
|
+
interface BudgetAdvisory {
|
|
102
|
+
nearLimit: boolean;
|
|
103
|
+
/** Utilization in basis points, 0..10000 (8500 = 85%). */
|
|
104
|
+
usedBps: number;
|
|
105
|
+
remainingUsd: number;
|
|
106
|
+
limitUsd: number;
|
|
107
|
+
spentUsd: number;
|
|
108
|
+
/** Hosted reports the tightest cap across all scopes; local is always "local". */
|
|
109
|
+
scope: "local";
|
|
73
110
|
}
|
|
74
111
|
declare class BudgetGuard {
|
|
75
112
|
readonly limitUsd: number;
|
|
76
113
|
spentUsd: number;
|
|
77
114
|
priceOverrides?: Record<string, ManualPrice>;
|
|
78
115
|
failClosed: boolean;
|
|
116
|
+
nearLimitBps: number;
|
|
79
117
|
private readonly onBlock;
|
|
80
118
|
/** Cost of the most recent priced call, used to predict the next one. */
|
|
81
119
|
private lastCost;
|
|
120
|
+
/** USD held for in-flight calls (reserved, not yet settled). Counts toward the ceiling. */
|
|
121
|
+
private reserved;
|
|
82
122
|
/**
|
|
83
123
|
* @param limitUsd the spend ceiling, in USD. `0` blocks the very first call.
|
|
84
124
|
*/
|
|
@@ -88,10 +128,35 @@ declare class BudgetGuard {
|
|
|
88
128
|
*
|
|
89
129
|
* Call this immediately before each LLM request. The "next call" is estimated
|
|
90
130
|
* from the last recorded call's cost (override with `estimatedNextCost`); the
|
|
91
|
-
* first call is always allowed unless the ceiling is already met.
|
|
92
|
-
*
|
|
131
|
+
* first call is always allowed unless the ceiling is already met. In-flight
|
|
132
|
+
* reservations count toward the total, so this stays correct alongside
|
|
133
|
+
* {@link BudgetGuard.reserve}.
|
|
134
|
+
*
|
|
135
|
+
* Note: `check` is a non-binding peek. For parallel calls, use `reserve()` /
|
|
136
|
+
* `settle()`, which hold the estimate across the await.
|
|
93
137
|
*/
|
|
94
138
|
check(estimatedNextCost?: number): void;
|
|
139
|
+
/**
|
|
140
|
+
* Atomically check the ceiling AND hold the estimated cost in flight.
|
|
141
|
+
*
|
|
142
|
+
* The concurrency-safe enforcement path: call before the request and hold the
|
|
143
|
+
* returned reservation across the await, so parallel callers can't all clear
|
|
144
|
+
* the same stale total. Throws {@link BudgetExceeded} (without reserving) if
|
|
145
|
+
* the reservation would cross the ceiling. Returns the reservation handle to
|
|
146
|
+
* pass to {@link BudgetGuard.settle} (or {@link BudgetGuard.release} on error).
|
|
147
|
+
* `estimatedCost` defaults to the last call's cost.
|
|
148
|
+
*/
|
|
149
|
+
reserve(estimatedCost?: number): number;
|
|
150
|
+
/**
|
|
151
|
+
* Release a reservation and record the actual cost. `record` is `settle` with
|
|
152
|
+
* no reservation. Returns the USD cost of this call; unpriceable-model handling
|
|
153
|
+
* matches {@link BudgetGuard.record}, and any held reservation is released even
|
|
154
|
+
* on the warn-and-skip path.
|
|
155
|
+
*/
|
|
156
|
+
settle(model: string, promptTokens: number, completionTokens: number, options?: {
|
|
157
|
+
reserved?: number;
|
|
158
|
+
price?: ManualPrice;
|
|
159
|
+
}): number;
|
|
95
160
|
/**
|
|
96
161
|
* Price one response's tokens offline and add the cost to the total.
|
|
97
162
|
*
|
|
@@ -102,8 +167,21 @@ declare class BudgetGuard {
|
|
|
102
167
|
record(model: string, promptTokens: number, completionTokens: number, options?: {
|
|
103
168
|
price?: ManualPrice;
|
|
104
169
|
}): number;
|
|
105
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* Drop an in-flight reservation without recording spend (e.g. the call failed
|
|
172
|
+
* before producing usage). Safe to call with `0`.
|
|
173
|
+
*/
|
|
174
|
+
release(reserved: number): void;
|
|
175
|
+
/** USD left before the ceiling, net of in-flight reservations (never negative). */
|
|
106
176
|
get remainingUsd(): number;
|
|
177
|
+
/**
|
|
178
|
+
* Context-aware spend advisory for this budget — see {@link BudgetAdvisory}.
|
|
179
|
+
*
|
|
180
|
+
* `nearLimit` flips once utilization reaches `nearLimitBps` (default 80%), so an
|
|
181
|
+
* agent can taper *before* the hard-stop. Advisory only: read it to adapt;
|
|
182
|
+
* {@link BudgetGuard.check} is what enforces the ceiling.
|
|
183
|
+
*/
|
|
184
|
+
advisory(): BudgetAdvisory;
|
|
107
185
|
}
|
|
108
186
|
|
|
109
187
|
/**
|
|
@@ -148,18 +226,55 @@ declare class UnpriceableModelError extends FloeGuardError {
|
|
|
148
226
|
* This is the TypeScript counterpart to the Python framework adapters. The AI SDK
|
|
149
227
|
* is TypeScript-only, so it ships as its own npm package.
|
|
150
228
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
229
|
+
* Works with BOTH `ai@4` (`LanguageModelV1Middleware`) and `ai@5`
|
|
230
|
+
* (`LanguageModelV2Middleware`). The two majors renamed the middleware type and
|
|
231
|
+
* the usage fields (`promptTokens`/`completionTokens` → `inputTokens`/
|
|
232
|
+
* `outputTokens`), so this module deliberately imports nothing from `ai`: the
|
|
233
|
+
* middleware is typed structurally against the surface both majors share, and
|
|
234
|
+
* usage is read from whichever field pair the installed SDK reports.
|
|
235
|
+
*
|
|
236
|
+
* - `wrapGenerate({ doGenerate, model })` — we `reserve()` (throws to hard-stop)
|
|
237
|
+
* BEFORE calling `doGenerate()`, hold the reservation across the await, then
|
|
238
|
+
* `settle()` from `result.usage`.
|
|
239
|
+
* - `wrapStream({ doStream, model })` — we `reserve()` BEFORE `doStream()`, then
|
|
240
|
+
* `settle()` from the `finish` part as the stream drains.
|
|
241
|
+
*
|
|
242
|
+
* Reserving before the await is what makes parallel calls (`Promise.all` over
|
|
243
|
+
* several generations) honour the ceiling: each holds its slice instead of all
|
|
244
|
+
* reading the same stale total (issue #18). The reservation is released if the
|
|
245
|
+
* call throws, or if a stream ends without reporting usage.
|
|
156
246
|
*
|
|
157
247
|
* The model id used for pricing comes from `model.modelId`.
|
|
158
248
|
*/
|
|
159
249
|
|
|
160
250
|
/**
|
|
161
|
-
*
|
|
251
|
+
* The middleware call surface shared by `ai@4` and `ai@5`. Both majors invoke
|
|
252
|
+
* `wrapGenerate`/`wrapStream` with an options object carrying these members
|
|
253
|
+
* (plus richer `model` fields we don't read). `doGenerate`/`doStream` are
|
|
254
|
+
* declared optional so every 4.x/5.x minor's options type stays assignable —
|
|
255
|
+
* the SDK always provides the one each hook actually calls.
|
|
256
|
+
*/
|
|
257
|
+
interface MiddlewareCallOptions {
|
|
258
|
+
doGenerate?: () => PromiseLike<any>;
|
|
259
|
+
doStream?: () => PromiseLike<any>;
|
|
260
|
+
model: {
|
|
261
|
+
modelId: string;
|
|
262
|
+
};
|
|
263
|
+
params?: unknown;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Structural stand-in for `LanguageModelV1Middleware` (ai@4) and
|
|
267
|
+
* `LanguageModelV2Middleware` (ai@5) — assignable to the `middleware` option of
|
|
268
|
+
* `wrapLanguageModel` on either major.
|
|
269
|
+
*/
|
|
270
|
+
interface BudgetGuardMiddleware {
|
|
271
|
+
wrapGenerate: (options: MiddlewareCallOptions) => Promise<any>;
|
|
272
|
+
wrapStream: (options: MiddlewareCallOptions) => Promise<any>;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Build a budget-guard middleware that hard-stops the model before a call
|
|
162
276
|
* crosses the guard's USD ceiling, and records priced token usage after.
|
|
277
|
+
* Compatible with `wrapLanguageModel` from both `ai@4` and `ai@5`.
|
|
163
278
|
*
|
|
164
279
|
* @example
|
|
165
280
|
* import { wrapLanguageModel } from "ai";
|
|
@@ -172,6 +287,6 @@ declare class UnpriceableModelError extends FloeGuardError {
|
|
|
172
287
|
* middleware: budgetGuardMiddleware(guard),
|
|
173
288
|
* });
|
|
174
289
|
*/
|
|
175
|
-
declare function budgetGuardMiddleware(guard: BudgetGuard):
|
|
290
|
+
declare function budgetGuardMiddleware(guard: BudgetGuard): BudgetGuardMiddleware;
|
|
176
291
|
|
|
177
|
-
export { BudgetExceeded, BudgetGuard, type BudgetGuardOptions, FloeGuardError, type ManualPrice, type PricedModel, UnpriceableModelError, budgetGuardMiddleware, priceTokens, pricing, resolvePrice };
|
|
292
|
+
export { type BudgetAdvisory, BudgetExceeded, BudgetGuard, type BudgetGuardMiddleware, type BudgetGuardOptions, FloeGuardError, type ManualPrice, type PricedModel, UnpriceableModelError, budgetGuardMiddleware, priceTokens, pricing, resolvePrice };
|
package/dist/index.js
CHANGED
|
@@ -80,6 +80,12 @@ var cost_map_default = {
|
|
|
80
80
|
litellm_provider: "anthropic",
|
|
81
81
|
mode: "chat"
|
|
82
82
|
},
|
|
83
|
+
"claude-fable-5": {
|
|
84
|
+
input_cost_per_token: 1e-5,
|
|
85
|
+
output_cost_per_token: 5e-5,
|
|
86
|
+
litellm_provider: "anthropic",
|
|
87
|
+
mode: "chat"
|
|
88
|
+
},
|
|
83
89
|
"claude-haiku-4-5": {
|
|
84
90
|
input_cost_per_token: 1e-6,
|
|
85
91
|
output_cost_per_token: 5e-6,
|
|
@@ -704,6 +710,24 @@ var cost_map_default = {
|
|
|
704
710
|
litellm_provider: "openai",
|
|
705
711
|
mode: "chat"
|
|
706
712
|
},
|
|
713
|
+
"llama-3.1-8b-instant": {
|
|
714
|
+
input_cost_per_token: 5e-8,
|
|
715
|
+
output_cost_per_token: 8e-8,
|
|
716
|
+
litellm_provider: "groq",
|
|
717
|
+
mode: "chat"
|
|
718
|
+
},
|
|
719
|
+
"llama-3.3-70b-versatile": {
|
|
720
|
+
input_cost_per_token: 59e-8,
|
|
721
|
+
output_cost_per_token: 79e-8,
|
|
722
|
+
litellm_provider: "groq",
|
|
723
|
+
mode: "chat"
|
|
724
|
+
},
|
|
725
|
+
"meta-llama/llama-4-scout-17b-16e-instruct": {
|
|
726
|
+
input_cost_per_token: 11e-8,
|
|
727
|
+
output_cost_per_token: 34e-8,
|
|
728
|
+
litellm_provider: "groq",
|
|
729
|
+
mode: "chat"
|
|
730
|
+
},
|
|
707
731
|
o1: {
|
|
708
732
|
input_cost_per_token: 15e-6,
|
|
709
733
|
output_cost_per_token: 6e-5,
|
|
@@ -752,6 +776,12 @@ var cost_map_default = {
|
|
|
752
776
|
litellm_provider: "openai",
|
|
753
777
|
mode: "chat"
|
|
754
778
|
},
|
|
779
|
+
"qwen/qwen3-32b": {
|
|
780
|
+
input_cost_per_token: 29e-8,
|
|
781
|
+
output_cost_per_token: 59e-8,
|
|
782
|
+
litellm_provider: "groq",
|
|
783
|
+
mode: "chat"
|
|
784
|
+
},
|
|
755
785
|
"text-embedding-3-large": {
|
|
756
786
|
input_cost_per_token: 13e-8,
|
|
757
787
|
output_cost_per_token: 0,
|
|
@@ -831,9 +861,12 @@ var BudgetGuard = class {
|
|
|
831
861
|
spentUsd = 0;
|
|
832
862
|
priceOverrides;
|
|
833
863
|
failClosed;
|
|
864
|
+
nearLimitBps;
|
|
834
865
|
onBlock;
|
|
835
866
|
/** Cost of the most recent priced call, used to predict the next one. */
|
|
836
867
|
lastCost = 0;
|
|
868
|
+
/** USD held for in-flight calls (reserved, not yet settled). Counts toward the ceiling. */
|
|
869
|
+
reserved = 0;
|
|
837
870
|
/**
|
|
838
871
|
* @param limitUsd the spend ceiling, in USD. `0` blocks the very first call.
|
|
839
872
|
*/
|
|
@@ -843,35 +876,81 @@ var BudgetGuard = class {
|
|
|
843
876
|
`limitUsd must be a finite, non-negative number, got ${limitUsd}`
|
|
844
877
|
);
|
|
845
878
|
}
|
|
879
|
+
const nearLimitBps = options.nearLimitBps === void 0 ? 8e3 : options.nearLimitBps;
|
|
880
|
+
if (!Number.isInteger(nearLimitBps) || nearLimitBps < 0 || nearLimitBps > 1e4) {
|
|
881
|
+
throw new RangeError(
|
|
882
|
+
`nearLimitBps must be an integer in 0..10000, got ${nearLimitBps}`
|
|
883
|
+
);
|
|
884
|
+
}
|
|
846
885
|
this.limitUsd = limitUsd;
|
|
847
886
|
this.priceOverrides = options.priceOverrides;
|
|
848
887
|
this.failClosed = options.failClosed ?? true;
|
|
849
888
|
this.onBlock = options.onBlock ?? defaultOnBlock;
|
|
889
|
+
this.nearLimitBps = nearLimitBps;
|
|
850
890
|
}
|
|
851
891
|
/**
|
|
852
892
|
* Throw {@link BudgetExceeded} if the next call would cross the ceiling.
|
|
853
893
|
*
|
|
854
894
|
* Call this immediately before each LLM request. The "next call" is estimated
|
|
855
895
|
* from the last recorded call's cost (override with `estimatedNextCost`); the
|
|
856
|
-
* first call is always allowed unless the ceiling is already met.
|
|
857
|
-
*
|
|
896
|
+
* first call is always allowed unless the ceiling is already met. In-flight
|
|
897
|
+
* reservations count toward the total, so this stays correct alongside
|
|
898
|
+
* {@link BudgetGuard.reserve}.
|
|
899
|
+
*
|
|
900
|
+
* Note: `check` is a non-binding peek. For parallel calls, use `reserve()` /
|
|
901
|
+
* `settle()`, which hold the estimate across the await.
|
|
858
902
|
*/
|
|
859
903
|
check(estimatedNextCost) {
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
|
|
904
|
+
const rawEstimate = estimatedNextCost === void 0 ? this.lastCost : estimatedNextCost;
|
|
905
|
+
if (!Number.isFinite(rawEstimate)) {
|
|
906
|
+
throw new RangeError(
|
|
907
|
+
`estimatedNextCost must be a finite number, got ${rawEstimate}`
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
const estimate = Math.max(0, rawEstimate);
|
|
911
|
+
const committed = this.spentUsd + this.reserved;
|
|
912
|
+
if (committed > this.limitUsd - EPS || committed + estimate > this.limitUsd + EPS) {
|
|
863
913
|
this.onBlock(this.spentUsd, this.limitUsd);
|
|
864
914
|
throw new BudgetExceeded(this.spentUsd, this.limitUsd);
|
|
865
915
|
}
|
|
866
916
|
}
|
|
867
917
|
/**
|
|
868
|
-
*
|
|
918
|
+
* Atomically check the ceiling AND hold the estimated cost in flight.
|
|
869
919
|
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
*
|
|
920
|
+
* The concurrency-safe enforcement path: call before the request and hold the
|
|
921
|
+
* returned reservation across the await, so parallel callers can't all clear
|
|
922
|
+
* the same stale total. Throws {@link BudgetExceeded} (without reserving) if
|
|
923
|
+
* the reservation would cross the ceiling. Returns the reservation handle to
|
|
924
|
+
* pass to {@link BudgetGuard.settle} (or {@link BudgetGuard.release} on error).
|
|
925
|
+
* `estimatedCost` defaults to the last call's cost.
|
|
873
926
|
*/
|
|
874
|
-
|
|
927
|
+
reserve(estimatedCost) {
|
|
928
|
+
const rawEstimate = estimatedCost === void 0 ? this.lastCost : estimatedCost;
|
|
929
|
+
if (!Number.isFinite(rawEstimate)) {
|
|
930
|
+
throw new RangeError(
|
|
931
|
+
`estimatedCost must be a finite number, got ${rawEstimate}`
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
const estimate = Math.max(0, rawEstimate);
|
|
935
|
+
const committed = this.spentUsd + this.reserved;
|
|
936
|
+
if (committed > this.limitUsd - EPS || committed + estimate > this.limitUsd + EPS) {
|
|
937
|
+
this.onBlock(this.spentUsd, this.limitUsd);
|
|
938
|
+
throw new BudgetExceeded(this.spentUsd, this.limitUsd);
|
|
939
|
+
}
|
|
940
|
+
this.reserved += estimate;
|
|
941
|
+
return estimate;
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Release a reservation and record the actual cost. `record` is `settle` with
|
|
945
|
+
* no reservation. Returns the USD cost of this call; unpriceable-model handling
|
|
946
|
+
* matches {@link BudgetGuard.record}, and any held reservation is released even
|
|
947
|
+
* on the warn-and-skip path.
|
|
948
|
+
*/
|
|
949
|
+
settle(model, promptTokens, completionTokens, options = {}) {
|
|
950
|
+
const reserved = options.reserved ?? 0;
|
|
951
|
+
if (!Number.isFinite(reserved) || reserved < 0) {
|
|
952
|
+
throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
|
|
953
|
+
}
|
|
875
954
|
let overrides = this.priceOverrides;
|
|
876
955
|
if (options.price !== void 0) {
|
|
877
956
|
overrides = { ...overrides ?? {}, [model]: options.price };
|
|
@@ -881,12 +960,22 @@ var BudgetGuard = class {
|
|
|
881
960
|
console.warn(
|
|
882
961
|
`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.`
|
|
883
962
|
);
|
|
963
|
+
this.release(reserved);
|
|
884
964
|
if (this.failClosed) {
|
|
885
965
|
throw new UnpriceableModelError(model);
|
|
886
966
|
}
|
|
887
967
|
return 0;
|
|
888
968
|
}
|
|
889
|
-
|
|
969
|
+
let cost;
|
|
970
|
+
try {
|
|
971
|
+
cost = priceTokens(priced, promptTokens, completionTokens);
|
|
972
|
+
} catch (err) {
|
|
973
|
+
this.release(reserved);
|
|
974
|
+
throw err;
|
|
975
|
+
}
|
|
976
|
+
if (reserved) {
|
|
977
|
+
this.reserved = Math.max(0, this.reserved - reserved);
|
|
978
|
+
}
|
|
890
979
|
this.spentUsd += cost;
|
|
891
980
|
if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
|
|
892
981
|
this.spentUsd = this.limitUsd;
|
|
@@ -894,9 +983,55 @@ var BudgetGuard = class {
|
|
|
894
983
|
this.lastCost = cost;
|
|
895
984
|
return cost;
|
|
896
985
|
}
|
|
897
|
-
/**
|
|
986
|
+
/**
|
|
987
|
+
* Price one response's tokens offline and add the cost to the total.
|
|
988
|
+
*
|
|
989
|
+
* Returns the USD cost of this call. If the model is unpriceable and no `price`
|
|
990
|
+
* is given, behaviour depends on `failClosed`: warn + throw (default), or
|
|
991
|
+
* warn + skip accrual.
|
|
992
|
+
*/
|
|
993
|
+
record(model, promptTokens, completionTokens, options = {}) {
|
|
994
|
+
return this.settle(model, promptTokens, completionTokens, {
|
|
995
|
+
reserved: 0,
|
|
996
|
+
price: options.price
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Drop an in-flight reservation without recording spend (e.g. the call failed
|
|
1001
|
+
* before producing usage). Safe to call with `0`.
|
|
1002
|
+
*/
|
|
1003
|
+
release(reserved) {
|
|
1004
|
+
if (!Number.isFinite(reserved) || reserved < 0) {
|
|
1005
|
+
throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
|
|
1006
|
+
}
|
|
1007
|
+
if (!reserved) return;
|
|
1008
|
+
this.reserved = Math.max(0, this.reserved - reserved);
|
|
1009
|
+
}
|
|
1010
|
+
/** USD left before the ceiling, net of in-flight reservations (never negative). */
|
|
898
1011
|
get remainingUsd() {
|
|
899
|
-
return Math.max(0, this.limitUsd - this.spentUsd);
|
|
1012
|
+
return Math.max(0, this.limitUsd - this.spentUsd - this.reserved);
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Context-aware spend advisory for this budget — see {@link BudgetAdvisory}.
|
|
1016
|
+
*
|
|
1017
|
+
* `nearLimit` flips once utilization reaches `nearLimitBps` (default 80%), so an
|
|
1018
|
+
* agent can taper *before* the hard-stop. Advisory only: read it to adapt;
|
|
1019
|
+
* {@link BudgetGuard.check} is what enforces the ceiling.
|
|
1020
|
+
*/
|
|
1021
|
+
advisory() {
|
|
1022
|
+
const usedBps = this.limitUsd <= 0 ? 1e4 : Math.max(0, Math.min(1e4, Math.floor(this.spentUsd / this.limitUsd * 1e4 + 1e-9)));
|
|
1023
|
+
return {
|
|
1024
|
+
nearLimit: usedBps >= this.nearLimitBps,
|
|
1025
|
+
usedBps,
|
|
1026
|
+
// Settled budget: limit minus accrued spend, deliberately NOT net of
|
|
1027
|
+
// in-flight reservations. Unlike the remainingUsd getter (which subtracts
|
|
1028
|
+
// `reserved`), the advisory is a soft utilization signal about money already
|
|
1029
|
+
// spent, while the getter reports what a new call can still claim.
|
|
1030
|
+
remainingUsd: Math.max(0, this.limitUsd - this.spentUsd),
|
|
1031
|
+
limitUsd: this.limitUsd,
|
|
1032
|
+
spentUsd: this.spentUsd,
|
|
1033
|
+
scope: "local"
|
|
1034
|
+
};
|
|
900
1035
|
}
|
|
901
1036
|
};
|
|
902
1037
|
function defaultOnBlock(spentUsd, limitUsd) {
|
|
@@ -908,33 +1043,88 @@ function defaultOnBlock(spentUsd, limitUsd) {
|
|
|
908
1043
|
}
|
|
909
1044
|
|
|
910
1045
|
// src/middleware.ts
|
|
1046
|
+
function usageTokens(modelId, usage) {
|
|
1047
|
+
const u = usage;
|
|
1048
|
+
const promptTokens = u?.promptTokens ?? u?.inputTokens;
|
|
1049
|
+
const completionTokens = u?.completionTokens ?? u?.outputTokens;
|
|
1050
|
+
if (typeof promptTokens !== "number" || typeof completionTokens !== "number") {
|
|
1051
|
+
throw new Error(
|
|
1052
|
+
`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.`
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
return { promptTokens, completionTokens };
|
|
1056
|
+
}
|
|
911
1057
|
function budgetGuardMiddleware(guard) {
|
|
912
1058
|
return {
|
|
913
1059
|
async wrapGenerate({ doGenerate, model }) {
|
|
914
|
-
guard.
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1060
|
+
const reserved = guard.reserve();
|
|
1061
|
+
let result;
|
|
1062
|
+
try {
|
|
1063
|
+
result = await doGenerate();
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
guard.release(reserved);
|
|
1066
|
+
throw err;
|
|
1067
|
+
}
|
|
1068
|
+
let handled = false;
|
|
1069
|
+
try {
|
|
1070
|
+
const { promptTokens, completionTokens } = usageTokens(
|
|
1071
|
+
model.modelId,
|
|
1072
|
+
result?.usage
|
|
1073
|
+
);
|
|
1074
|
+
handled = true;
|
|
1075
|
+
guard.settle(model.modelId, promptTokens, completionTokens, { reserved });
|
|
1076
|
+
return result;
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
if (!handled) guard.release(reserved);
|
|
1079
|
+
throw err;
|
|
1080
|
+
}
|
|
922
1081
|
},
|
|
923
1082
|
async wrapStream({ doStream, model }) {
|
|
924
|
-
guard.
|
|
925
|
-
|
|
1083
|
+
const reserved = guard.reserve();
|
|
1084
|
+
let streamResult;
|
|
1085
|
+
try {
|
|
1086
|
+
streamResult = await doStream();
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
guard.release(reserved);
|
|
1089
|
+
throw err;
|
|
1090
|
+
}
|
|
1091
|
+
const { stream, ...rest } = streamResult;
|
|
1092
|
+
let handled = false;
|
|
926
1093
|
const guarded = stream.pipeThrough(
|
|
927
1094
|
new TransformStream({
|
|
928
1095
|
transform(chunk, controller) {
|
|
929
|
-
if (chunk
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1096
|
+
if (chunk?.type === "finish" && !handled) {
|
|
1097
|
+
try {
|
|
1098
|
+
const { promptTokens, completionTokens } = usageTokens(
|
|
1099
|
+
model.modelId,
|
|
1100
|
+
chunk.usage
|
|
1101
|
+
);
|
|
1102
|
+
handled = true;
|
|
1103
|
+
guard.settle(model.modelId, promptTokens, completionTokens, { reserved });
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
if (!handled) {
|
|
1106
|
+
handled = true;
|
|
1107
|
+
guard.release(reserved);
|
|
1108
|
+
}
|
|
1109
|
+
throw err;
|
|
1110
|
+
}
|
|
935
1111
|
}
|
|
936
1112
|
controller.enqueue(chunk);
|
|
1113
|
+
},
|
|
1114
|
+
flush() {
|
|
1115
|
+
if (!handled) {
|
|
1116
|
+
handled = true;
|
|
1117
|
+
guard.release(reserved);
|
|
1118
|
+
}
|
|
1119
|
+
},
|
|
1120
|
+
cancel() {
|
|
1121
|
+
if (!handled) {
|
|
1122
|
+
handled = true;
|
|
1123
|
+
guard.release(reserved);
|
|
1124
|
+
}
|
|
937
1125
|
}
|
|
1126
|
+
// `cancel` is valid per the Streams spec and supported in Node 18+, but
|
|
1127
|
+
// TS's Transformer lib type lags and omits it — cast to keep the type check.
|
|
938
1128
|
})
|
|
939
1129
|
);
|
|
940
1130
|
return { stream: guarded, ...rest };
|