floe-guard 0.15.7 → 0.16.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
@@ -34,6 +34,79 @@ The middleware sits in the call path: it `check()`s before `doGenerate` /
34
34
  `doStream` (throwing `BudgetExceeded` to halt the run) and `record()`s priced
35
35
  token usage after — for streaming it reads usage from the `finish` part.
36
36
 
37
+ ## Mid-stream budget enforcement
38
+
39
+ `StreamGuard` and `guardStream` port Python's streaming USD guard. They price
40
+ each text delta before it reaches the consumer. When a chunk crosses the
41
+ shared ceiling, its partial spend is recorded in `guard.spendLog` **before**
42
+ `BudgetExceeded` is thrown. Active streams on the same guard share the ceiling.
43
+
44
+ ```ts
45
+ import { BudgetGuard, guardStream } from "floe-guard";
46
+
47
+ const guard = new BudgetGuard(0.01);
48
+ // textDeltas is your provider's Iterable<string> or AsyncIterable<string>.
49
+ for await (const text of guardStream(guard, "gpt-4o", textDeltas)) {
50
+ consume(text);
51
+ }
52
+ ```
53
+
54
+ For structured chunks, supply `{ getText: chunk => chunk.delta.text ?? "" }`
55
+ using your provider's actual shape. Without an extractor, non-string chunks
56
+ throw instead of silently recording zero. Synchronous inputs return a
57
+ synchronous iterator; asynchronous inputs return an asynchronous iterator.
58
+
59
+ Use `StreamGuard` directly when the provider reports final usage:
60
+
61
+ ```ts
62
+ import { StreamGuard } from "floe-guard";
63
+
64
+ const reserved = guard.reserve(guard.estimateCall("gpt-4o", 100, 200));
65
+ const stream = new StreamGuard(guard, "gpt-4o", { promptTokens: 100, reserved });
66
+ try {
67
+ for await (const text of textDeltas) {
68
+ stream.feedText(text); // or feedTokens(n) with known per-chunk counts
69
+ consume(text);
70
+ }
71
+ stream.finish({ promptTokens: reportedPromptTokens, completionTokens: reportedCompletionTokens });
72
+ } finally {
73
+ stream.close(); // idempotent; settles estimates if finish() was not reached
74
+ }
75
+ ```
76
+
77
+ - Options: `promptTokens`, `reserved`, `price`, `label`, and `countTokens(delta)`.
78
+ `guardStream` additionally accepts `getText(chunk)`.
79
+ Configuration is captured at construction; changing the caller's options or
80
+ manual price object later does not change an existing stream.
81
+ - `approxTokens` defaults to roughly four Unicode characters per token, with
82
+ a minimum of one for a non-empty delta. A custom tokenizer can replace it.
83
+ `finish()` reconciles to reported usage; `completionTokens` exposes the running
84
+ estimate. Mid-stream checks cover the aggregate USD ceiling, matching Python;
85
+ token reservations are reconciled at settlement.
86
+ - An unpriceable model fails at construction and releases its reservation.
87
+ With `failClosed: false`, unpriceable streams pass through and settlement
88
+ warns and skips accounting, matching the existing guard policy.
89
+ - The wrapper settles on exhaustion, source/consumer errors, and early `break`,
90
+ and closes the source iterator when iteration ends early. Direct users must
91
+ call `close()` in `finally`. If a wrapper is **never iterated**, its reservation
92
+ remains the caller's responsibility: call `guard.release(reserved)`.
93
+ - The crossing chunk has already been generated. With accurate counts, the
94
+ local cutoff can overshoot by that chunk; heuristic error, parallel streams,
95
+ provider buffering and delayed cancellation can cause additional actual
96
+ spend. Stopping iteration requests iterator cleanup, not guaranteed remote
97
+ cancellation. Connect cleanup to your provider's abort mechanism where needed.
98
+ - Existing Vapi, LiveKit and middleware streaming behavior is unchanged; this
99
+ is the standalone primitive requested in issue #124.
100
+
101
+ Run the no-key example from a repository checkout:
102
+
103
+ ```bash
104
+ cd js
105
+ npm ci
106
+ npm run build
107
+ node ../examples/streaming_guard.mjs
108
+ ```
109
+
37
110
  ## Pricing
