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/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
  *
@@ -25,8 +23,9 @@ interface PricedModel {
25
23
  /**
26
24
  * Resolve a model to its per-token price, or `null` if it cannot be priced.
27
25
  *
28
- * Overrides win, then the bundled cost map (looked up by bare name, then the raw
29
- * field). Fail-closed: both prices must be finite, else `null`.
26
+ * Per specificity group (exact forms first, date-stripped fallbacks second):
27
+ * overrides win, then the bundled cost map. Fail-closed: the first matching
28
+ * entry must have finite prices, else `null`.
30
29
  */
31
30
  declare function resolvePrice(model: string, overrides?: Record<string, ManualPrice>): PricedModel | null;
32
31
  /** USD cost for token usage. Negative counts are clamped to zero. */
@@ -51,6 +50,16 @@ declare namespace pricing {
51
50
  * 2. Call {@link BudgetGuard.record} AFTER every response, with the token usage.
52
51
  * It prices the tokens offline and accrues the USD into a running total.
53
52
  *
53
+ * **Concurrency.** `check()` then `record()` is a check-then-act with an `await`
54
+ * in between. Fire several model calls at once (e.g. `Promise.all`) and they all
55
+ * `check()` against the same under-limit total before any `record()` lands, so
56
+ * the ceiling is blown (see issue #18). {@link BudgetGuard.reserve} /
57
+ * {@link BudgetGuard.settle} close that gap: `reserve()` holds the estimated cost
58
+ * in flight (synchronously, before the await), so parallel callers each take
59
+ * their own slice of the ceiling. JS is single-threaded, so an in-flight counter
60
+ * is enough — no lock needed. The middleware uses it; `check`/`record` are
61
+ * unchanged.
62
+ *
54
63
  * This is a faithful port of `src/floe_guard/guard.py` — same prediction logic,
55
64
  * same epsilon handling, same fail-closed default.
56
65
  */
@@ -70,15 +79,47 @@ interface BudgetGuardOptions {
70
79
  * `BUDGET EXCEEDED — call blocked` banner to stderr.
71
80
  */
72
81
  onBlock?: (spentUsd: number, limitUsd: number) => void;
82
+ /**
83
+ * Utilization (basis points, 0..10000) at which {@link BudgetGuard.advisory}
84
+ * flags `nearLimit` so an agent can taper before the hard-stop. Default 8000.
85
+ */
86
+ nearLimitBps?: number;
87
+ }
88
+ /**
89
+ * A context-aware spend signal for the single local budget.
90
+ *
91
+ * Mirrors the core fields of hosted Floe's `X-Floe-Budget-Advisory` header, so
92
+ * agent logic that reads it (taper as you approach the cap, stop at it) ports
93
+ * unchanged to the hosted path. Hosted adds what a local, single-budget guard
94
+ * cannot know: which of several caps is tightest (`scope` across
95
+ * `credit_line | session | task | api | vendor`), cross-vendor reasoning,
96
+ * server-truth balances, and rolling-window reset timing.
97
+ *
98
+ * This is a **soft** signal — the model may ignore it. The hard-stop
99
+ * ({@link BudgetGuard.check}) is what enforces the ceiling; the advisory is
100
+ * upside (let the agent finish on budget rather than be cut off).
101
+ */
102
+ interface BudgetAdvisory {
103
+ nearLimit: boolean;
104
+ /** Utilization in basis points, 0..10000 (8500 = 85%). */
105
+ usedBps: number;
106
+ remainingUsd: number;
107
+ limitUsd: number;
108
+ spentUsd: number;
109
+ /** Hosted reports the tightest cap across all scopes; local is always "local". */
110
+ scope: "local";
73
111
  }
74
112
  declare class BudgetGuard {
75
113
  readonly limitUsd: number;
76
114
  spentUsd: number;
77
115
  priceOverrides?: Record<string, ManualPrice>;
78
116
  failClosed: boolean;
117
+ nearLimitBps: number;
79
118
  private readonly onBlock;
80
119
  /** Cost of the most recent priced call, used to predict the next one. */
81
120
  private lastCost;
121
+ /** USD held for in-flight calls (reserved, not yet settled). Counts toward the ceiling. */
122
+ private reserved;
82
123
  /**
83
124
  * @param limitUsd the spend ceiling, in USD. `0` blocks the very first call.
84
125
  */
@@ -88,10 +129,35 @@ declare class BudgetGuard {
88
129
  *
89
130
  * Call this immediately before each LLM request. The "next call" is estimated
90
131
  * 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.
132
+ * first call is always allowed unless the ceiling is already met. In-flight
133
+ * reservations count toward the total, so this stays correct alongside
134
+ * {@link BudgetGuard.reserve}.
135
+ *
136
+ * Note: `check` is a non-binding peek. For parallel calls, use `reserve()` /
137
+ * `settle()`, which hold the estimate across the await.
93
138
  */
94
139
  check(estimatedNextCost?: number): void;
140
+ /**
141
+ * Atomically check the ceiling AND hold the estimated cost in flight.
142
+ *
143
+ * The concurrency-safe enforcement path: call before the request and hold the
144
+ * returned reservation across the await, so parallel callers can't all clear
145
+ * the same stale total. Throws {@link BudgetExceeded} (without reserving) if
146
+ * the reservation would cross the ceiling. Returns the reservation handle to
147
+ * pass to {@link BudgetGuard.settle} (or {@link BudgetGuard.release} on error).
148
+ * `estimatedCost` defaults to the last call's cost.
149
+ */
150
+ reserve(estimatedCost?: number): number;
151
+ /**
152
+ * Release a reservation and record the actual cost. `record` is `settle` with
153
+ * no reservation. Returns the USD cost of this call; unpriceable-model handling
154
+ * matches {@link BudgetGuard.record}, and any held reservation is released even
155
+ * on the warn-and-skip path.
156
+ */
157
+ settle(model: string, promptTokens: number, completionTokens: number, options?: {
158
+ reserved?: number;
159
+ price?: ManualPrice;
160
+ }): number;
95
161
  /**
96
162
  * Price one response's tokens offline and add the cost to the total.
97
163
  *
@@ -102,8 +168,21 @@ declare class BudgetGuard {
102
168
  record(model: string, promptTokens: number, completionTokens: number, options?: {
103
169
  price?: ManualPrice;
104
170
  }): number;
105
- /** USD left before the ceiling (never negative). */
171
+ /**
172
+ * Drop an in-flight reservation without recording spend (e.g. the call failed
173
+ * before producing usage). Safe to call with `0`.
174
+ */
175
+ release(reserved: number): void;
176
+ /** USD left before the ceiling, net of in-flight reservations (never negative). */
106
177
  get remainingUsd(): number;
178
+ /**
179
+ * Context-aware spend advisory for this budget — see {@link BudgetAdvisory}.
180
+ *
181
+ * `nearLimit` flips once utilization reaches `nearLimitBps` (default 80%), so an
182
+ * agent can taper *before* the hard-stop. Advisory only: read it to adapt;
183
+ * {@link BudgetGuard.check} is what enforces the ceiling.
184
+ */
185
+ advisory(): BudgetAdvisory;
107
186
  }
108
187
 
109
188
  /**
@@ -148,18 +227,55 @@ declare class UnpriceableModelError extends FloeGuardError {
148
227
  * This is the TypeScript counterpart to the Python framework adapters. The AI SDK
149
228
  * is TypeScript-only, so it ships as its own npm package.
150
229
  *
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.
230
+ * Works with BOTH `ai@4` (`LanguageModelV1Middleware`) and `ai@5`
231
+ * (`LanguageModelV2Middleware`). The two majors renamed the middleware type and
232
+ * the usage fields (`promptTokens`/`completionTokens` `inputTokens`/
233
+ * `outputTokens`), so this module deliberately imports nothing from `ai`: the
234
+ * middleware is typed structurally against the surface both majors share, and
235
+ * usage is read from whichever field pair the installed SDK reports.
236
+ *
237
+ * - `wrapGenerate({ doGenerate, model })` — we `reserve()` (throws to hard-stop)
238
+ * BEFORE calling `doGenerate()`, hold the reservation across the await, then
239
+ * `settle()` from `result.usage`.
240
+ * - `wrapStream({ doStream, model })` — we `reserve()` BEFORE `doStream()`, then
241
+ * `settle()` from the `finish` part as the stream drains.
242
+ *
243
+ * Reserving before the await is what makes parallel calls (`Promise.all` over
244
+ * several generations) honour the ceiling: each holds its slice instead of all
245
+ * reading the same stale total (issue #18). The reservation is released if the
246
+ * call throws, or if a stream ends without reporting usage.
156
247
  *
157
248
  * The model id used for pricing comes from `model.modelId`.
158
249
  */
159
250
 
160
251
  /**
161
- * Build a `LanguageModelV1Middleware` that hard-stops the model before a call
252
+ * The middleware call surface shared by `ai@4` and `ai@5`. Both majors invoke
253
+ * `wrapGenerate`/`wrapStream` with an options object carrying these members
254
+ * (plus richer `model` fields we don't read). `doGenerate`/`doStream` are
255
+ * declared optional so every 4.x/5.x minor's options type stays assignable —
256
+ * the SDK always provides the one each hook actually calls.
257
+ */
258
+ interface MiddlewareCallOptions {
259
+ doGenerate?: () => PromiseLike<any>;
260
+ doStream?: () => PromiseLike<any>;
261
+ model: {
262
+ modelId: string;
263
+ };
264
+ params?: unknown;
265
+ }
266
+ /**
267
+ * Structural stand-in for `LanguageModelV1Middleware` (ai@4) and
268
+ * `LanguageModelV2Middleware` (ai@5) — assignable to the `middleware` option of
269
+ * `wrapLanguageModel` on either major.
270
+ */
271
+ interface BudgetGuardMiddleware {
272
+ wrapGenerate: (options: MiddlewareCallOptions) => Promise<any>;
273
+ wrapStream: (options: MiddlewareCallOptions) => Promise<any>;
274
+ }
275
+ /**
276
+ * Build a budget-guard middleware that hard-stops the model before a call
162
277
  * crosses the guard's USD ceiling, and records priced token usage after.
278
+ * Compatible with `wrapLanguageModel` from both `ai@4` and `ai@5`.
163
279
  *
164
280
  * @example
165
281
  * import { wrapLanguageModel } from "ai";
@@ -172,6 +288,6 @@ declare class UnpriceableModelError extends FloeGuardError {
172
288
  * middleware: budgetGuardMiddleware(guard),
173
289
  * });
174
290
  */
175
- declare function budgetGuardMiddleware(guard: BudgetGuard): LanguageModelV1Middleware;
291
+ declare function budgetGuardMiddleware(guard: BudgetGuard): BudgetGuardMiddleware;
176
292
 
177
- export { BudgetExceeded, BudgetGuard, type BudgetGuardOptions, FloeGuardError, type ManualPrice, type PricedModel, UnpriceableModelError, budgetGuardMiddleware, priceTokens, pricing, resolvePrice };
293
+ export { type BudgetAdvisory, BudgetExceeded, BudgetGuard, type BudgetGuardMiddleware, type BudgetGuardOptions, FloeGuardError, type ManualPrice, type PricedModel, UnpriceableModelError, budgetGuardMiddleware, priceTokens, pricing, resolvePrice };