@loomwork/middleware 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +82 -2
- package/dist/claimCheck.d.ts +179 -0
- package/dist/claimCheck.d.ts.map +1 -0
- package/dist/claimCheck.js +241 -0
- package/dist/claimCheck.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/killSwitchRegistry.d.ts +74 -0
- package/dist/killSwitchRegistry.d.ts.map +1 -0
- package/dist/killSwitchRegistry.js +108 -0
- package/dist/killSwitchRegistry.js.map +1 -0
- package/dist/metering.d.ts +81 -0
- package/dist/metering.d.ts.map +1 -0
- package/dist/metering.js +153 -0
- package/dist/metering.js.map +1 -0
- package/dist/nestedBudget.d.ts +60 -0
- package/dist/nestedBudget.d.ts.map +1 -0
- package/dist/nestedBudget.js +134 -0
- package/dist/nestedBudget.js.map +1 -0
- package/dist/profile.d.ts +38 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +70 -0
- package/dist/profile.js.map +1 -0
- package/dist/rateLimit.d.ts +40 -0
- package/dist/rateLimit.d.ts.map +1 -0
- package/dist/rateLimit.js +83 -0
- package/dist/rateLimit.js.map +1 -0
- package/dist/redaction.d.ts +66 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +141 -0
- package/dist/redaction.js.map +1 -0
- package/dist/retry.d.ts +50 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +98 -0
- package/dist/retry.js.map +1 -0
- package/dist/safety.d.ts +93 -0
- package/dist/safety.d.ts.map +1 -0
- package/dist/safety.js +152 -0
- package/dist/safety.js.map +1 -0
- package/dist/streamTokenBudget.d.ts +58 -0
- package/dist/streamTokenBudget.d.ts.map +1 -0
- package/dist/streamTokenBudget.js +47 -0
- package/dist/streamTokenBudget.js.map +1 -0
- package/package.json +55 -3
- package/src/claimCheck.ts +410 -0
- package/src/index.ts +79 -0
- package/src/killSwitchRegistry.ts +168 -0
- package/src/metering.ts +226 -0
- package/src/nestedBudget.ts +200 -0
- package/src/profile.ts +86 -0
- package/src/rateLimit.ts +126 -0
- package/src/redaction.ts +184 -0
- package/src/retry.ts +133 -0
- package/src/safety.ts +235 -0
- package/src/streamTokenBudget.ts +98 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { EffectContext } from "@loomwork/effect-runtime";
|
|
2
|
+
import type { Budget } from "./metering";
|
|
3
|
+
import { QuotaExceededError } from "./metering";
|
|
4
|
+
|
|
5
|
+
// ── K8.4 — sever a runaway generation WHILE it is generating ─────────────────
|
|
6
|
+
//
|
|
7
|
+
// Every other K3 enforcement point is admission control: it decides whether the
|
|
8
|
+
// NEXT effect runs. A model that decides to emit 100K tokens is billed in full
|
|
9
|
+
// and stopped afterwards, which is the gap ADR 0020 K8.4 named.
|
|
10
|
+
//
|
|
11
|
+
// ADR 0020 A5 said this cannot be middleware, and it is right: middleware wraps
|
|
12
|
+
// the effect and sees it once, at the end, which is exactly too late. A5 then
|
|
13
|
+
// proposed a usage-reporter hook on `EffectContext`, and that turned out to be
|
|
14
|
+
// the wrong half — both wired providers report usage ONCE, at the end (Anthropic
|
|
15
|
+
// in `message_delta`, OpenAI in a trailing usage-only chunk), so such a hook
|
|
16
|
+
// could only fire where metering already reads the number.
|
|
17
|
+
//
|
|
18
|
+
// The one thing a stream reports continuously is its own output. So the guard
|
|
19
|
+
// ESTIMATES from the text, and the estimate is named as an estimate everywhere it
|
|
20
|
+
// surfaces — in this type, in the QUOTA_EXCEEDED reason, and in the default. A
|
|
21
|
+
// budget guard that severs at roughly the right point is worth far more than
|
|
22
|
+
// exact accounting that arrives after the money is spent; it must not be sold as
|
|
23
|
+
// exact. Final metering is unchanged and still uses the provider's own numbers.
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Estimate output tokens from generated text. The default is the usual ~4
|
|
27
|
+
* characters-per-token heuristic — deliberately crude and deliberately visible.
|
|
28
|
+
* Pass a real tokenizer when the budget is tight enough for the difference to
|
|
29
|
+
* matter.
|
|
30
|
+
*/
|
|
31
|
+
export type TokenEstimator = (text: string) => number;
|
|
32
|
+
|
|
33
|
+
export const estimateTokensByChars: TokenEstimator = (text) => Math.ceil(text.length / 4);
|
|
34
|
+
|
|
35
|
+
export interface StreamTokenBudgetOptions {
|
|
36
|
+
/** Swap in a real tokenizer. Default {@link estimateTokensByChars}. */
|
|
37
|
+
estimate?: TokenEstimator;
|
|
38
|
+
/**
|
|
39
|
+
* Check at most every N deltas.
|
|
40
|
+
*
|
|
41
|
+
* The DEFAULT estimator is O(1) — `text.length / 4`, and `.length` is a field
|
|
42
|
+
* read — so with it this knob buys almost nothing. It exists for a real
|
|
43
|
+
* tokenizer, which scans the text and is therefore O(text) per call: at every
|
|
44
|
+
* delta that would be quadratic over a generation, and every 8 still divides
|
|
45
|
+
* the same quadratic by 8 rather than removing it. If you pass a scanning
|
|
46
|
+
* estimator, prefer one that can score an increment.
|
|
47
|
+
*
|
|
48
|
+
* (An earlier version of this comment credited `checkEvery` with bounding a
|
|
49
|
+
* cost that lived in `collectStream`, not here — the caller rebuilt the whole
|
|
50
|
+
* text per delta before this guard was ever consulted, so the knob could not
|
|
51
|
+
* have helped. That is fixed; the note stays because the reasoning it corrects
|
|
52
|
+
* is easy to repeat.)
|
|
53
|
+
*/
|
|
54
|
+
checkEvery?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build the `collectStream` guard that enforces a token {@link Budget} mid-flight.
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* createLlmEffect({
|
|
62
|
+
* provider,
|
|
63
|
+
* stream: true,
|
|
64
|
+
* buildRequest,
|
|
65
|
+
* guard: streamTokenBudget({ maxTokens: 50_000 }),
|
|
66
|
+
* });
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* On breach it emits `QUOTA_EXCEEDED` — the same durable fact
|
|
70
|
+
* `meteringMiddleware` writes, so an over-budget run looks the same to analytics
|
|
71
|
+
* whichever gate caught it — and throws `QuotaExceededError`, which propagates
|
|
72
|
+
* out of the `for await` and releases the HTTP call through K8.1's abort path.
|
|
73
|
+
*
|
|
74
|
+
* What is already spent comes from `ctx.resources` (K3's running totals from the
|
|
75
|
+
* durable fold), so the ceiling covers the execution rather than this one call.
|
|
76
|
+
* With no `ctx.resources` wired, only this generation's own output counts —
|
|
77
|
+
* degraded, but never wrong in the dangerous direction.
|
|
78
|
+
*/
|
|
79
|
+
export function streamTokenBudget(
|
|
80
|
+
budget: Budget,
|
|
81
|
+
options: StreamTokenBudgetOptions = {}
|
|
82
|
+
): (ctx: EffectContext) => (progress: { text: string; deltas: number }) => void {
|
|
83
|
+
const estimate = options.estimate ?? estimateTokensByChars;
|
|
84
|
+
const checkEvery = Math.max(1, options.checkEvery ?? 8);
|
|
85
|
+
|
|
86
|
+
return (ctx: EffectContext) => (progress) => {
|
|
87
|
+
if (budget.maxTokens === undefined) return;
|
|
88
|
+
if (progress.deltas % checkEvery !== 0) return;
|
|
89
|
+
|
|
90
|
+
const alreadySpent = ctx.resources?.tokens ?? 0;
|
|
91
|
+
const projected = alreadySpent + estimate(progress.text);
|
|
92
|
+
if (projected < budget.maxTokens) return;
|
|
93
|
+
|
|
94
|
+
const reason = `maxTokens ${budget.maxTokens} exceeded mid-stream (~${projected} estimated, ${alreadySpent} already metered)`;
|
|
95
|
+
ctx.emit("QUOTA_EXCEEDED", { stepId: ctx.stepId, reason });
|
|
96
|
+
throw new QuotaExceededError(reason);
|
|
97
|
+
};
|
|
98
|
+
}
|