@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.
Files changed (59) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +82 -2
  4. package/dist/claimCheck.d.ts +179 -0
  5. package/dist/claimCheck.d.ts.map +1 -0
  6. package/dist/claimCheck.js +241 -0
  7. package/dist/claimCheck.js.map +1 -0
  8. package/dist/index.d.ts +13 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +48 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/killSwitchRegistry.d.ts +74 -0
  13. package/dist/killSwitchRegistry.d.ts.map +1 -0
  14. package/dist/killSwitchRegistry.js +108 -0
  15. package/dist/killSwitchRegistry.js.map +1 -0
  16. package/dist/metering.d.ts +81 -0
  17. package/dist/metering.d.ts.map +1 -0
  18. package/dist/metering.js +153 -0
  19. package/dist/metering.js.map +1 -0
  20. package/dist/nestedBudget.d.ts +60 -0
  21. package/dist/nestedBudget.d.ts.map +1 -0
  22. package/dist/nestedBudget.js +134 -0
  23. package/dist/nestedBudget.js.map +1 -0
  24. package/dist/profile.d.ts +38 -0
  25. package/dist/profile.d.ts.map +1 -0
  26. package/dist/profile.js +70 -0
  27. package/dist/profile.js.map +1 -0
  28. package/dist/rateLimit.d.ts +40 -0
  29. package/dist/rateLimit.d.ts.map +1 -0
  30. package/dist/rateLimit.js +83 -0
  31. package/dist/rateLimit.js.map +1 -0
  32. package/dist/redaction.d.ts +66 -0
  33. package/dist/redaction.d.ts.map +1 -0
  34. package/dist/redaction.js +141 -0
  35. package/dist/redaction.js.map +1 -0
  36. package/dist/retry.d.ts +50 -0
  37. package/dist/retry.d.ts.map +1 -0
  38. package/dist/retry.js +98 -0
  39. package/dist/retry.js.map +1 -0
  40. package/dist/safety.d.ts +93 -0
  41. package/dist/safety.d.ts.map +1 -0
  42. package/dist/safety.js +152 -0
  43. package/dist/safety.js.map +1 -0
  44. package/dist/streamTokenBudget.d.ts +58 -0
  45. package/dist/streamTokenBudget.d.ts.map +1 -0
  46. package/dist/streamTokenBudget.js +47 -0
  47. package/dist/streamTokenBudget.js.map +1 -0
  48. package/package.json +55 -3
  49. package/src/claimCheck.ts +410 -0
  50. package/src/index.ts +79 -0
  51. package/src/killSwitchRegistry.ts +168 -0
  52. package/src/metering.ts +226 -0
  53. package/src/nestedBudget.ts +200 -0
  54. package/src/profile.ts +86 -0
  55. package/src/rateLimit.ts +126 -0
  56. package/src/redaction.ts +184 -0
  57. package/src/retry.ts +133 -0
  58. package/src/safety.ts +235 -0
  59. 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
+ }