@naced/llm-multi-rate-limiter 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/CHANGELOG.md +37 -0
- package/LICENSE +22 -0
- package/README.md +67 -2
- package/README.zh.md +72 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +691 -0
- package/lib/index.js +454 -0
- package/lib/status-rpc.js +285 -0
- package/lib/strategies/sliding-window.js +148 -0
- package/lib/strategies/token-bucket.js +168 -0
- package/lib/types/config.js +85 -0
- package/lib/types/index.js +1 -0
- package/package.json +59 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
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 { CHANNEL, createChannelRoute, createStatusChannel } from "./status-rpc.js";
|
|
26
|
+
|
|
27
|
+
const name = "llm-multi-rate-limiter";
|
|
28
|
+
/**
|
|
29
|
+
* Settings namespace / profile entry id.
|
|
30
|
+
*
|
|
31
|
+
* `dsh-settings` keys every form by `entry.options.id` (`lib/index.js:432`),
|
|
32
|
+
* which is the `id` this plugin's `cordis.patch.yml` row declares. Renaming
|
|
33
|
+
* that row id therefore renames this namespace too — keep the two in step.
|
|
34
|
+
*/
|
|
35
|
+
const SETTINGS_NS = "llm-multi-rate-limiter";
|
|
36
|
+
|
|
37
|
+
/** Maximum retained events in the status ring (bounded memory). */
|
|
38
|
+
const EVENT_RING_LIMIT = 64;
|
|
39
|
+
/** Events carried by one snapshot (the panel shows these). */
|
|
40
|
+
const SNAPSHOT_EVENT_COUNT = 8;
|
|
41
|
+
|
|
42
|
+
/* ── Helpers ─────────────────────────────────────────────────────────── */
|
|
43
|
+
|
|
44
|
+
/** Built-in defaults, used when a reference yields nothing (or no config at all). */
|
|
45
|
+
const FALLBACK = {
|
|
46
|
+
enabled: true,
|
|
47
|
+
strategy: "token-bucket",
|
|
48
|
+
defaults: { maxConcurrent: 5, maxRpm: 60, burstSize: 10, refillRate: 1 },
|
|
49
|
+
models: {},
|
|
50
|
+
onThrottled: "queue",
|
|
51
|
+
maxQueueWaitMs: 60_000,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read one volatile reference from the parsed Config.
|
|
56
|
+
*
|
|
57
|
+
* The loader mutates volatile references IN PLACE when only raw config values
|
|
58
|
+
* changed (`cordis-plugin-loader/lib/index.js:393` `_commitVolatile()`), so
|
|
59
|
+
* the reference object stays valid for the plugin's whole lifetime and its
|
|
60
|
+
* `get()` always yields the current value. Call this at each use rather than
|
|
61
|
+
* caching the unwrapped value.
|
|
62
|
+
*
|
|
63
|
+
* @param ref - a `.volatile()` field of the parsed Config, or undefined when
|
|
64
|
+
* the entry was mounted with no config at all.
|
|
65
|
+
* @param fallback - value used when the reference is absent.
|
|
66
|
+
* @returns the current plain value.
|
|
67
|
+
*/
|
|
68
|
+
function read(ref, fallback) {
|
|
69
|
+
if (ref === undefined || ref === null) return fallback;
|
|
70
|
+
if (typeof ref.get === "function") {
|
|
71
|
+
const value = ref.get();
|
|
72
|
+
return value === undefined || value === null ? fallback : value;
|
|
73
|
+
}
|
|
74
|
+
return ref;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Merge defaults + per-model overrides into a flat config object that both
|
|
79
|
+
* strategy constructors understand.
|
|
80
|
+
*
|
|
81
|
+
* @param cfg - plain config object (`{ enabled, strategy, defaults, models, ... }`).
|
|
82
|
+
* @param key - `"provider/model"` of the call being limited.
|
|
83
|
+
*/
|
|
84
|
+
function resolveModelConfig(cfg, key) {
|
|
85
|
+
const o = cfg.models?.[key] ?? {};
|
|
86
|
+
const d = cfg.defaults ?? {};
|
|
87
|
+
const maxRpm = o.maxRpm ?? d.maxRpm ?? 60;
|
|
88
|
+
// When a model overrides maxRpm but not refillRate, derive refillRate from
|
|
89
|
+
// the model's maxRpm — this is the most common case ("set maxRpm=3").
|
|
90
|
+
// When model doesn't override maxRpm, use the defaults' refillRate as-is.
|
|
91
|
+
const refillRate = o.refillRate ?? (o.maxRpm != null ? o.maxRpm / 60 : d.refillRate ?? (maxRpm / 60));
|
|
92
|
+
return {
|
|
93
|
+
maxConcurrent: o.maxConcurrent ?? d.maxConcurrent ?? 5,
|
|
94
|
+
maxRpm,
|
|
95
|
+
burstSize: o.burstSize ?? d.burstSize ?? 10,
|
|
96
|
+
refillRate,
|
|
97
|
+
windowMs: d.windowMs ?? 60_000,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ensure a rate-limiting strategy instance exists (or is updated) for `key`.
|
|
103
|
+
*/
|
|
104
|
+
function ensureLimiter(limiters, key, cfg) {
|
|
105
|
+
const merged = resolveModelConfig(cfg, key);
|
|
106
|
+
const existing = limiters.get(key);
|
|
107
|
+
if (existing) {
|
|
108
|
+
existing.update(merged);
|
|
109
|
+
return existing;
|
|
110
|
+
}
|
|
111
|
+
const strategy = cfg.strategy === "sliding-window"
|
|
112
|
+
? new SlidingWindowStrategy(merged)
|
|
113
|
+
: new TokenBucketStrategy(merged);
|
|
114
|
+
limiters.set(key, strategy);
|
|
115
|
+
return strategy;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a terminal StreamChunk for rate-limiting failures.
|
|
120
|
+
* Uses `kind: "aborted"` when the caller's AbortSignal fired, so the agent
|
|
121
|
+
* loop treats it as a cancellation rather than a retryable error.
|
|
122
|
+
* Otherwise uses `kind: "error"` with `RATE_LIMIT` code, which the
|
|
123
|
+
* `dsh-llm-retry` plugin can catch and back off.
|
|
124
|
+
*/
|
|
125
|
+
function terminalChunk(modelKey, signal) {
|
|
126
|
+
const aborted = signal?.aborted === true;
|
|
127
|
+
return {
|
|
128
|
+
type: "finish",
|
|
129
|
+
reason: aborted
|
|
130
|
+
? { kind: "aborted", failure: { code: "RATE_LIMIT", message: `Rate limit: ${modelKey} (aborted)` } }
|
|
131
|
+
: { kind: "error", failure: { code: "RATE_LIMIT", message: `Rate limit exceeded for ${modelKey}` } },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ── Plugin entry ────────────────────────────────────────────────────── */
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {import("@deepseek-ai/cordis").Context} ctx
|
|
139
|
+
* @param {object} [config] - parsed Config; volatile fields are live references.
|
|
140
|
+
*/
|
|
141
|
+
function apply(ctx, config) {
|
|
142
|
+
const logger = ctx.logger?.("rate-limiter");
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Snapshot the whole config as one plain object, reading each reference at
|
|
146
|
+
* call time. Derived settings are not cached: a volatile-only change updates
|
|
147
|
+
* the references in place without re-running `apply`.
|
|
148
|
+
*/
|
|
149
|
+
function readConfig() {
|
|
150
|
+
return {
|
|
151
|
+
enabled: read(config?.enabled, FALLBACK.enabled),
|
|
152
|
+
strategy: read(config?.strategy, FALLBACK.strategy),
|
|
153
|
+
defaults: read(config?.defaults, FALLBACK.defaults),
|
|
154
|
+
models: read(config?.models, FALLBACK.models),
|
|
155
|
+
onThrottled: read(config?.onThrottled, FALLBACK.onThrottled),
|
|
156
|
+
maxQueueWaitMs: read(config?.maxQueueWaitMs, FALLBACK.maxQueueWaitMs),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** @type {Map<string, TokenBucketStrategy | SlidingWindowStrategy>} */
|
|
161
|
+
const limiters = new Map();
|
|
162
|
+
|
|
163
|
+
// ── Live statistics (read by the status RPC channel) ───────
|
|
164
|
+
// Counters are plain numbers so a snapshot is always JSON-safe.
|
|
165
|
+
const totals = {
|
|
166
|
+
requests: 0,
|
|
167
|
+
granted: 0,
|
|
168
|
+
rejected: 0,
|
|
169
|
+
timeouts: 0,
|
|
170
|
+
aborted: 0,
|
|
171
|
+
totalWaitMs: 0,
|
|
172
|
+
};
|
|
173
|
+
/** Bumped on every recorded event and on reset — the panel's change cursor. */
|
|
174
|
+
let rev = 0;
|
|
175
|
+
/** Bounded ring of recent events (newest last). */
|
|
176
|
+
const eventRing = [];
|
|
177
|
+
|
|
178
|
+
function recordEvent(event) {
|
|
179
|
+
eventRing.push({ ...event, ts: Date.now() });
|
|
180
|
+
if (eventRing.length > EVENT_RING_LIMIT) eventRing.shift();
|
|
181
|
+
rev += 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Build the JSON-safe snapshot served on the "snapshot" endpoint. */
|
|
185
|
+
function buildSnapshot() {
|
|
186
|
+
const cfg = readConfig();
|
|
187
|
+
const models = {};
|
|
188
|
+
for (const [key, limiter] of limiters) {
|
|
189
|
+
try {
|
|
190
|
+
models[key] = limiter.getStatus();
|
|
191
|
+
} catch {
|
|
192
|
+
// A strategy that cannot report status must not break the whole panel.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
rev,
|
|
197
|
+
ts: Date.now(),
|
|
198
|
+
enabled: cfg.enabled !== false,
|
|
199
|
+
strategy: cfg.strategy,
|
|
200
|
+
onThrottled: cfg.onThrottled,
|
|
201
|
+
totals: { ...totals },
|
|
202
|
+
models,
|
|
203
|
+
events: eventRing.slice(-SNAPSHOT_EVENT_COUNT),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Zero every counter and drop retained events (the panel's clear button). */
|
|
208
|
+
function resetTotals() {
|
|
209
|
+
for (const key of Object.keys(totals)) totals[key] = 0;
|
|
210
|
+
eventRing.length = 0;
|
|
211
|
+
rev += 1;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Settings presentation (dynamic inject) ─────────────────
|
|
215
|
+
// This plugin ships its own browser page, so it opts out of any
|
|
216
|
+
// schema-generated page. Wrapped in ctx.inject so a host WITHOUT a settings
|
|
217
|
+
// service still runs the limiter — the callback simply never runs. The
|
|
218
|
+
// optional child ctx names the plugin fiber the policy belongs to, which is
|
|
219
|
+
// why `ctx.fiber` is passed explicitly (`dsh-settings/lib/index.js:370`).
|
|
220
|
+
ctx.inject(["settings"], (scopedCtx) => {
|
|
221
|
+
if (typeof scopedCtx.settings?.configure !== "function") return; // older/foreign settings seam: keep running
|
|
222
|
+
scopedCtx.effect(
|
|
223
|
+
() => scopedCtx.settings.configure({ auto: false }, ctx.fiber),
|
|
224
|
+
"rate-limiter: settings presentation",
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ── Status channel (dynamic inject) ────────────────────────
|
|
229
|
+
// Same defensive shape as the settings wiring: on a host without a
|
|
230
|
+
// connection service the callback never runs and the plugin stays silent.
|
|
231
|
+
// Either path below registers inside an effect owned by this fiber, so
|
|
232
|
+
// unloading the plugin withdraws the channel automatically.
|
|
233
|
+
//
|
|
234
|
+
// Two mounting paths exist, and both speak the same wire protocol, so the
|
|
235
|
+
// browser half never needs to know which one is live:
|
|
236
|
+
//
|
|
237
|
+
// 1. connection.rpc.handle(channel, handler) — the framework's generic
|
|
238
|
+
// channel registry (preferred: the framework owns the route, the
|
|
239
|
+
// request validation, and the withdrawal).
|
|
240
|
+
//
|
|
241
|
+
// 2. a self-registered prefix route reusing connection.requestRejection()
|
|
242
|
+
// — the fallback for hosts where path 1 is broken. DSH 0.1.5-rc.3
|
|
243
|
+
// changed dsh-client-connection's own `inject` from
|
|
244
|
+
// ["webServer", "credentials"] to ["credentials"] while its
|
|
245
|
+
// HostConnectionService.register() still dereferences
|
|
246
|
+
// `owner.webServer`, and Cordis rebinds a cross-fiber service's `ctx`
|
|
247
|
+
// to the *reader's* fiber — so rpc.handle() throws
|
|
248
|
+
// 'cannot get property "webServer" without inject'. Registering the
|
|
249
|
+
// route on our own fiber (which injects webServer below) avoids that
|
|
250
|
+
// while still using the framework's own 403/401 fence.
|
|
251
|
+
// Still unfixed in 0.1.7-rc.1 (dsh-client-connection/lib/index.js:798
|
|
252
|
+
// keeps `inject = ["credentials"]`), so the fallback still fires.
|
|
253
|
+
ctx.inject(["connection"], (scopedCtx) => {
|
|
254
|
+
const connection = scopedCtx.get("connection");
|
|
255
|
+
const channel = createStatusChannel({ buildSnapshot, resetTotals });
|
|
256
|
+
|
|
257
|
+
// ── Path 1: the framework's generic channel registry ──
|
|
258
|
+
const handle = typeof connection?.rpc?.handle === "function"
|
|
259
|
+
? connection.rpc.handle.bind(connection.rpc)
|
|
260
|
+
: undefined;
|
|
261
|
+
|
|
262
|
+
if (handle !== undefined) {
|
|
263
|
+
try {
|
|
264
|
+
scopedCtx.effect(() => {
|
|
265
|
+
const unregister = handle(CHANNEL, channel);
|
|
266
|
+
return () => { unregister(); };
|
|
267
|
+
}, "rate-limiter: status rpc channel");
|
|
268
|
+
logger?.info("rate limiter status channel mounted via connection.rpc (%s)", CHANNEL);
|
|
269
|
+
return;
|
|
270
|
+
} catch (err) {
|
|
271
|
+
logger?.warn(
|
|
272
|
+
"connection.rpc channel registration failed (%s); falling back to a self-registered route",
|
|
273
|
+
err instanceof Error ? err.message : String(err),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Path 2: self-registered route (needs webServer on THIS fiber) ──
|
|
279
|
+
// The fence is mandatory: without connection.requestRejection() we would
|
|
280
|
+
// be publishing an unauthenticated route, so we refuse to mount at all.
|
|
281
|
+
// Probing once here turns a broken fence into a mount-time refusal instead
|
|
282
|
+
// of a per-request surprise.
|
|
283
|
+
try {
|
|
284
|
+
if (typeof connection?.requestRejection !== "function") {
|
|
285
|
+
logger?.warn("connection.requestRejection unavailable; status channel not mounted");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
connection.requestRejection({ headers: {} });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
logger?.warn(
|
|
291
|
+
"connection.requestRejection unusable (%s); refusing to mount an unfenced status route",
|
|
292
|
+
err instanceof Error ? err.message : String(err),
|
|
293
|
+
);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
scopedCtx.inject(["webServer"], (webCtx) => {
|
|
298
|
+
const webServer = webCtx.get("webServer");
|
|
299
|
+
if (typeof webServer?.register !== "function") return; // no web carrier: degrade silently
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
webCtx.effect(() => webServer.register(createChannelRoute({
|
|
303
|
+
channel: CHANNEL,
|
|
304
|
+
handler: channel,
|
|
305
|
+
reject: (req) => connection.requestRejection(req),
|
|
306
|
+
})), "rate-limiter: status route (self-registered)");
|
|
307
|
+
} catch (err) {
|
|
308
|
+
logger?.warn("status channel registration failed: %s", err instanceof Error ? err.message : String(err));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
logger?.info("rate limiter status channel mounted via self-registered route (%s)", CHANNEL);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ── Duplicate-limiter guard ────────────────────────────────
|
|
317
|
+
// The upstream plugin hooks the same waterfall. With both installed every call
|
|
318
|
+
// is limited twice: the effective rate becomes the stricter of the two, and a
|
|
319
|
+
// queued call can wait through both queues in sequence.
|
|
320
|
+
if (typeof import.meta.resolve === "function") {
|
|
321
|
+
let upstream;
|
|
322
|
+
try {
|
|
323
|
+
upstream = import.meta.resolve("@leaf233/dsh-llm-rate-limiter");
|
|
324
|
+
} catch {
|
|
325
|
+
upstream = undefined; // not installed next to us — the usual case
|
|
326
|
+
}
|
|
327
|
+
if (upstream !== undefined) {
|
|
328
|
+
logger?.warn(
|
|
329
|
+
"both @naced/llm-multi-rate-limiter and @leaf233/dsh-llm-rate-limiter are installed (%s); every call is limited twice — remove one of them",
|
|
330
|
+
upstream,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── LLM stream interceptor ─────────────────────────────────
|
|
336
|
+
// Reads the live Config on every call, so a settings edit that reaches the
|
|
337
|
+
// loader takes effect on the next request without a restart.
|
|
338
|
+
//
|
|
339
|
+
// Fails open: if config parsing or the limiter itself throws, the call is
|
|
340
|
+
// forwarded untouched. A broken limiter must never take the model offline.
|
|
341
|
+
const disposeLlmListener = ctx.on("llm/stream", async function* rateLimitInterceptor(options, next) {
|
|
342
|
+
const modelKey = `${options.provider}/${options.model}`;
|
|
343
|
+
|
|
344
|
+
/** @type {TokenBucketStrategy | SlidingWindowStrategy | null} */
|
|
345
|
+
let limiter = null;
|
|
346
|
+
let slotTaken = false;
|
|
347
|
+
let passThrough = false;
|
|
348
|
+
/** @type {object | null} */
|
|
349
|
+
let refused = null;
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const cfg = readConfig();
|
|
353
|
+
|
|
354
|
+
if (!cfg.enabled || cfg.models?.[modelKey]?.enabled === false) {
|
|
355
|
+
// Global switch off, or per-model opt-out: zero-touch pass-through.
|
|
356
|
+
passThrough = true;
|
|
357
|
+
} else {
|
|
358
|
+
limiter = ensureLimiter(limiters, modelKey, cfg);
|
|
359
|
+
totals.requests += 1;
|
|
360
|
+
|
|
361
|
+
if (cfg.onThrottled === "reject") {
|
|
362
|
+
// Non-blocking check — if at capacity, refuse the call outright.
|
|
363
|
+
// acquireNonBlocking deducts a token AND takes a concurrent slot, so
|
|
364
|
+
// reject mode still enforces the frequency limit, not just concurrency.
|
|
365
|
+
const permit = limiter.acquireNonBlocking();
|
|
366
|
+
if (permit.granted) {
|
|
367
|
+
slotTaken = true;
|
|
368
|
+
totals.granted += 1;
|
|
369
|
+
recordEvent({ event: "granted", model: modelKey, waitMs: 0 });
|
|
370
|
+
} else {
|
|
371
|
+
totals.rejected += 1;
|
|
372
|
+
recordEvent({ event: "rejected", model: modelKey });
|
|
373
|
+
logger?.warn("model %C rejected — rate limit reached", modelKey);
|
|
374
|
+
refused = terminalChunk(modelKey, options.signal);
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
// Queue mode — park until a slot opens.
|
|
378
|
+
const permit = await limiter.acquire({
|
|
379
|
+
signal: options.signal,
|
|
380
|
+
timeoutMs: cfg.maxQueueWaitMs ?? 60_000,
|
|
381
|
+
});
|
|
382
|
+
if (permit.granted) {
|
|
383
|
+
totals.granted += 1;
|
|
384
|
+
totals.totalWaitMs += permit.waitMs ?? 0;
|
|
385
|
+
recordEvent({ event: "granted", model: modelKey, waitMs: permit.waitMs ?? 0 });
|
|
386
|
+
} else {
|
|
387
|
+
const aborted = options.signal?.aborted === true;
|
|
388
|
+
if (aborted) {
|
|
389
|
+
totals.aborted += 1;
|
|
390
|
+
recordEvent({ event: "aborted", model: modelKey, waitMs: permit.waitMs ?? 0 });
|
|
391
|
+
} else {
|
|
392
|
+
totals.timeouts += 1;
|
|
393
|
+
recordEvent({ event: "timeout", model: modelKey, waitMs: permit.waitMs ?? 0 });
|
|
394
|
+
}
|
|
395
|
+
logger?.warn(
|
|
396
|
+
"model %C %s after %d ms",
|
|
397
|
+
modelKey, aborted ? "aborted" : "queue timeout", permit.waitMs,
|
|
398
|
+
);
|
|
399
|
+
refused = terminalChunk(modelKey, options.signal);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} catch (err) {
|
|
404
|
+
// Fail open. Release a slot taken earlier in this same attempt, then let
|
|
405
|
+
// the real call proceed unthrottled rather than failing it.
|
|
406
|
+
if (slotTaken && limiter !== null) {
|
|
407
|
+
try {
|
|
408
|
+
limiter.releaseSlot();
|
|
409
|
+
} catch {
|
|
410
|
+
// Ignore: the strategy is already in an unknown state.
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
logger?.warn(
|
|
414
|
+
"rate limiter bypassed for %C (%s); forwarding the call unthrottled",
|
|
415
|
+
modelKey,
|
|
416
|
+
err instanceof Error ? err.message : String(err),
|
|
417
|
+
);
|
|
418
|
+
limiter = null;
|
|
419
|
+
slotTaken = false;
|
|
420
|
+
passThrough = true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (refused !== null) {
|
|
424
|
+
yield refused;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (passThrough || limiter === null) {
|
|
429
|
+
yield* next();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── Phase 2: run the real LLM call ─────────────────────────
|
|
434
|
+
// Queue mode does not take a concurrent slot inside acquire().
|
|
435
|
+
if (!slotTaken) limiter.acquireSlot();
|
|
436
|
+
try {
|
|
437
|
+
yield* next();
|
|
438
|
+
} finally {
|
|
439
|
+
limiter.releaseSlot();
|
|
440
|
+
recordEvent({ event: "completed", model: modelKey });
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// ── Teardown ──
|
|
445
|
+
ctx.effect(() => () => {
|
|
446
|
+
disposeLlmListener();
|
|
447
|
+
for (const limiter of limiters.values()) limiter.dispose();
|
|
448
|
+
limiters.clear();
|
|
449
|
+
}, "rate-limiter: dispose llm/stream listener");
|
|
450
|
+
|
|
451
|
+
logger?.info("rate limiter active — llm/stream interceptor registered");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export { apply, name, SETTINGS_NS, Config };
|