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