38
111
 
39
112
  Tokens are priced **offline** from a bundled
@@ -1,4 +1,4 @@
1
- import { B as BudgetGuard, a as BudgetExceeded } from '../guard-BPIqdSVt.cjs';
1
+ import { B as BudgetGuard, a as BudgetExceeded } from '../guard-Bbs5OxAF.cjs';
2
2
 
3
3
  /**
4
4
  * LiveKit Agents adapter (optional peer: `npm i @livekit/agents`).
@@ -1,4 +1,4 @@
1
- import { B as BudgetGuard, a as BudgetExceeded } from '../guard-BPIqdSVt.js';
1
+ import { B as BudgetGuard, a as BudgetExceeded } from '../guard-Bbs5OxAF.js';
2
2
 
3
3
  /**
4
4
  * LiveKit Agents adapter (optional peer: `npm i @livekit/agents`).
@@ -1,4 +1,4 @@
1
- import { B as BudgetGuard, a as BudgetExceeded } from '../guard-BPIqdSVt.cjs';
1
+ import { B as BudgetGuard, a as BudgetExceeded } from '../guard-Bbs5OxAF.cjs';
2
2
 
3
3
  /**
4
4
  * Retell custom-LLM WebSocket adapter (optional peer: the `ws` server your custom
@@ -1,4 +1,4 @@
1
- import { B as BudgetGuard, a as BudgetExceeded } from '../guard-BPIqdSVt.js';
1
+ import { B as BudgetGuard, a as BudgetExceeded } from '../guard-Bbs5OxAF.js';
2
2
 
3
3
  /**
4
4
  * Retell custom-LLM WebSocket adapter (optional peer: the `ws` server your custom
@@ -1,5 +1,5 @@
1
- import { B as BudgetGuard, F as FloeGuardError } from '../guard-BPIqdSVt.cjs';
2
- export { a as BudgetExceeded } from '../guard-BPIqdSVt.cjs';
1
+ import { B as BudgetGuard, F as FloeGuardError } from '../guard-Bbs5OxAF.cjs';
2
+ export { a as BudgetExceeded } from '../guard-Bbs5OxAF.cjs';
3
3
 
4
4
  /**
5
5
  * Vapi custom-LLM adapter (no SDK dependency — typed structurally).
@@ -1,5 +1,5 @@
1
- import { B as BudgetGuard, F as FloeGuardError } from '../guard-BPIqdSVt.js';
2
- export { a as BudgetExceeded } from '../guard-BPIqdSVt.js';
1
+ import { B as BudgetGuard, F as FloeGuardError } from '../guard-Bbs5OxAF.js';
2
+ export { a as BudgetExceeded } from '../guard-Bbs5OxAF.js';
3
3
 
4
4
  /**
5
5
  * Vapi custom-LLM adapter (no SDK dependency — typed structurally).
@@ -347,6 +347,7 @@ declare class BudgetGuard {
347
347
  private lastToolCost;
348
348
  /** USD held for in-flight calls (reserved, not yet settled). Counts toward the ceiling. */
349
349
  private reserved;
350
+ private readonly streamCosts;
350
351
  /** Per-call ledger, oldest first; a ring buffer when maxLogEvents is set. */
351
352
  private readonly spendEvents;
352
353
  private readonly maxLogEvents?;
@@ -506,7 +507,7 @@ declare class BudgetGuard {
506
507
  * before producing usage). Safe to call with `0`.
507
508
  */
508
509
  release(reserved: ReservationHandle): void;
