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 CHANGED
@@ -1,12 +1,17 @@
1
1
  # floe-guard (Vercel AI SDK)
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/floe-guard.svg)](https://www.npmjs.com/package/floe-guard)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](../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@4 @ai-sdk/openai
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
- ## Verified against
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@4` (`LanguageModelV1Middleware` via `wrapLanguageModel` /
50
- `experimental_wrapLanguageModel`). Declared as a peer dependency.
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
- function bareModel(model) {
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 slash = m.lastIndexOf("/");
813
- return slash === -1 ? m : m.slice(slash + 1);
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 bare = bareModel(model);
820
- if (overrides) {
821
- const ov = overrides[bare] ?? overrides[model.trim()];
822
- if (ov !== void 0) {
823
- if (bothFinite(ov.inputCostPerToken, ov.outputCostPerToken)) {
824
- return {
825
- inputCostPerToken: ov.inputCostPerToken,
826
- outputCostPerToken: ov.outputCostPerToken,
827
- source: "override"
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
- return null;
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
- const entry = COST_MAP[bare] ?? COST_MAP[model.trim()];
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. A check on
884
- * the running total catches an overshoot if the estimate was too low.
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 estimate = estimatedNextCost === void 0 ? this.lastCost : Math.max(0, estimatedNextCost);
888
- const projected = this.spentUsd + estimate;
889
- if (this.spentUsd > this.limitUsd - EPS || projected > this.limitUsd + EPS) {
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
- * Price one response's tokens offline and add the cost to the total.
1030
+ * Atomically check the ceiling AND hold the estimated cost in flight.
896
1031
  *
897
- * Returns the USD cost of this call. If the model is unpriceable and no `price`
898
- * is given, behaviour depends on `failClosed`: warn + throw (default), or
899
- * warn + skip accrual.
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
- record(model, promptTokens, completionTokens, options = {}) {
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
- const cost = priceTokens(priced, promptTokens, completionTokens);
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
- /** USD left before the ceiling (never negative). */
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.check();
942
- const result = await doGenerate();
943
- guard.record(
944
- model.modelId,
945
- result.usage.promptTokens,
946
- result.usage.completionTokens
947
- );
948
- return result;
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.check();
952
- const { stream, ...rest } = await doStream();
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.type === "finish") {
957
- guard.record(
958
- model.modelId,
959
- chunk.usage.promptTokens,
960
- chunk.usage.completionTokens
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 };