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/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. A check on
92
- * the running total catches an overshoot if the estimate was too low.
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
- /** USD left before the ceiling (never negative). */
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
- * Verified against `ai@4.3.19` (`LanguageModelV1Middleware`):
152
- * - `wrapGenerate({ doGenerate, model })` we `check()` (throws to hard-stop)
153
- * BEFORE calling `doGenerate()`, then `record()` from `result.usage`.
154
- * - `wrapStream({ doStream, model })` we `check()` BEFORE `doStream()`, then
155
- * read `usage` from the `finish` part as the stream drains.
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
- * Build a `LanguageModelV1Middleware` that hard-stops the model before a call
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): LanguageModelV1Middleware;
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. A check on
857
- * the running total catches an overshoot if the estimate was too low.
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 estimate = estimatedNextCost === void 0 ? this.lastCost : Math.max(0, estimatedNextCost);
861
- const projected = this.spentUsd + estimate;
862
- if (this.spentUsd > this.limitUsd - EPS || projected > this.limitUsd + EPS) {
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
- * Price one response's tokens offline and add the cost to the total.
918
+ * Atomically check the ceiling AND hold the estimated cost in flight.
869
919
  *
870
- * Returns the USD cost of this call. If the model is unpriceable and no `price`
871
- * is given, behaviour depends on `failClosed`: warn + throw (default), or
872
- * warn + skip accrual.
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
- record(model, promptTokens, completionTokens, options = {}) {
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
- const cost = priceTokens(priced, promptTokens, completionTokens);
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
- /** USD left before the ceiling (never negative). */
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.check();
915
- const result = await doGenerate();
916
- guard.record(
917
- model.modelId,
918
- result.usage.promptTokens,
919
- result.usage.completionTokens
920
- );
921
- return result;
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.check();
925
- const { stream, ...rest } = await doStream();
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.type === "finish") {
930
- guard.record(
931
- model.modelId,
932
- chunk.usage.promptTokens,
933
- chunk.usage.completionTokens
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 };