509
- /** USD left before the ceiling, net of in-flight reservations (never negative). */
510
+ /** USD left, net of reservations and unsettled stream overages (never negative). */
510
511
  get remainingUsd(): number;
511
512
  /**
512
513
  * Per-tool running USD totals, keyed by the name given to `settleTool()` /
@@ -624,6 +625,21 @@ declare class BudgetGuard {
624
625
  * (called outside the lock) reads no shared state and stays race-free.
625
626
  */
626
627
  private blockingCross;
628
+ /** Accrued stream costs beyond existing holds, excluding a stream if requested. */
629
+ private streamOverage;
630
+ /** @internal Validate before transferring a reservation to a stream. */
631
+ _validateStreamReservation(reserved: ReservationHandle): void;
632
+ /** @internal Register accrued-but-unsettled streaming spend. */
633
+ _registerStream(reserved: ReservationHandle): symbol;
634
+ /** @internal Settlement moved this stream's accrual into spentUsd. */
635
+ _unregisterStream(key: symbol): void;
636
+ /** @internal Replace this stream's estimate with its cumulative actual estimate.
637
+ * Count other streams' overages once, in addition to their existing holds.
638
+ * Synchronous within one JS isolate, matching Python's locked registry.
639
+ */
640
+ _streamWouldCross(key: symbol, cumulative: number): boolean;
641
+ /** @internal Notify and throw after a stream has settled its partial spend. */
642
+ _blockStream(): never;
627
643
  /** Notify + throw the right error for a [dimension, scope, spent, limit] block. */
628
644
  private raiseBlock;
629
645
  /**
@@ -347,6 +347,7 @@ declare class BudgetGuard {
347
347
  private lastToolCost;
348
348
  /** USD held for in-flight calls (reserved, not yet settled). Counts toward the ceiling. */
349
349
  private reserved;
350
+ private readonly streamCosts;
350
351
  /** Per-call ledger, oldest first; a ring buffer when maxLogEvents is set. */
351
352
  private readonly spendEvents;
352
353
  private readonly maxLogEvents?;
@@ -506,7 +507,7 @@ declare class BudgetGuard {
506
507
  * before producing usage). Safe to call with `0`.
507
508
  */
508
509
  release(reserved: ReservationHandle): void;
509
- /** USD left before the ceiling, net of in-flight reservations (never negative). */
510
+ /** USD left, net of reservations and unsettled stream overages (never negative). */
510
511
  get remainingUsd(): number;
511
512
  /**
512
513
  * Per-tool running USD totals, keyed by the name given to `settleTool()` /
@@ -624,6 +625,21 @@ declare class BudgetGuard {
624
625
  * (called outside the lock) reads no shared state and stays race-free.
625
626
  */
626
627
  private blockingCross;
628
+ /** Accrued stream costs beyond existing holds, excluding a stream if requested. */
629
+ private streamOverage;
630
+ /** @internal Validate before transferring a reservation to a stream. */
631
+ _validateStreamReservation(reserved: ReservationHandle): void;
632
+ /** @internal Register accrued-but-unsettled streaming spend. */
633
+ _registerStream(reserved: ReservationHandle): symbol;
634
+ /** @internal Settlement moved this stream's accrual into spentUsd. */
635
+ _unregisterStream(key: symbol): void;
636
+ /** @internal Replace this stream's estimate with its cumulative actual estimate.
637
+ * Count other streams' overages once, in addition to their existing holds.
638
+ * Synchronous within one JS isolate, matching Python's locked registry.
639
+ */
640
+ _streamWouldCross(key: symbol, cumulative: number): boolean;
641
+ /** @internal Notify and throw after a stream has settled its partial spend. */
642
+ _blockStream(): never;
627
643
  /** Notify + throw the right error for a [dimension, scope, spent, limit] block. */
628
644
  private raiseBlock;
