@loomwork/middleware 0.0.0-stage → 0.1.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/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
package/dist/metering.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.QuotaExceededError = void 0;
|
|
4
|
+
exports.isBuiltinDimension = isBuiltinDimension;
|
|
5
|
+
exports.isCustomDimensionName = isCustomDimensionName;
|
|
6
|
+
exports.assertMaxCustom = assertMaxCustom;
|
|
7
|
+
exports.overCustomBudget = overCustomBudget;
|
|
8
|
+
exports.meteredPayload = meteredPayload;
|
|
9
|
+
exports.llmUsageTokens = llmUsageTokens;
|
|
10
|
+
exports.meteringMiddleware = meteringMiddleware;
|
|
11
|
+
// ── Resource accounting & quotas (Loom 2.0 K3) ───────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// The scarce resource for intelligent software is tokens / $ / latency, not CPU
|
|
14
|
+
// cycles. This middleware meters every effect it wraps as a durable RESOURCE_METERED
|
|
15
|
+
// fact (folded into `variables._resources`), and — given a budget — enforces a quota
|
|
16
|
+
// by admission control: once an execution has spent its budget, the next effect is
|
|
17
|
+
// refused (QUOTA_EXCEEDED + a thrown QuotaExceededError that fails the step). Both
|
|
18
|
+
// the accounting and the enforcement are replay-safe (metered facts live in the log;
|
|
19
|
+
// the running total is read from the deterministic fold via `ctx.resources`).
|
|
20
|
+
const core_1 = require("@loomwork/core");
|
|
21
|
+
/** True when `name` is a built-in metering dimension (never a custom one). */
|
|
22
|
+
function isBuiltinDimension(name) {
|
|
23
|
+
return core_1.BUILTIN_METER_DIMENSIONS.has(name);
|
|
24
|
+
}
|
|
25
|
+
/** True when `name` is a valid custom dimension name (pattern shared with the fold's guard). */
|
|
26
|
+
function isCustomDimensionName(name) {
|
|
27
|
+
return (0, core_1.isVocabularyName)(name) && !core_1.BUILTIN_METER_DIMENSIONS.has(name);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Validate a `Budget.maxCustom` and return a FROZEN shallow copy to enforce against (so a later
|
|
31
|
+
* mutation of the caller's object cannot smuggle in a built-in name or a bad ceiling). Refuses a
|
|
32
|
+
* built-in dimension name (use `maxTokens` etc.: a custom ceiling on `tokens` would read
|
|
33
|
+
* `_resources.custom.tokens`, which nothing meters) and a non-numeric ceiling.
|
|
34
|
+
*/
|
|
35
|
+
function assertMaxCustom(maxCustom) {
|
|
36
|
+
if (maxCustom === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
for (const [name, max] of Object.entries(maxCustom)) {
|
|
39
|
+
if (core_1.BUILTIN_METER_DIMENSIONS.has(name)) {
|
|
40
|
+
throw new Error(`Budget.maxCustom."${name}" names a built-in dimension; use its own max field (e.g. maxTokens)`);
|
|
41
|
+
}
|
|
42
|
+
if (typeof max !== "number" || !Number.isFinite(max) || max < 0) {
|
|
43
|
+
throw new Error(`Budget.maxCustom."${name}" must be a finite non-negative number`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return Object.freeze({ ...maxCustom });
|
|
47
|
+
}
|
|
48
|
+
/** The first custom ceiling the spend has reached, as a reason — own-property reads only. */
|
|
49
|
+
function overCustomBudget(custom, maxCustom) {
|
|
50
|
+
if (maxCustom === undefined)
|
|
51
|
+
return null;
|
|
52
|
+
for (const [name, max] of Object.entries(maxCustom)) {
|
|
53
|
+
const spent = custom !== undefined && Object.hasOwn(custom, name) ? custom[name] : 0;
|
|
54
|
+
if (spent >= max)
|
|
55
|
+
return `${name} ${spent} >= budget ${max}`;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
class QuotaExceededError extends Error {
|
|
60
|
+
reason;
|
|
61
|
+
/** A budget refusal is a decision; retrying repeats it (`retryMiddleware` honours this). */
|
|
62
|
+
retryable = false;
|
|
63
|
+
constructor(reason) {
|
|
64
|
+
super(`Quota exceeded: ${reason}`);
|
|
65
|
+
this.name = "QuotaExceededError";
|
|
66
|
+
this.reason = reason;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
exports.QuotaExceededError = QuotaExceededError;
|
|
70
|
+
function overBudget(r, b) {
|
|
71
|
+
if (b.maxCostUsd !== undefined && r.costUsd >= b.maxCostUsd) {
|
|
72
|
+
return `cost ${r.costUsd} >= budget ${b.maxCostUsd} USD`;
|
|
73
|
+
}
|
|
74
|
+
if (b.maxTokens !== undefined && r.tokens >= b.maxTokens) {
|
|
75
|
+
return `tokens ${r.tokens} >= budget ${b.maxTokens}`;
|
|
76
|
+
}
|
|
77
|
+
if (b.maxMemBytes !== undefined && (r.memBytes ?? 0) >= b.maxMemBytes) {
|
|
78
|
+
return `memBytes ${r.memBytes ?? 0} >= budget ${b.maxMemBytes}`;
|
|
79
|
+
}
|
|
80
|
+
if (b.maxWallMs !== undefined && (r.wallMs ?? 0) >= b.maxWallMs) {
|
|
81
|
+
return `wallMs ${r.wallMs ?? 0} >= budget ${b.maxWallMs}`;
|
|
82
|
+
}
|
|
83
|
+
if (b.maxNetBytes !== undefined && (r.netBytes ?? 0) >= b.maxNetBytes) {
|
|
84
|
+
return `netBytes ${r.netBytes ?? 0} >= budget ${b.maxNetBytes}`;
|
|
85
|
+
}
|
|
86
|
+
return overCustomBudget(r.custom, b.maxCustom);
|
|
87
|
+
}
|
|
88
|
+
/** The RESOURCE_METERED payload for a delta — only dimensions the pricing reported. */
|
|
89
|
+
function meteredPayload(delta, ms) {
|
|
90
|
+
return {
|
|
91
|
+
...(delta.tokens !== undefined && { tokens: delta.tokens }),
|
|
92
|
+
...(delta.costUsd !== undefined && { costUsd: delta.costUsd }),
|
|
93
|
+
...(delta.memBytes !== undefined && { memBytes: delta.memBytes }),
|
|
94
|
+
...(delta.wallMs !== undefined && { wallMs: delta.wallMs }),
|
|
95
|
+
...(delta.netBytes !== undefined && { netBytes: delta.netBytes }),
|
|
96
|
+
...(delta.custom !== undefined && { custom: delta.custom }),
|
|
97
|
+
ms: delta.ms ?? ms,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Read token usage from an LlmResponse-shaped result (`{ usage: { inputTokens, outputTokens } }`). */
|
|
101
|
+
function llmUsageTokens(result) {
|
|
102
|
+
const usage = result
|
|
103
|
+
?.usage;
|
|
104
|
+
if (usage === undefined || usage === null)
|
|
105
|
+
return 0;
|
|
106
|
+
return (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
|
107
|
+
}
|
|
108
|
+
function meteringMiddleware(options) {
|
|
109
|
+
const now = options.clock ?? (() => Date.now());
|
|
110
|
+
const frozenMax = assertMaxCustom(options.budget?.maxCustom);
|
|
111
|
+
const budget = options.budget === undefined
|
|
112
|
+
? undefined
|
|
113
|
+
: { ...options.budget, ...(frozenMax !== undefined && { maxCustom: frozenMax }) };
|
|
114
|
+
return async (ctx, input, next) => {
|
|
115
|
+
// Admission control: refuse if the execution has already spent its budget.
|
|
116
|
+
if (budget !== undefined && ctx.resources !== undefined) {
|
|
117
|
+
const reason = overBudget(ctx.resources, budget);
|
|
118
|
+
if (reason !== null) {
|
|
119
|
+
ctx.emit("QUOTA_EXCEEDED", { stepId: ctx.stepId, reason });
|
|
120
|
+
throw new QuotaExceededError(reason);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const start = now();
|
|
124
|
+
let result;
|
|
125
|
+
try {
|
|
126
|
+
result = await next();
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
// K8 — meter an aborted effect before rethrowing. Metering normally runs
|
|
130
|
+
// after next(), so a severed call would never be seen and
|
|
131
|
+
// start-expensive-effect → cancel → repeat could evade the budget forever.
|
|
132
|
+
// Only aborts are metered here; an ordinary failure keeps today's
|
|
133
|
+
// unmetered semantics.
|
|
134
|
+
if (ctx.signal?.aborted === true) {
|
|
135
|
+
const ms = now() - start;
|
|
136
|
+
const delta = options.pricing({
|
|
137
|
+
stepId: ctx.stepId,
|
|
138
|
+
input,
|
|
139
|
+
result: undefined,
|
|
140
|
+
ms,
|
|
141
|
+
aborted: true,
|
|
142
|
+
});
|
|
143
|
+
ctx.emit("RESOURCE_METERED", meteredPayload(delta, ms));
|
|
144
|
+
}
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
const ms = now() - start;
|
|
148
|
+
const delta = options.pricing({ stepId: ctx.stepId, input, result, ms });
|
|
149
|
+
ctx.emit("RESOURCE_METERED", meteredPayload(delta, ms));
|
|
150
|
+
return result;
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=metering.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metering.js","sourceRoot":"","sources":["../src/metering.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { EffectContext, EffectMiddleware } from "@loomwork/effect-runtime";
|
|
2
|
+
import { type Budget, type Pricing, type ResourceDelta } from "./metering";
|
|
3
|
+
export interface ScopeSpend {
|
|
4
|
+
tokens: number;
|
|
5
|
+
costUsd: number;
|
|
6
|
+
memBytes?: number;
|
|
7
|
+
wallMs?: number;
|
|
8
|
+
netBytes?: number;
|
|
9
|
+
/** ADR 0115 — open dimensions, accumulated by name (own-property keys only). */
|
|
10
|
+
custom?: Record<string, number>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A projection of spend per scope key (e.g. `tenant:acme`, `workflow:audit`).
|
|
14
|
+
* The app updates it from the RESOURCE_METERED event stream (across all of a
|
|
15
|
+
* tenant's executions) — typically in `defineConfig({ onEvent })`. It is
|
|
16
|
+
* eventually consistent by nature; two nodes may briefly under-count, which is
|
|
17
|
+
* the accepted trade-off for cross-execution budgets.
|
|
18
|
+
*/
|
|
19
|
+
export declare class BudgetLedger {
|
|
20
|
+
private readonly spend;
|
|
21
|
+
add(scopeKey: string, delta: ResourceDelta): void;
|
|
22
|
+
spent(scopeKey: string): ScopeSpend;
|
|
23
|
+
/** Fold a RESOURCE_METERED event into the given scope keys (from an onEvent hook). */
|
|
24
|
+
applyMetered(scopeKeys: readonly string[], payload: unknown): void;
|
|
25
|
+
}
|
|
26
|
+
/** One level of the nesting: a labelled scope key + its budget. */
|
|
27
|
+
export interface BudgetScope {
|
|
28
|
+
/** e.g. "tenant", "workflow", "execution" — recorded in the trip for attribution. */
|
|
29
|
+
level: string;
|
|
30
|
+
/** The ledger key this level accumulates under (execution uses ctx.resources instead). */
|
|
31
|
+
key: string;
|
|
32
|
+
budget: Budget;
|
|
33
|
+
}
|
|
34
|
+
export interface NestedBudgetOptions {
|
|
35
|
+
/** Cost model — same shape as `meteringMiddleware`. */
|
|
36
|
+
pricing: Pricing;
|
|
37
|
+
/** The cross-execution spend projection (tenant/workflow levels). */
|
|
38
|
+
ledger: BudgetLedger;
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the nested scopes for an effect, OUTERMOST (broadest) first. The
|
|
41
|
+
* execution level should use `key: ""` — its spend is read from the pure
|
|
42
|
+
* per-execution fold (`ctx.resources`), not the ledger.
|
|
43
|
+
*/
|
|
44
|
+
scopesOf: (ctx: EffectContext) => BudgetScope[];
|
|
45
|
+
/** Injectable clock for the measured `ms`. */
|
|
46
|
+
clock?: () => number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* K12.3 — nested-budget admission control. Before an effect runs, every scope
|
|
50
|
+
* (tenant → workflow → execution) is checked; the FIRST (most-restrictive)
|
|
51
|
+
* breach denies with a `QUOTA_EXCEEDED` fact attributed to that `level`, and the
|
|
52
|
+
* effect never runs. Otherwise the effect runs, is priced, and its delta is
|
|
53
|
+
* recorded into the ledger's non-execution scopes (the execution level is
|
|
54
|
+
* metered by the durable fold as usual) and emitted as `RESOURCE_METERED`.
|
|
55
|
+
*
|
|
56
|
+
* The execution level reads spend from `ctx.resources` (the pure fold, K3); the
|
|
57
|
+
* broader levels read from the ledger projection.
|
|
58
|
+
*/
|
|
59
|
+
export declare function nestedBudgetMiddleware(options: NestedBudgetOptions): EffectMiddleware;
|
|
60
|
+
//# sourceMappingURL=nestedBudget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nestedBudget.d.ts","sourceRoot":"","sources":["../src/nestedBudget.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAChF,OAAO,EACL,KAAK,MAAM,EACX,KAAK,OAAO,EAEZ,KAAK,aAAa,EAInB,MAAM,YAAY,CAAC;AAcpB,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAEhB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAKD;;;;;;GAMG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiC;IAEvD,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,IAAI;IA4BjD,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,UAAU;IAInC,sFAAsF;IACtF,YAAY,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI;CAqBnE;AAED,mEAAmE;AACnE,MAAM,WAAW,WAAW;IAC1B,qFAAqF;IACrF,KAAK,EAAE,MAAM,CAAC;IACd,0FAA0F;IAC1F,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,uDAAuD;IACvD,OAAO,EAAE,OAAO,CAAC;IACjB,qEAAqE;IACrE,MAAM,EAAE,YAAY,CAAC;IACrB;;;;OAIG;IACH,QAAQ,EAAE,CAAC,GAAG,EAAE,aAAa,KAAK,WAAW,EAAE,CAAC;IAChD,8CAA8C;IAC9C,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;CACtB;AAaD;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,mBAAmB,GAAG,gBAAgB,CAkDrF"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BudgetLedger = void 0;
|
|
4
|
+
exports.nestedBudgetMiddleware = nestedBudgetMiddleware;
|
|
5
|
+
const metering_1 = require("./metering");
|
|
6
|
+
/** Distinct custom dimension names one ledger scope keeps (the fold's own cap, ADR 0115). */
|
|
7
|
+
const LEDGER_CUSTOM_MAX = 32;
|
|
8
|
+
/**
|
|
9
|
+
* A projection of spend per scope key (e.g. `tenant:acme`, `workflow:audit`).
|
|
10
|
+
* The app updates it from the RESOURCE_METERED event stream (across all of a
|
|
11
|
+
* tenant's executions) — typically in `defineConfig({ onEvent })`. It is
|
|
12
|
+
* eventually consistent by nature; two nodes may briefly under-count, which is
|
|
13
|
+
* the accepted trade-off for cross-execution budgets.
|
|
14
|
+
*/
|
|
15
|
+
class BudgetLedger {
|
|
16
|
+
spend = new Map();
|
|
17
|
+
add(scopeKey, delta) {
|
|
18
|
+
const cur = this.spend.get(scopeKey) ?? { tokens: 0, costUsd: 0 };
|
|
19
|
+
const memBytes = (cur.memBytes ?? 0) + (delta.memBytes ?? 0);
|
|
20
|
+
const wallMs = (cur.wallMs ?? 0) + (delta.wallMs ?? 0);
|
|
21
|
+
const netBytes = (cur.netBytes ?? 0) + (delta.netBytes ?? 0);
|
|
22
|
+
let custom = cur.custom;
|
|
23
|
+
if (delta.custom !== undefined) {
|
|
24
|
+
custom = { ...cur.custom };
|
|
25
|
+
for (const [name, v] of Object.entries(delta.custom)) {
|
|
26
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0)
|
|
27
|
+
continue;
|
|
28
|
+
if (name === "constructor" || name === "prototype" || !(0, metering_1.isCustomDimensionName)(name))
|
|
29
|
+
continue;
|
|
30
|
+
if (!Object.hasOwn(custom, name) && Object.keys(custom).length >= LEDGER_CUSTOM_MAX) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
custom[name] = (Object.hasOwn(custom, name) ? custom[name] : 0) + v;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
this.spend.set(scopeKey, {
|
|
37
|
+
tokens: cur.tokens + (delta.tokens ?? 0),
|
|
38
|
+
...(custom !== undefined && { custom }),
|
|
39
|
+
costUsd: cur.costUsd + (delta.costUsd ?? 0),
|
|
40
|
+
...(memBytes > 0 && { memBytes }),
|
|
41
|
+
...(wallMs > 0 && { wallMs }),
|
|
42
|
+
...(netBytes > 0 && { netBytes }),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
spent(scopeKey) {
|
|
46
|
+
return this.spend.get(scopeKey) ?? { tokens: 0, costUsd: 0 };
|
|
47
|
+
}
|
|
48
|
+
/** Fold a RESOURCE_METERED event into the given scope keys (from an onEvent hook). */
|
|
49
|
+
applyMetered(scopeKeys, payload) {
|
|
50
|
+
const p = payload;
|
|
51
|
+
const delta = {
|
|
52
|
+
...(p.custom !== undefined &&
|
|
53
|
+
typeof p.custom === "object" &&
|
|
54
|
+
p.custom !== null && { custom: p.custom }),
|
|
55
|
+
...(p.tokens !== undefined && { tokens: p.tokens }),
|
|
56
|
+
...(p.costUsd !== undefined && { costUsd: p.costUsd }),
|
|
57
|
+
...(p.memBytes !== undefined && { memBytes: p.memBytes }),
|
|
58
|
+
...(p.wallMs !== undefined && { wallMs: p.wallMs }),
|
|
59
|
+
...(p.netBytes !== undefined && { netBytes: p.netBytes }),
|
|
60
|
+
};
|
|
61
|
+
for (const key of scopeKeys)
|
|
62
|
+
this.add(key, delta);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
exports.BudgetLedger = BudgetLedger;
|
|
66
|
+
function over(spent, b) {
|
|
67
|
+
return ((b.maxCostUsd !== undefined && spent.costUsd >= b.maxCostUsd) ||
|
|
68
|
+
(b.maxTokens !== undefined && spent.tokens >= b.maxTokens) ||
|
|
69
|
+
(b.maxMemBytes !== undefined && (spent.memBytes ?? 0) >= b.maxMemBytes) ||
|
|
70
|
+
(b.maxWallMs !== undefined && (spent.wallMs ?? 0) >= b.maxWallMs) ||
|
|
71
|
+
(b.maxNetBytes !== undefined && (spent.netBytes ?? 0) >= b.maxNetBytes) ||
|
|
72
|
+
(0, metering_1.overCustomBudget)(spent.custom, b.maxCustom) !== null);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* K12.3 — nested-budget admission control. Before an effect runs, every scope
|
|
76
|
+
* (tenant → workflow → execution) is checked; the FIRST (most-restrictive)
|
|
77
|
+
* breach denies with a `QUOTA_EXCEEDED` fact attributed to that `level`, and the
|
|
78
|
+
* effect never runs. Otherwise the effect runs, is priced, and its delta is
|
|
79
|
+
* recorded into the ledger's non-execution scopes (the execution level is
|
|
80
|
+
* metered by the durable fold as usual) and emitted as `RESOURCE_METERED`.
|
|
81
|
+
*
|
|
82
|
+
* The execution level reads spend from `ctx.resources` (the pure fold, K3); the
|
|
83
|
+
* broader levels read from the ledger projection.
|
|
84
|
+
*/
|
|
85
|
+
function nestedBudgetMiddleware(options) {
|
|
86
|
+
const now = options.clock ?? (() => Date.now());
|
|
87
|
+
return async (ctx, input, next) => {
|
|
88
|
+
const scopes = options.scopesOf(ctx);
|
|
89
|
+
// Scopes are resolved per effect, so their budgets cannot be checked at construction; refuse a
|
|
90
|
+
// bad `maxCustom` (built-in name / non-numeric ceiling) here, before any scope is evaluated,
|
|
91
|
+
// rather than part-way through the admission loop. Not memoised: a mutation cannot bypass it.
|
|
92
|
+
for (const scope of scopes)
|
|
93
|
+
(0, metering_1.assertMaxCustom)(scope.budget.maxCustom);
|
|
94
|
+
// Admission control — most-restrictive wins, attributed to its level.
|
|
95
|
+
for (const scope of scopes) {
|
|
96
|
+
const spent = scope.level === "execution"
|
|
97
|
+
? {
|
|
98
|
+
tokens: ctx.resources?.tokens ?? 0,
|
|
99
|
+
costUsd: ctx.resources?.costUsd ?? 0,
|
|
100
|
+
...(ctx.resources?.memBytes !== undefined && { memBytes: ctx.resources.memBytes }),
|
|
101
|
+
...(ctx.resources?.wallMs !== undefined && { wallMs: ctx.resources.wallMs }),
|
|
102
|
+
...(ctx.resources?.netBytes !== undefined && { netBytes: ctx.resources.netBytes }),
|
|
103
|
+
...(ctx.resources?.custom !== undefined && {
|
|
104
|
+
custom: ctx.resources.custom,
|
|
105
|
+
}),
|
|
106
|
+
}
|
|
107
|
+
: options.ledger.spent(scope.key);
|
|
108
|
+
if (over(spent, scope.budget)) {
|
|
109
|
+
const reason = `${scope.level} budget exceeded (${scope.key || ctx.executionId})`;
|
|
110
|
+
ctx.emit("QUOTA_EXCEEDED", { stepId: ctx.stepId, reason, level: scope.level });
|
|
111
|
+
throw new metering_1.QuotaExceededError(reason);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const start = now();
|
|
115
|
+
const result = await next();
|
|
116
|
+
const ms = now() - start;
|
|
117
|
+
const delta = options.pricing({ stepId: ctx.stepId, input, result, ms });
|
|
118
|
+
ctx.emit("RESOURCE_METERED", {
|
|
119
|
+
...(delta.tokens !== undefined && { tokens: delta.tokens }),
|
|
120
|
+
...(delta.costUsd !== undefined && { costUsd: delta.costUsd }),
|
|
121
|
+
...(delta.custom !== undefined && { custom: delta.custom }),
|
|
122
|
+
ms: delta.ms ?? ms,
|
|
123
|
+
});
|
|
124
|
+
// Update the projection for the broader (non-execution) levels immediately,
|
|
125
|
+
// so within a node successive effects see the running total (the durable
|
|
126
|
+
// fold + cross-node ledger reconcile via the RESOURCE_METERED stream).
|
|
127
|
+
for (const scope of scopes) {
|
|
128
|
+
if (scope.level !== "execution")
|
|
129
|
+
options.ledger.add(scope.key, delta);
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=nestedBudget.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nestedBudget.js","sourceRoot":"","sources":["../src/nestedBudget.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { EffectMiddleware } from "@loomwork/effect-runtime";
|
|
2
|
+
/** Effect-side timings, in milliseconds. */
|
|
3
|
+
export interface EffectProfile {
|
|
4
|
+
/** Effect invocations that completed (successfully or not). */
|
|
5
|
+
calls: number;
|
|
6
|
+
/** Summed wall time inside effects. */
|
|
7
|
+
totalEffectMs: number;
|
|
8
|
+
/** Longest single effect. */
|
|
9
|
+
maxEffectMs: number;
|
|
10
|
+
/** Greatest number of effects in flight at once. */
|
|
11
|
+
peakConcurrentEffects: number;
|
|
12
|
+
/** Per-tool breakdown, so a single slow tool is visible rather than averaged away. */
|
|
13
|
+
byTool: Record<string, {
|
|
14
|
+
calls: number;
|
|
15
|
+
totalMs: number;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export interface EffectProfiler {
|
|
19
|
+
middleware: EffectMiddleware;
|
|
20
|
+
profile(): EffectProfile;
|
|
21
|
+
reset(): void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Measures what effects actually cost, as the counterpart to `ProfilingEventStore`.
|
|
25
|
+
*
|
|
26
|
+
* Together they answer [ADR 0065](../../../docs/adr/0065-group-commit-deferred-evidence-gate.md)
|
|
27
|
+
* gate 1: is **event-write throughput** the bottleneck for this workload, or is it effect
|
|
28
|
+
* latency? The gate requires that question be settled by profiling rather than intuition,
|
|
29
|
+
* and it is not rhetorical — `ExecutionJournal` queues appends on a serial tail, so the
|
|
30
|
+
* interpreter is decoupled from write latency and a workload can write a great deal without
|
|
31
|
+
* writes ever constraining it.
|
|
32
|
+
*
|
|
33
|
+
* Wire it as effect-runtime middleware in your own app, wrap your store in
|
|
34
|
+
* `ProfilingEventStore`, run real traffic, and compare `totalEffectMs` against
|
|
35
|
+
* `totalWriteMs`. Batching the write path can only help if the latter dominates.
|
|
36
|
+
*/
|
|
37
|
+
export declare function effectTimingMiddleware(): EffectProfiler;
|
|
38
|
+
//# sourceMappingURL=profile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"profile.d.ts","sourceRoot":"","sources":["../src/profile.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAEjE,4CAA4C;AAC5C,MAAM,WAAW,aAAa;IAC5B,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,aAAa,EAAE,MAAM,CAAC;IACtB,6BAA6B;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,oDAAoD;IACpD,qBAAqB,EAAE,MAAM,CAAC;IAC9B,sFAAsF;IACtF,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC5D;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,gBAAgB,CAAC;IAC7B,OAAO,IAAI,aAAa,CAAC;IACzB,KAAK,IAAI,IAAI,CAAC;CACf;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,IAAI,cAAc,CAiDvD"}
|
package/dist/profile.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.effectTimingMiddleware = effectTimingMiddleware;
|
|
4
|
+
/**
|
|
5
|
+
* Measures what effects actually cost, as the counterpart to `ProfilingEventStore`.
|
|
6
|
+
*
|
|
7
|
+
* Together they answer [ADR 0065](../../../docs/adr/0065-group-commit-deferred-evidence-gate.md)
|
|
8
|
+
* gate 1: is **event-write throughput** the bottleneck for this workload, or is it effect
|
|
9
|
+
* latency? The gate requires that question be settled by profiling rather than intuition,
|
|
10
|
+
* and it is not rhetorical — `ExecutionJournal` queues appends on a serial tail, so the
|
|
11
|
+
* interpreter is decoupled from write latency and a workload can write a great deal without
|
|
12
|
+
* writes ever constraining it.
|
|
13
|
+
*
|
|
14
|
+
* Wire it as effect-runtime middleware in your own app, wrap your store in
|
|
15
|
+
* `ProfilingEventStore`, run real traffic, and compare `totalEffectMs` against
|
|
16
|
+
* `totalWriteMs`. Batching the write path can only help if the latter dominates.
|
|
17
|
+
*/
|
|
18
|
+
function effectTimingMiddleware() {
|
|
19
|
+
let calls = 0;
|
|
20
|
+
let totalEffectMs = 0;
|
|
21
|
+
let maxEffectMs = 0;
|
|
22
|
+
let inFlight = 0;
|
|
23
|
+
let peakConcurrentEffects = 0;
|
|
24
|
+
const byTool = new Map();
|
|
25
|
+
const middleware = async (ctx, input, next) => {
|
|
26
|
+
const started = performance.now();
|
|
27
|
+
inFlight++;
|
|
28
|
+
if (inFlight > peakConcurrentEffects)
|
|
29
|
+
peakConcurrentEffects = inFlight;
|
|
30
|
+
try {
|
|
31
|
+
return await next(input);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
const elapsed = performance.now() - started;
|
|
35
|
+
inFlight--;
|
|
36
|
+
calls++;
|
|
37
|
+
totalEffectMs += elapsed;
|
|
38
|
+
if (elapsed > maxEffectMs)
|
|
39
|
+
maxEffectMs = elapsed;
|
|
40
|
+
// `stepId` is the tool identity at this seam; fall back so an unnamed effect still
|
|
41
|
+
// shows up rather than vanishing from the breakdown.
|
|
42
|
+
const tool = ctx.stepId || "<unknown>";
|
|
43
|
+
const entry = byTool.get(tool);
|
|
44
|
+
if (entry === undefined)
|
|
45
|
+
byTool.set(tool, { calls: 1, totalMs: elapsed });
|
|
46
|
+
else {
|
|
47
|
+
entry.calls++;
|
|
48
|
+
entry.totalMs += elapsed;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
middleware,
|
|
54
|
+
profile: () => ({
|
|
55
|
+
calls,
|
|
56
|
+
totalEffectMs,
|
|
57
|
+
maxEffectMs,
|
|
58
|
+
peakConcurrentEffects,
|
|
59
|
+
byTool: Object.fromEntries([...byTool].map(([k, v]) => [k, { ...v }])),
|
|
60
|
+
}),
|
|
61
|
+
reset: () => {
|
|
62
|
+
calls = 0;
|
|
63
|
+
totalEffectMs = 0;
|
|
64
|
+
maxEffectMs = 0;
|
|
65
|
+
peakConcurrentEffects = 0;
|
|
66
|
+
byTool.clear();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=profile.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"profile.js","sourceRoot":"","sources":["../src/profile.ts"],"names":[],"mappings":";;AAoCA,wDAiDC;AA/DD;;;;;;;;;;;;;GAaG;AACH,SAAgB,sBAAsB;IACpC,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,IAAI,aAAa,GAAG,CAAC,CAAC;IACtB,IAAI,WAAW,GAAG,CAAC,CAAC;IACpB,IAAI,QAAQ,GAAG,CAAC,CAAC;IACjB,IAAI,qBAAqB,GAAG,CAAC,CAAC;IAC9B,MAAM,MAAM,GAAG,IAAI,GAAG,EAA8C,CAAC;IAErE,MAAM,UAAU,GAAqB,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;QAC9D,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC;QAClC,QAAQ,EAAE,CAAC;QACX,IAAI,QAAQ,GAAG,qBAAqB;YAAE,qBAAqB,GAAG,QAAQ,CAAC;QACvE,IAAI,CAAC;YACH,OAAO,MAAM,IAAI,CAAC,KAAK,CAAC,CAAC;QAC3B,CAAC;gBAAS,CAAC;YACT,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC;YAC5C,QAAQ,EAAE,CAAC;YACX,KAAK,EAAE,CAAC;YACR,aAAa,IAAI,OAAO,CAAC;YACzB,IAAI,OAAO,GAAG,WAAW;gBAAE,WAAW,GAAG,OAAO,CAAC;YACjD,mFAAmF;YACnF,qDAAqD;YACrD,MAAM,IAAI,GAAG,GAAG,CAAC,MAAM,IAAI,WAAW,CAAC;YACvC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YAC/B,IAAI,KAAK,KAAK,SAAS;gBAAE,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,CAAC;iBACrE,CAAC;gBACJ,KAAK,CAAC,KAAK,EAAE,CAAC;gBACd,KAAK,CAAC,OAAO,IAAI,OAAO,CAAC;YAC3B,CAAC;QACH,CAAC;IACH,CAAC,CAAC;IAEF,OAAO;QACL,UAAU;QACV,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;YACd,KAAK;YACL,aAAa;YACb,WAAW;YACX,qBAAqB;YACrB,MAAM,EAAE,MAAM,CAAC,WAAW,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;SACvE,CAAC;QACF,KAAK,EAAE,GAAG,EAAE;YACV,KAAK,GAAG,CAAC,CAAC;YACV,aAAa,GAAG,CAAC,CAAC;YAClB,WAAW,GAAG,CAAC,CAAC;YAChB,qBAAqB,GAAG,CAAC,CAAC;YAC1B,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { EffectMiddleware } from "@loomwork/effect-runtime";
|
|
2
|
+
/** Which requests share one bucket. */
|
|
3
|
+
export type RateLimitScope = "execution" | "tenant" | "workflow" | "global";
|
|
4
|
+
export interface RateLimitOptions {
|
|
5
|
+
/** Bucket size: the burst a scope may issue at once. Must be a positive integer. */
|
|
6
|
+
capacity: number;
|
|
7
|
+
/** Sustained rate: tokens added per second. Must be positive. */
|
|
8
|
+
refillPerSecond: number;
|
|
9
|
+
/**
|
|
10
|
+
* Who shares a bucket. Default `"execution"` (one runaway execution cannot starve the
|
|
11
|
+
* rest). `"tenant"` keys on `ctx.principal.tenant` — a request with no tenant shares ONE
|
|
12
|
+
* `"(no-tenant)"` bucket rather than escaping the limit. `"workflow"` keys on the flow id
|
|
13
|
+
* (the per-agent case); `"global"` is one bucket for the process.
|
|
14
|
+
*/
|
|
15
|
+
scope?: RateLimitScope;
|
|
16
|
+
/** Injectable clock in ms (tests); defaults to `Date.now`. */
|
|
17
|
+
clock?: () => number;
|
|
18
|
+
/**
|
|
19
|
+
* Upper bound on live buckets. A fresh bucket is created per execution/tenant key, so an
|
|
20
|
+
* unbounded map would be a leak; the least-recently-used bucket is evicted past this size
|
|
21
|
+
* (an evicted key simply starts full again — it can only err toward permitting). Default 10_000.
|
|
22
|
+
*/
|
|
23
|
+
maxBuckets?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The typed, NON-retryable refusal for a rate-limit trip. It is the control plane speaking,
|
|
27
|
+
* not a transient fault: retrying immediately repeats it, so callers that classify errors by
|
|
28
|
+
* `name` (loom-ui's `isControlPlaneError`) must treat `"RateLimitExceededError"` as a refusal.
|
|
29
|
+
*/
|
|
30
|
+
export declare class RateLimitExceededError extends Error {
|
|
31
|
+
readonly scope: RateLimitScope;
|
|
32
|
+
readonly key: string;
|
|
33
|
+
/** Milliseconds until one token is available again (a hint, never a promise). */
|
|
34
|
+
readonly retryAfterMs: number;
|
|
35
|
+
/** Honoured by `retryMiddleware`'s default classifier: an immediate retry repeats the refusal. */
|
|
36
|
+
readonly retryable = false;
|
|
37
|
+
constructor(scope: RateLimitScope, key: string, retryAfterMs: number);
|
|
38
|
+
}
|
|
39
|
+
export declare function rateLimitMiddleware(options: RateLimitOptions): EffectMiddleware;
|
|
40
|
+
//# sourceMappingURL=rateLimit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rateLimit.d.ts","sourceRoot":"","sources":["../src/rateLimit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAgBhF,uCAAuC;AACvC,MAAM,MAAM,cAAc,GAAG,WAAW,GAAG,QAAQ,GAAG,UAAU,GAAG,QAAQ,CAAC;AAE5E,MAAM,WAAW,gBAAgB;IAC/B,oFAAoF;IACpF,QAAQ,EAAE,MAAM,CAAC;IACjB,iEAAiE;IACjE,eAAe,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;IACrB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;IAC/C,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,kGAAkG;IAClG,QAAQ,CAAC,SAAS,SAAS;gBACf,KAAK,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM;CAOrE;AAoBD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,gBAAgB,GAAG,gBAAgB,CA6C/E"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RateLimitExceededError = void 0;
|
|
4
|
+
exports.rateLimitMiddleware = rateLimitMiddleware;
|
|
5
|
+
/**
|
|
6
|
+
* The typed, NON-retryable refusal for a rate-limit trip. It is the control plane speaking,
|
|
7
|
+
* not a transient fault: retrying immediately repeats it, so callers that classify errors by
|
|
8
|
+
* `name` (loom-ui's `isControlPlaneError`) must treat `"RateLimitExceededError"` as a refusal.
|
|
9
|
+
*/
|
|
10
|
+
class RateLimitExceededError extends Error {
|
|
11
|
+
scope;
|
|
12
|
+
key;
|
|
13
|
+
/** Milliseconds until one token is available again (a hint, never a promise). */
|
|
14
|
+
retryAfterMs;
|
|
15
|
+
/** Honoured by `retryMiddleware`'s default classifier: an immediate retry repeats the refusal. */
|
|
16
|
+
retryable = false;
|
|
17
|
+
constructor(scope, key, retryAfterMs) {
|
|
18
|
+
super(`Rate limit exceeded for ${scope} "${key}" — retry after ~${retryAfterMs}ms`);
|
|
19
|
+
this.name = "RateLimitExceededError";
|
|
20
|
+
this.scope = scope;
|
|
21
|
+
this.key = key;
|
|
22
|
+
this.retryAfterMs = retryAfterMs;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.RateLimitExceededError = RateLimitExceededError;
|
|
26
|
+
function keyOf(scope, ctx) {
|
|
27
|
+
switch (scope) {
|
|
28
|
+
case "execution":
|
|
29
|
+
return ctx.executionId;
|
|
30
|
+
case "workflow":
|
|
31
|
+
return ctx.workflowId;
|
|
32
|
+
case "tenant":
|
|
33
|
+
return ctx.principal?.tenant ?? "(no-tenant)";
|
|
34
|
+
case "global":
|
|
35
|
+
return "*";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function rateLimitMiddleware(options) {
|
|
39
|
+
const { capacity, refillPerSecond } = options;
|
|
40
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
41
|
+
throw new Error(`rateLimitMiddleware: capacity must be a positive integer (got ${capacity})`);
|
|
42
|
+
}
|
|
43
|
+
if (!(refillPerSecond > 0) || !Number.isFinite(refillPerSecond)) {
|
|
44
|
+
throw new Error(`rateLimitMiddleware: refillPerSecond must be a positive number (got ${refillPerSecond})`);
|
|
45
|
+
}
|
|
46
|
+
const scope = options.scope ?? "execution";
|
|
47
|
+
const now = options.clock ?? (() => Date.now());
|
|
48
|
+
const maxBuckets = options.maxBuckets ?? 10_000;
|
|
49
|
+
// Map iteration order is insertion order, so delete+set on every touch keeps the OLDEST
|
|
50
|
+
// key first — an O(1) LRU without a second structure.
|
|
51
|
+
const buckets = new Map();
|
|
52
|
+
return async (ctx, _input, next) => {
|
|
53
|
+
const key = keyOf(scope, ctx);
|
|
54
|
+
const t = now();
|
|
55
|
+
const bucket = buckets.get(key) ?? { tokens: capacity, at: t };
|
|
56
|
+
buckets.delete(key);
|
|
57
|
+
const elapsedSec = Math.max(0, t - bucket.at) / 1000;
|
|
58
|
+
bucket.tokens = Math.min(capacity, bucket.tokens + elapsedSec * refillPerSecond);
|
|
59
|
+
bucket.at = t;
|
|
60
|
+
const admitted = bucket.tokens >= 1;
|
|
61
|
+
if (admitted)
|
|
62
|
+
bucket.tokens -= 1;
|
|
63
|
+
buckets.set(key, bucket);
|
|
64
|
+
if (buckets.size > maxBuckets) {
|
|
65
|
+
const oldest = buckets.keys().next().value;
|
|
66
|
+
if (oldest !== undefined)
|
|
67
|
+
buckets.delete(oldest);
|
|
68
|
+
}
|
|
69
|
+
if (!admitted) {
|
|
70
|
+
const retryAfterMs = Math.ceil(((1 - bucket.tokens) / refillPerSecond) * 1000);
|
|
71
|
+
ctx.emit("SAFETY_BLOCKED", {
|
|
72
|
+
stepId: ctx.stepId,
|
|
73
|
+
validatorId: "rate-limit",
|
|
74
|
+
phase: "input",
|
|
75
|
+
reason: `rate limit exceeded for ${scope} "${key}"`,
|
|
76
|
+
category: "rate-limit",
|
|
77
|
+
});
|
|
78
|
+
throw new RateLimitExceededError(scope, key, retryAfterMs);
|
|
79
|
+
}
|
|
80
|
+
return next();
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=rateLimit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rateLimit.js","sourceRoot":"","sources":["../src/rateLimit.ts"],"names":[],"mappings":"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"}
|