@naced/llm-multi-rate-limiter 0.0.0-stage → 0.1.2

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/lib/index.js ADDED
@@ -0,0 +1,552 @@
1
+ /**
2
+ * dsh-llm-multi-rate-limiter — Host (server-side) entry point.
3
+ *
4
+ * Intercepts the `llm/stream` waterfall and enforces per-model rate limits.
5
+ *
6
+ * Configuration is the profile ENTRY's own `config` (this module's exported
7
+ * {@link Config} schema), read through the volatile references Cordis builds
8
+ * for it. `dsh-settings` projects the volatile fields of every active entry
9
+ * into a form the browser can edit, so the plugin ships no settings service
10
+ * of its own. (DSH 0.1.5 had a `ctx.settings.register()` namespace API; 0.1.7
11
+ * removed it, and its browser half dropped `settingsScope` for `configForms`.
12
+ * The migration record lives in CHANGELOG.md under `[0.3.0]`.)
13
+ *
14
+ * The `llm/stream` waterfall signature is:
15
+ * (options: GenerateOptions, next: () => AsyncIterable<StreamChunk>) => AsyncIterable<StreamChunk>
16
+ *
17
+ * where `options` always has `.provider` (string) and `.model` (string).
18
+ *
19
+ * @module dsh-llm-multi-rate-limiter
20
+ */
21
+
22
+ import { TokenBucketStrategy } from "./strategies/token-bucket.js";
23
+ import { SlidingWindowStrategy } from "./strategies/sliding-window.js";
24
+ import { Config } from "./types/config.js";
25
+ import {
26
+ CooldownTracker,
27
+ resolveModelConfig,
28
+ scaleConfig,
29
+ } from "./config-resolve.js";
30
+ import { CHANNEL, createChannelRoute, createStatusChannel } from "./status-rpc.js";
31
+
32
+ const name = "llm-multi-rate-limiter";
33
+ /**
34
+ * Settings namespace / profile entry id.
35
+ *
36
+ * `dsh-settings` keys every form by `entry.options.id` (`lib/index.js:432`),
37
+ * which is the `id` this plugin's `cordis.patch.yml` row declares. Renaming
38
+ * that row id therefore renames this namespace too — keep the two in step.
39
+ */
40
+ const SETTINGS_NS = "llm-multi-rate-limiter";
41
+
42
+ /** Maximum retained events in the status ring (bounded memory). */
43
+ const EVENT_RING_LIMIT = 64;
44
+ /** Events carried by one snapshot (the panel shows these). */
45
+ const SNAPSHOT_EVENT_COUNT = 8;
46
+ /** How often idle, unconfigured limiters are dropped from the map. */
47
+ const IDLE_SWEEP_MS = 60_000;
48
+ /** A limiter with no override and no activity for this long is released. */
49
+ const IDLE_LIMITER_MS = 10 * 60_000;
50
+
51
+ /* ── Helpers ─────────────────────────────────────────────────────────── */
52
+
53
+ /** Built-in defaults, used when a reference yields nothing (or no config at all). */
54
+ const FALLBACK = {
55
+ enabled: true,
56
+ strategy: "token-bucket",
57
+ defaults: { maxConcurrent: 5, maxRpm: 60, burstSize: 10, refillRate: 1 },
58
+ providers: {},
59
+ models: {},
60
+ cooldown: { enabled: true, cooldownMs: 30_000, factor: 0.5, recoverAfter: 3 },
61
+ onThrottled: "queue",
62
+ maxQueueWaitMs: 60_000,
63
+ };
64
+
65
+ /**
66
+ * Read one volatile reference from the parsed Config.
67
+ *
68
+ * The loader mutates volatile references IN PLACE when only raw config values
69
+ * changed (`cordis-plugin-loader/lib/index.js:393` `_commitVolatile()`), so
70
+ * the reference object stays valid for the plugin's whole lifetime and its
71
+ * `get()` always yields the current value. Call this at each use rather than
72
+ * caching the unwrapped value.
73
+ *
74
+ * @param ref - a `.volatile()` field of the parsed Config, or undefined when
75
+ * the entry was mounted with no config at all.
76
+ * @param fallback - value used when the reference is absent.
77
+ * @returns the current plain value.
78
+ */
79
+ function read(ref, fallback) {
80
+ if (ref === undefined || ref === null) return fallback;
81
+ if (typeof ref.get === "function") {
82
+ const value = ref.get();
83
+ return value === undefined || value === null ? fallback : value;
84
+ }
85
+ return ref;
86
+ }
87
+
88
+ /**
89
+ * Ensure a rate-limiting strategy instance exists (or is updated) for `key`.
90
+ *
91
+ * @param {Map<string, TokenBucketStrategy | SlidingWindowStrategy>} limiters
92
+ * @param {string} key
93
+ * @param {ReturnType<typeof resolveModelConfig>} merged resolved tier config
94
+ * @param {string} strategyName
95
+ */
96
+ function ensureLimiter(limiters, key, merged, strategyName) {
97
+ const existing = limiters.get(key);
98
+ if (existing) {
99
+ existing.update(merged);
100
+ return existing;
101
+ }
102
+ const strategy = strategyName === "sliding-window"
103
+ ? new SlidingWindowStrategy(merged)
104
+ : new TokenBucketStrategy(merged);
105
+ limiters.set(key, strategy);
106
+ return strategy;
107
+ }
108
+
109
+ /**
110
+ * Build a terminal StreamChunk for rate-limiting failures.
111
+ *
112
+ * The failure code matters: `dsh-llm-retry` only retries the codes its policy
113
+ * lists (by default `RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`,
114
+ * `EMPTY_RESPONSE`). A queue timeout is a LOCAL decision — retrying it just
115
+ * parks the caller in another queue — so it carries its own code, which no
116
+ * default policy retries. A provider-side 429 keeps `RATE_LIMIT`, because
117
+ * retrying THAT is exactly what the retry plugin is for.
118
+ *
119
+ * `kind: "aborted"` is used when the caller's AbortSignal fired, so the agent
120
+ * loop treats it as a cancellation rather than a failure.
121
+ */
122
+ function terminalChunk(modelKey, code, signal) {
123
+ const aborted = signal?.aborted === true;
124
+ const failure = {
125
+ code,
126
+ message: aborted
127
+ ? `Rate limit: ${modelKey} (aborted while waiting)`
128
+ : `Rate limit: ${modelKey} was not granted a slot in time`,
129
+ };
130
+ return {
131
+ type: "finish",
132
+ reason: aborted ? { kind: "aborted", failure } : { kind: "error", failure },
133
+ };
134
+ }
135
+
136
+ /** True when a stream chunk reports that the PROVIDER throttled the call. */
137
+ function isProviderRateLimit(chunk) {
138
+ return chunk?.type === "finish"
139
+ && chunk.reason?.kind === "error"
140
+ && chunk.reason?.failure?.code === "RATE_LIMIT";
141
+ }
142
+
143
+ /** Optional `retryAfterMs` hint carried by a provider failure, when present. */
144
+ function retryAfterOf(chunk) {
145
+ const hint = chunk?.reason?.failure?.retryAfterMs;
146
+ return Number.isFinite(hint) && hint > 0 ? hint : undefined;
147
+ }
148
+
149
+ /* ── Plugin entry ────────────────────────────────────────────────────── */
150
+
151
+ /**
152
+ * @param {import("@deepseek-ai/cordis").Context} ctx
153
+ * @param {object} [config] - parsed Config; volatile fields are live references.
154
+ */
155
+ function apply(ctx, config) {
156
+ const logger = ctx.logger?.("rate-limiter");
157
+
158
+ /**
159
+ * Snapshot the whole config as one plain object, reading each reference at
160
+ * call time. Derived settings are not cached: a volatile-only change updates
161
+ * the references in place without re-running `apply`.
162
+ */
163
+ function readConfig() {
164
+ return {
165
+ enabled: read(config?.enabled, FALLBACK.enabled),
166
+ strategy: read(config?.strategy, FALLBACK.strategy),
167
+ defaults: read(config?.defaults, FALLBACK.defaults),
168
+ providers: read(config?.providers, FALLBACK.providers),
169
+ models: read(config?.models, FALLBACK.models),
170
+ cooldown: read(config?.cooldown, FALLBACK.cooldown),
171
+ onThrottled: read(config?.onThrottled, FALLBACK.onThrottled),
172
+ maxQueueWaitMs: read(config?.maxQueueWaitMs, FALLBACK.maxQueueWaitMs),
173
+ };
174
+ }
175
+
176
+ /** @type {Map<string, TokenBucketStrategy | SlidingWindowStrategy>} */
177
+ const limiters = new Map();
178
+ /** Adaptive back-off state after provider 429s. */
179
+ const cooldown = new CooldownTracker(FALLBACK.cooldown);
180
+
181
+ // ── Live statistics (read by the status RPC channel) ───────
182
+ // Counters are plain numbers so a snapshot is always JSON-safe.
183
+ const totals = {
184
+ requests: 0,
185
+ granted: 0,
186
+ rejected: 0,
187
+ timeouts: 0,
188
+ aborted: 0,
189
+ throttledByProvider: 0,
190
+ totalWaitMs: 0,
191
+ };
192
+ /** Bumped on every recorded event and on reset — the panel's change cursor. */
193
+ let rev = 0;
194
+ /** Bounded ring of recent events (newest last). */
195
+ const eventRing = [];
196
+
197
+ function recordEvent(event) {
198
+ eventRing.push({ ...event, ts: Date.now() });
199
+ if (eventRing.length > EVENT_RING_LIMIT) eventRing.shift();
200
+ rev += 1;
201
+ }
202
+
203
+ /** Build the JSON-safe snapshot served on the "snapshot" endpoint. */
204
+ function buildSnapshot() {
205
+ const cfg = readConfig();
206
+ const models = {};
207
+ for (const [key, limiter] of limiters) {
208
+ try {
209
+ const status = limiter.getStatus();
210
+ const factor = cooldown.factorFor(key);
211
+ models[key] = {
212
+ ...status,
213
+ // Effective limit after any 429 penalty — what the panel should show.
214
+ effectiveMaxRpm: factor < 1 ? Math.max(1, Math.round((status.maxRpm ?? 0) * factor)) : status.maxRpm,
215
+ cooling: cooldown.isCooling(key),
216
+ cooldownRemainingMs: cooldown.remainingMs(key),
217
+ };
218
+ } catch {
219
+ // A strategy that cannot report status must not break the whole panel.
220
+ }
221
+ }
222
+ return {
223
+ rev,
224
+ ts: Date.now(),
225
+ enabled: cfg.enabled !== false,
226
+ strategy: cfg.strategy,
227
+ onThrottled: cfg.onThrottled,
228
+ cooldown: {
229
+ enabled: cfg.cooldown?.enabled !== false,
230
+ models: cooldown.snapshot(),
231
+ },
232
+ totals: { ...totals },
233
+ models,
234
+ events: eventRing.slice(-SNAPSHOT_EVENT_COUNT),
235
+ };
236
+ }
237
+
238
+ /** Zero every counter and drop retained events (the panel's clear button). */
239
+ function resetTotals() {
240
+ for (const key of Object.keys(totals)) totals[key] = 0;
241
+ eventRing.length = 0;
242
+ cooldown.clear();
243
+ rev += 1;
244
+ }
245
+
246
+ /** Drop limiters that no tier configures and nothing has touched for a while. */
247
+ function sweepIdleLimiters() {
248
+ if (limiters.size === 0) return;
249
+ const cfg = readConfig();
250
+ const now = Date.now();
251
+ for (const [key, limiter] of limiters) {
252
+ if (now - (limiter.lastUsedAt ?? 0) < IDLE_LIMITER_MS) continue;
253
+ let status;
254
+ try {
255
+ status = limiter.getStatus();
256
+ } catch {
257
+ status = undefined;
258
+ }
259
+ // Never drop a limiter with work in flight or queued behind it.
260
+ if (status && ((status.concurrent ?? 0) > 0 || (status.reserved ?? 0) > 0 || (status.queued ?? 0) > 0)) continue;
261
+ // A model that still has an explicit override stays visible even when idle.
262
+ if (resolveModelConfig(cfg, key).source !== "defaults") continue;
263
+ try {
264
+ limiter.dispose();
265
+ } catch {
266
+ // Disposal is best-effort; dropping the map entry is what matters.
267
+ }
268
+ limiters.delete(key);
269
+ cooldown.forget(key);
270
+ rev += 1;
271
+ }
272
+ }
273
+
274
+ const sweepTimer = setInterval(sweepIdleLimiters, IDLE_SWEEP_MS);
275
+ // Node's unref keeps the timer from holding the process open on shutdown.
276
+ sweepTimer.unref?.();
277
+
278
+ // ── Settings presentation (dynamic inject) ─────────────────
279
+ // This plugin ships its own browser page, so it opts out of any
280
+ // schema-generated page. Wrapped in ctx.inject so a host WITHOUT a settings
281
+ // service still runs the limiter — the callback simply never runs. The
282
+ // optional child ctx names the plugin fiber the policy belongs to, which is
283
+ // why `ctx.fiber` is passed explicitly (`dsh-settings/lib/index.js:370`).
284
+ ctx.inject(["settings"], (scopedCtx) => {
285
+ if (typeof scopedCtx.settings?.configure !== "function") return; // older/foreign settings seam: keep running
286
+ scopedCtx.effect(
287
+ () => scopedCtx.settings.configure({ auto: false }, ctx.fiber),
288
+ "rate-limiter: settings presentation",
289
+ );
290
+ });
291
+
292
+ // ── Status channel (dynamic inject) ────────────────────────
293
+ // Same defensive shape as the settings wiring: on a host without a
294
+ // connection service the callback never runs and the plugin stays silent.
295
+ // Either path below registers inside an effect owned by this fiber, so
296
+ // unloading the plugin withdraws the channel automatically.
297
+ //
298
+ // Two mounting paths exist, and both speak the same wire protocol, so the
299
+ // browser half never needs to know which one is live:
300
+ //
301
+ // 1. connection.rpc.handle(channel, handler) — the framework's generic
302
+ // channel registry (preferred: the framework owns the route, the
303
+ // request validation, and the withdrawal).
304
+ //
305
+ // 2. a self-registered prefix route reusing connection.requestRejection()
306
+ // — the fallback for hosts where path 1 is broken. DSH 0.1.5-rc.3
307
+ // changed dsh-client-connection's own `inject` from
308
+ // ["webServer", "credentials"] to ["credentials"] while its
309
+ // HostConnectionService.register() still dereferences
310
+ // `owner.webServer`, and Cordis rebinds a cross-fiber service's `ctx`
311
+ // to the *reader's* fiber — so rpc.handle() throws
312
+ // 'cannot get property "webServer" without inject'. Registering the
313
+ // route on our own fiber (which injects webServer below) avoids that
314
+ // while still using the framework's own 403/401 fence.
315
+ // Still unfixed in 0.2.1-rc.2 (dsh-client-connection/lib/index.js:1013
316
+ // keeps `inject = ["credentials"]`), so the fallback still fires.
317
+ ctx.inject(["connection"], (scopedCtx) => {
318
+ const connection = scopedCtx.get("connection");
319
+ const channel = createStatusChannel({ buildSnapshot, resetTotals });
320
+
321
+ // ── Path 1: the framework's generic channel registry ──
322
+ const handle = typeof connection?.rpc?.handle === "function"
323
+ ? connection.rpc.handle.bind(connection.rpc)
324
+ : undefined;
325
+
326
+ if (handle !== undefined) {
327
+ try {
328
+ scopedCtx.effect(() => {
329
+ const unregister = handle(CHANNEL, channel);
330
+ return () => { unregister(); };
331
+ }, "rate-limiter: status rpc channel");
332
+ logger?.info("rate limiter status channel mounted via connection.rpc (%s)", CHANNEL);
333
+ return;
334
+ } catch (err) {
335
+ logger?.warn(
336
+ "connection.rpc channel registration failed (%s); falling back to a self-registered route",
337
+ err instanceof Error ? err.message : String(err),
338
+ );
339
+ }
340
+ }
341
+
342
+ // ── Path 2: self-registered route (needs webServer on THIS fiber) ──
343
+ // The fence is mandatory: without connection.requestRejection() we would
344
+ // be publishing an unauthenticated route, so we refuse to mount at all.
345
+ // Probing once here turns a broken fence into a mount-time refusal instead
346
+ // of a per-request surprise.
347
+ try {
348
+ if (typeof connection?.requestRejection !== "function") {
349
+ logger?.warn("connection.requestRejection unavailable; status channel not mounted");
350
+ return;
351
+ }
352
+ connection.requestRejection({ headers: {} });
353
+ } catch (err) {
354
+ logger?.warn(
355
+ "connection.requestRejection unusable (%s); refusing to mount an unfenced status route",
356
+ err instanceof Error ? err.message : String(err),
357
+ );
358
+ return;
359
+ }
360
+
361
+ scopedCtx.inject(["webServer"], (webCtx) => {
362
+ const webServer = webCtx.get("webServer");
363
+ if (typeof webServer?.register !== "function") return; // no web carrier: degrade silently
364
+
365
+ try {
366
+ webCtx.effect(() => webServer.register(createChannelRoute({
367
+ channel: CHANNEL,
368
+ handler: channel,
369
+ reject: (req) => connection.requestRejection(req),
370
+ })), "rate-limiter: status route (self-registered)");
371
+ } catch (err) {
372
+ logger?.warn("status channel registration failed: %s", err instanceof Error ? err.message : String(err));
373
+ return;
374
+ }
375
+
376
+ logger?.info("rate limiter status channel mounted via self-registered route (%s)", CHANNEL);
377
+ });
378
+ });
379
+
380
+ // ── Duplicate-limiter guard ────────────────────────────────
381
+ // The upstream plugin hooks the same waterfall. With both installed every call
382
+ // is limited twice: the effective rate becomes the stricter of the two, and a
383
+ // queued call can wait through both queues in sequence.
384
+ if (typeof import.meta.resolve === "function") {
385
+ let upstream;
386
+ try {
387
+ upstream = import.meta.resolve("@leaf233/dsh-llm-rate-limiter");
388
+ } catch {
389
+ upstream = undefined; // not installed next to us — the usual case
390
+ }
391
+ if (upstream !== undefined) {
392
+ logger?.warn(
393
+ "both @naced/llm-multi-rate-limiter and @leaf233/dsh-llm-rate-limiter are installed (%s); every call is limited twice — remove one of them",
394
+ upstream,
395
+ );
396
+ }
397
+ }
398
+
399
+ // ── LLM stream interceptor ─────────────────────────────────
400
+ // Reads the live Config on every call, so a settings edit that reaches the
401
+ // loader takes effect on the next request without a restart.
402
+ //
403
+ // Fails open: if config parsing or the limiter itself throws, the call is
404
+ // forwarded untouched. A broken limiter must never take the model offline.
405
+ const disposeLlmListener = ctx.on("llm/stream", async function* rateLimitInterceptor(options, next) {
406
+ const modelKey = `${options.provider}/${options.model}`;
407
+
408
+ /** @type {TokenBucketStrategy | SlidingWindowStrategy | null} */
409
+ let limiter = null;
410
+ let slotTaken = false;
411
+ let passThrough = false;
412
+ /** @type {object | null} */
413
+ let refused = null;
414
+
415
+ try {
416
+ const cfg = readConfig();
417
+ cooldown.configure(cfg.cooldown);
418
+
419
+ if (!cfg.enabled || cfg.models?.[modelKey]?.enabled === false) {
420
+ // Global switch off, or per-model opt-out: zero-touch pass-through.
421
+ passThrough = true;
422
+ } else {
423
+ // Resolve the tier chain (model > provider > defaults) and apply any
424
+ // 429 penalty currently in force for this model.
425
+ const resolved = resolveModelConfig(cfg, modelKey);
426
+ const factor = cooldown.factorFor(modelKey);
427
+ const effective = factor < 1 ? scaleConfig(resolved, factor) : resolved;
428
+
429
+ limiter = ensureLimiter(limiters, modelKey, effective, cfg.strategy);
430
+ limiter.lastUsedAt = Date.now();
431
+ totals.requests += 1;
432
+
433
+ if (cfg.onThrottled === "reject") {
434
+ // Non-blocking check — if at capacity, refuse the call outright.
435
+ // acquireNonBlocking deducts a token AND takes a concurrent slot, so
436
+ // reject mode still enforces the frequency limit, not just concurrency.
437
+ const permit = limiter.acquireNonBlocking();
438
+ if (permit.granted) {
439
+ slotTaken = true;
440
+ totals.granted += 1;
441
+ recordEvent({ event: "granted", model: modelKey, waitMs: 0 });
442
+ } else {
443
+ totals.rejected += 1;
444
+ recordEvent({ event: "rejected", model: modelKey });
445
+ logger?.warn("model %C rejected — rate limit reached", modelKey);
446
+ // Local decision: not a retryable provider condition.
447
+ refused = terminalChunk(modelKey, "RATE_LIMIT_REJECTED", options.signal);
448
+ }
449
+ } else {
450
+ // Queue mode — park until a slot opens.
451
+ const permit = await limiter.acquire({
452
+ signal: options.signal,
453
+ timeoutMs: cfg.maxQueueWaitMs ?? 60_000,
454
+ });
455
+ if (permit.granted) {
456
+ totals.granted += 1;
457
+ totals.totalWaitMs += permit.waitMs ?? 0;
458
+ recordEvent({ event: "granted", model: modelKey, waitMs: permit.waitMs ?? 0 });
459
+ } else {
460
+ const aborted = options.signal?.aborted === true;
461
+ if (aborted) {
462
+ totals.aborted += 1;
463
+ recordEvent({ event: "aborted", model: modelKey, waitMs: permit.waitMs ?? 0 });
464
+ } else {
465
+ totals.timeouts += 1;
466
+ recordEvent({ event: "timeout", model: modelKey, waitMs: permit.waitMs ?? 0 });
467
+ }
468
+ logger?.warn(
469
+ "model %C %s after %d ms",
470
+ modelKey, aborted ? "aborted" : "queue timeout", permit.waitMs,
471
+ );
472
+ // Own code: a queue timeout must not be retried into another queue.
473
+ refused = terminalChunk(modelKey, "RATE_LIMIT_QUEUE_TIMEOUT", options.signal);
474
+ }
475
+ }
476
+ }
477
+ } catch (err) {
478
+ // Fail open. Release a slot taken earlier in this same attempt, then let
479
+ // the real call proceed unthrottled rather than failing it.
480
+ if (slotTaken && limiter !== null) {
481
+ try {
482
+ limiter.releaseSlot();
483
+ } catch {
484
+ // Ignore: the strategy is already in an unknown state.
485
+ }
486
+ }
487
+ logger?.warn(
488
+ "rate limiter bypassed for %C (%s); forwarding the call unthrottled",
489
+ modelKey,
490
+ err instanceof Error ? err.message : String(err),
491
+ );
492
+ limiter = null;
493
+ slotTaken = false;
494
+ passThrough = true;
495
+ }
496
+
497
+ if (refused !== null) {
498
+ yield refused;
499
+ return;
500
+ }
501
+
502
+ if (passThrough || limiter === null) {
503
+ yield* next();
504
+ return;
505
+ }
506
+
507
+ // ── Phase 2: run the real LLM call ─────────────────────────
508
+ // Queue mode does not take a concurrent slot inside acquire().
509
+ if (!slotTaken) limiter.acquireSlot();
510
+ const activeLimiter = limiter;
511
+ try {
512
+ // Pass chunks straight through while watching for a provider 429: the
513
+ // stream stays streaming, and the cooldown learns from the failure.
514
+ for await (const chunk of next()) {
515
+ if (isProviderRateLimit(chunk)) {
516
+ const retryAfterMs = retryAfterOf(chunk);
517
+ const entry = cooldown.recordFailure(modelKey, Date.now(), retryAfterMs);
518
+ totals.throttledByProvider += 1;
519
+ recordEvent({
520
+ event: "throttled",
521
+ model: modelKey,
522
+ cooldownMs: entry === undefined ? 0 : Math.max(0, entry.until - Date.now()),
523
+ });
524
+ logger?.warn(
525
+ "provider throttled %C; slowing to %d%% for %d ms",
526
+ modelKey,
527
+ Math.round((entry?.factor ?? 1) * 100),
528
+ entry === undefined ? 0 : Math.max(0, entry.until - Date.now()),
529
+ );
530
+ }
531
+ yield chunk;
532
+ }
533
+ cooldown.recordSuccess(modelKey);
534
+ } finally {
535
+ activeLimiter.releaseSlot();
536
+ recordEvent({ event: "completed", model: modelKey });
537
+ }
538
+ });
539
+
540
+ // ── Teardown ──
541
+ ctx.effect(() => () => {
542
+ clearInterval(sweepTimer);
543
+ disposeLlmListener();
544
+ for (const limiter of limiters.values()) limiter.dispose();
545
+ limiters.clear();
546
+ cooldown.clear();
547
+ }, "rate-limiter: dispose llm/stream listener");
548
+
549
+ logger?.info("rate limiter active — llm/stream interceptor registered");
550
+ }
551
+
552
+ export { apply, name, SETTINGS_NS, Config };