629
645
  /**
package/dist/index.cjs CHANGED
@@ -26,12 +26,15 @@ __export(index_exports, {
26
26
  FloeGuardError: () => FloeGuardError,
27
27
  LatencyBudget: () => LatencyBudget,
28
28
  LedgerSyncError: () => LedgerSyncError,
29
+ StreamGuard: () => StreamGuard,
29
30
  TokenBudgetExceeded: () => TokenBudgetExceeded,
30
31
  UnpriceableModelError: () => UnpriceableModelError,
31
32
  UnpriceableVoiceError: () => UnpriceableVoiceError,
33
+ approxTokens: () => approxTokens,
32
34
  budgetGuardMiddleware: () => budgetGuardMiddleware,
33
35
  costMapGeneratedAt: () => costMapGeneratedAt,
34
36
  gates: () => gates_exports,
37
+ guardStream: () => guardStream,
35
38
  lookupVoiceRate: () => lookupVoiceRate,
36
39
  priceTokens: () => priceTokens,
37
40
  priceVoiceLeg: () => priceVoiceLeg,
@@ -1736,6 +1739,7 @@ var BudgetGuard = class {
1736
1739
  lastToolCost = 0;
1737
1740
  /** USD held for in-flight calls (reserved, not yet settled). Counts toward the ceiling. */
1738
1741
  reserved = 0;
1742
+ streamCosts = /* @__PURE__ */ new Map();
1739
1743
  /** Per-call ledger, oldest first; a ring buffer when maxLogEvents is set. */
1740
1744
  spendEvents = [];
1741
1745
  maxLogEvents;
@@ -2056,9 +2060,9 @@ var BudgetGuard = class {
2056
2060
  if (!reserved) return;
2057
2061
  this.consumeReservation(reserved);
2058
2062
  }
2059
- /** USD left before the ceiling, net of in-flight reservations (never negative). */
2063
+ /** USD left, net of reservations and unsettled stream overages (never negative). */
2060
2064
  get remainingUsd() {
2061
- return Math.max(0, this.limitUsd - this.spentUsd - this.reserved);
2065
+ return Math.max(0, this.limitUsd - this.spentUsd - this.reserved - this.streamOverage());
2062
2066
  }
2063
2067
  /**
2064
2068
  * Per-tool running USD totals, keyed by the name given to `settleTool()` /
@@ -2261,7 +2265,7 @@ var BudgetGuard = class {
2261
2265
  * (called outside the lock) reads no shared state and stays race-free.
2262
2266
  */
2263
2267
  blockingCross(estimateUsd, estimateTokens) {
2264
- const committed = this.spentUsd + this.reserved;
2268
+ const committed = this.spentUsd + this.reserved + this.streamOverage();
2265
2269
  if (committed > this.limitUsd - EPS || committed + estimateUsd > this.limitUsd + EPS) {
2266
2270
  return ["usd", "aggregate", this.spentUsd, this.limitUsd];
2267
2271
  }
@@ -2288,6 +2292,43 @@ var BudgetGuard = class {
2288
2292
  }
2289
2293
  return null;
2290
2294
  }
2295
+ /** Accrued stream costs beyond existing holds, excluding a stream if requested. */
2296
+ streamOverage(exclude) {
2297
+ let overage = 0;
2298
+ for (const [key, stream] of this.streamCosts) {
2299
+ if (key !== exclude) overage += Math.max(0, stream.accrued - stream.held);
2300
+ }
2301
+ return overage;
2302
+ }
2303
+ /** @internal Validate before transferring a reservation to a stream. */
2304
+ _validateStreamReservation(reserved) {
2305
+ this.reservedUsdOf(reserved);
2306
+ }
2307
+ /** @internal Register accrued-but-unsettled streaming spend. */
2308
+ _registerStream(reserved) {
2309
+ const held = this.reservedUsdOf(reserved);
2310
+ const key = /* @__PURE__ */ Symbol();
2311
+ this.streamCosts.set(key, { accrued: 0, held });
2312
+ return key;
2313
+ }
2314
+ /** @internal Settlement moved this stream's accrual into spentUsd. */
2315
+ _unregisterStream(key) {
2316
+ this.streamCosts.delete(key);
2317
+ }
2318
+ /** @internal Replace this stream's estimate with its cumulative actual estimate.
2319
+ * Count other streams' overages once, in addition to their existing holds.
2320
+ * Synchronous within one JS isolate, matching Python's locked registry.
2321
+ */
2322
+ _streamWouldCross(key, cumulative) {
2323
+ const own = this.streamCosts.get(key);
2324
+ own.accrued = cumulative;
2325
+ const others = this.spentUsd + Math.max(0, this.reserved - own.held) + this.streamOverage(key);
2326
+ return others + cumulative > this.limitUsd + EPS;
2327
+ }
2328
+ /** @internal Notify and throw after a stream has settled its partial spend. */
2329
+ _blockStream() {
2330
+ return this.raiseBlock(["usd", "aggregate", this.spentUsd, this.limitUsd]);
2331
+ }
2291
2332
  /** Notify + throw the right error for a [dimension, scope, spent, limit] block. */
2292
2333
  raiseBlock(blocked) {
2293
2334
  const [dimension, scope, spent, limit] = blocked;
@@ -2635,6 +2676,130 @@ async function nextPlan(guard, error, current, onDegrade) {
2635
2676
  return current;
2636
2677
  }
2637
2678
 
2679
+ // src/stream.ts
2680
+ function approxTokens(text) {
2681
+ if (typeof text !== "string") throw new TypeError("stream text must be a string");
2682
+ return text ? Math.max(1, Math.floor(Array.from(text).length / 4)) : 0;
2683
+ }
2684
+ function tokenCount(value) {
2685
+ if (!Number.isFinite(value)) throw new RangeError("token count must be finite");
2686
+ return Math.max(0, Math.trunc(value));
2687
+ }
2688
+ var StreamGuard = class {
2689
+ /** Capture pricing and release the supplied reservation if validation fails. */
2690
+ constructor(guard, model, options = {}) {
2691
+ this.guard = guard;
2692
+ this.model = model;
2693
+ this.reserved = options.reserved ?? 0;
2694
+ guard._validateStreamReservation(this.reserved);
2695
+ try {
2696
+ this.price = options.price === void 0 ? void 0 : { ...options.price };
2697
+ this.label = options.label;
2698
+ this.countTokens = options.countTokens ?? approxTokens;
2699
+ this.promptTokens = tokenCount(options.promptTokens ?? 0);
2700
+ this.priced = resolvePrice(
2701
+ model,
2702
+ this.price === void 0 ? guard.priceOverrides : { ...guard.priceOverrides, [model]: this.price }
2703
+ );
2704
+ if (this.priced === null && guard.failClosed) {
2705
+ console.warn(`Cannot price model '${model}': pass a price override to enforce a streaming budget.`);
2706
+ throw new UnpriceableModelError(model);
2707
+ }
2708
+ } catch (error) {
2709
+ guard.release(this.reserved);
2710
+ throw error;
2711
+ }
2712
+ }
2713
+ guard;
2714
+ model;
2715
+ tokens = 0;
2716
+ closed = false;
2717
+ priced;
2718
+ key;
2719
+ promptTokens;
2720
+ reserved;
2721
+ price;
2722
+ label;
2723
+ countTokens;
2724
+ /** Generated completion tokens counted so far (estimated until finish()). */
2725
+ get completionTokens() {
2726
+ return this.tokens;
2727
+ }
2728
+ /** Meter a text delta with the configured tokenizer. */
2729
+ feedText(delta) {
2730
+ if (typeof delta !== "string") throw new TypeError("stream text must be a string");
2731
+ const count = this.countTokens;
2732
+ this.feedTokens(count(delta));
2733
+ }
2734
+ /** Meter additional completion tokens, settling before a budget interruption. */
2735
+ feedTokens(tokens) {
2736
+ if (this.closed) throw new Error("stream already settled");
2737
+ this.tokens = tokenCount(this.tokens + tokenCount(tokens));
2738
+ if (this.priced === null) return;
2739
+ const cost = priceTokens(this.priced, this.promptTokens, this.tokens);
2740
+ this.key ??= this.guard._registerStream(this.reserved);
2741
+ if (this.guard._streamWouldCross(this.key, cost)) {
2742
+ this.finish();
2743
+ this.guard._blockStream();
2744
+ }
2745
+ }
2746
+ /** Reconcile estimates to provider usage, or settle accumulated estimates. */
2747
+ finish(usage = {}) {
2748
+ if (this.closed) throw new Error("stream already settled");
2749
+ const prompt = tokenCount(usage.promptTokens ?? this.promptTokens);
2750
+ const completion = tokenCount(usage.completionTokens ?? this.tokens);
2751
+ this.promptTokens = prompt;
2752
+ this.tokens = completion;
2753
+ this.closed = true;
2754
+ try {
2755
+ if (this.priced === null) {
2756
+ this.guard.release(this.reserved);
2757
+ console.warn(`Cannot price model '${this.model}' at stream construction; skipping streaming spend.`);
2758
+ return 0;
2759
+ }
2760
+ return this.guard.settle(this.model, prompt, completion, {
2761
+ reserved: this.reserved,
2762
+ price: this.priced,
2763
+ label: this.label
2764
+ });
2765
+ } finally {
2766
+ if (this.key !== void 0) this.guard._unregisterStream(this.key);
2767
+ }
2768
+ }
2769
+ /** Idempotent cleanup for a finally block; partial generated usage is billed. */
2770
+ close() {
2771
+ if (!this.closed) this.finish();
2772
+ }
2773
+ };
2774
+ function guardStream(guard, model, chunks, options = {}) {
2775
+ const stream = new StreamGuard(guard, model, options);
2776
+ const extract = options.getText ?? ((chunk) => {
2777
+ if (typeof chunk !== "string") throw new TypeError("guardStream needs getText for non-string chunks");
2778
+ return chunk;
2779
+ });
2780
+ function* run(source) {
2781
+ try {
2782
+ for (const chunk of source) {
2783
+ stream.feedText(extract(chunk));
2784
+ yield chunk;
2785
+ }
2786
+ } finally {
2787
+ stream.close();
2788
+ }
2789
+ }
2790
+ async function* runAsync(source) {
2791
+ try {
2792
+ for await (const chunk of source) {
2793
+ stream.feedText(extract(chunk));
2794
+ yield chunk;
2795
+ }
2796
+ } finally {
2797
+ stream.close();
2798
+ }
2799
+ }
2800
+ return Symbol.asyncIterator in Object(chunks) ? runAsync(chunks) : run(chunks);
2801
+ }
2802
+
2638
2803
  // src/voice-pricing.ts
2639
2804
  var VOICE_MAP = cost_map_default["__voice__"] ?? {};
2640
2805
  var unitForMode = {
@@ -2743,12 +2908,15 @@ function vapi(guard, options = {}) {
2743
2908
  FloeGuardError,
2744
2909
  LatencyBudget,
2745
2910
  LedgerSyncError,
2911
+ StreamGuard,
2746
2912
  TokenBudgetExceeded,
2747
2913
  UnpriceableModelError,
2748
2914
  UnpriceableVoiceError,
2915
+ approxTokens,
2749
2916
  budgetGuardMiddleware,
2750
2917
  costMapGeneratedAt,
2751
2918
  gates,
2919
+ guardStream,
2752
2920
  lookupVoiceRate,
2753
2921
  priceTokens,
2754
2922
  priceVoiceLeg,