@naced/llm-multi-rate-limiter 0.1.0 → 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/CHANGELOG.md +41 -0
- package/README.md +52 -7
- package/README.zh.md +50 -7
- package/lib/client.js +269 -4
- package/lib/config-resolve.js +259 -0
- package/lib/index.d.ts +91 -0
- package/lib/index.js +140 -42
- package/lib/strategies/sliding-window.js +27 -1
- package/lib/strategies/token-bucket.js +30 -2
- package/lib/types/config.js +42 -1
- package/package.json +28 -6
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure configuration-resolution helpers.
|
|
3
|
+
*
|
|
4
|
+
* Kept free of Cordis, schemastery and timers so the unit tests can exercise
|
|
5
|
+
* the precedence rules, the wildcard matcher and the 429 cooldown machine with
|
|
6
|
+
* plain objects.
|
|
7
|
+
*
|
|
8
|
+
* Precedence for one call (`provider/model`):
|
|
9
|
+
*
|
|
10
|
+
* models["<provider>/<model>"] exact model key
|
|
11
|
+
* > models["<pattern with *>"] wildcard model key (most specific first)
|
|
12
|
+
* > providers["<provider>"] provider-wide tier
|
|
13
|
+
* > defaults global tier
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-llm-multi-rate-limiter/config-resolve
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Sliding-window width. `maxRpm` is "per minute", so the window is a minute. */
|
|
19
|
+
export const WINDOW_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
/** Chars that would change the meaning of the generated regexp. */
|
|
22
|
+
const REGEXP_SPECIALS = /[.*+?^${}()|[\]\\]/g;
|
|
23
|
+
|
|
24
|
+
function escapeRegExp(text) {
|
|
25
|
+
return text.replace(REGEXP_SPECIALS, "\\$&");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compile a `*`-wildcard key into a matcher.
|
|
30
|
+
*
|
|
31
|
+
* `*` matches any run of characters that does not cross a `/`, so
|
|
32
|
+
* `openai/*` matches `openai/gpt-5.1` but not `openai/gpt-5.1/turbo`, and
|
|
33
|
+
* `*` alone matches every provider/model pair.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} pattern
|
|
36
|
+
* @returns {RegExp}
|
|
37
|
+
*/
|
|
38
|
+
export function compilePattern(pattern) {
|
|
39
|
+
const source = pattern.split("*").map(escapeRegExp).join("[^/]*");
|
|
40
|
+
return new RegExp(`^${source}$`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True when `key` contains a wildcard and is therefore a pattern, not a key. */
|
|
44
|
+
export function isPattern(key) {
|
|
45
|
+
return key.includes("*");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Number of literal characters a pattern pins down — its specificity score. */
|
|
49
|
+
function specificity(pattern) {
|
|
50
|
+
return pattern.length - pattern.split("*").length + 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Find the override that applies to `key` in a `{ key: override }` dict.
|
|
55
|
+
*
|
|
56
|
+
* Exact keys always win over patterns. When several patterns match, the most
|
|
57
|
+
* specific one wins (most literal characters); remaining ties break on the
|
|
58
|
+
* longer pattern so the choice is stable across runs.
|
|
59
|
+
*
|
|
60
|
+
* @param {Record<string, object> | undefined} dict
|
|
61
|
+
* @param {string} key
|
|
62
|
+
* @returns {{ override: object, matched: string } | undefined}
|
|
63
|
+
*/
|
|
64
|
+
export function matchOverride(dict, key) {
|
|
65
|
+
if (dict === null || typeof dict !== "object") return undefined;
|
|
66
|
+
|
|
67
|
+
const exact = dict[key];
|
|
68
|
+
if (exact !== null && typeof exact === "object") return { override: exact, matched: key };
|
|
69
|
+
|
|
70
|
+
/** @type {Array<{ pattern: string, override: object, score: number }>} */
|
|
71
|
+
const candidates = [];
|
|
72
|
+
for (const [pattern, override] of Object.entries(dict)) {
|
|
73
|
+
if (!isPattern(pattern)) continue;
|
|
74
|
+
if (override === null || typeof override !== "object") continue;
|
|
75
|
+
if (!compilePattern(pattern).test(key)) continue;
|
|
76
|
+
candidates.push({ pattern, override, score: specificity(pattern) });
|
|
77
|
+
}
|
|
78
|
+
if (candidates.length === 0) return undefined;
|
|
79
|
+
|
|
80
|
+
candidates.sort((a, b) => b.score - a.score || b.pattern.length - a.pattern.length);
|
|
81
|
+
return { override: candidates[0].override, matched: candidates[0].pattern };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Provider half of a `provider/model` key. */
|
|
85
|
+
export function providerOf(key) {
|
|
86
|
+
const slash = key.indexOf("/");
|
|
87
|
+
return slash === -1 ? key : key.slice(0, slash);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Flatten the four config tiers into the flat shape the strategies understand.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} cfg plain config (`{ defaults, providers, models }`)
|
|
94
|
+
* @param {string} key `provider/model` of the call being limited
|
|
95
|
+
* @returns {{ maxConcurrent: number, maxRpm: number, burstSize: number,
|
|
96
|
+
* refillRate: number, windowMs: number, source: "model" | "provider" | "defaults",
|
|
97
|
+
* matchedKey: string }}
|
|
98
|
+
*/
|
|
99
|
+
export function resolveModelConfig(cfg, key) {
|
|
100
|
+
const defaults = cfg?.defaults ?? {};
|
|
101
|
+
const modelHit = matchOverride(cfg?.models, key);
|
|
102
|
+
const providerId = providerOf(key);
|
|
103
|
+
const providerHit = matchOverride(cfg?.providers, providerId);
|
|
104
|
+
|
|
105
|
+
const model = modelHit?.override ?? {};
|
|
106
|
+
const provider = providerHit?.override ?? {};
|
|
107
|
+
const source = modelHit !== undefined ? "model" : providerHit !== undefined ? "provider" : "defaults";
|
|
108
|
+
|
|
109
|
+
const maxRpm = model.maxRpm ?? provider.maxRpm ?? defaults.maxRpm ?? 60;
|
|
110
|
+
// A model/provider that sets maxRpm but not refillRate gets a refill derived
|
|
111
|
+
// from ITS maxRpm, so "maxRpm: 20" alone behaves as 20 per minute. When the
|
|
112
|
+
// winning tier does not touch maxRpm, the narrower tier's refillRate (if any)
|
|
113
|
+
// is used as-is.
|
|
114
|
+
const explicitRefill = model.refillRate ?? provider.refillRate;
|
|
115
|
+
const refillRate = explicitRefill
|
|
116
|
+
?? ((model.maxRpm ?? provider.maxRpm) != null
|
|
117
|
+
? maxRpm / 60
|
|
118
|
+
: defaults.refillRate ?? maxRpm / 60);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
maxConcurrent: model.maxConcurrent ?? provider.maxConcurrent ?? defaults.maxConcurrent ?? 5,
|
|
122
|
+
maxRpm,
|
|
123
|
+
burstSize: model.burstSize ?? provider.burstSize ?? defaults.burstSize ?? 10,
|
|
124
|
+
refillRate,
|
|
125
|
+
windowMs: WINDOW_MS,
|
|
126
|
+
source,
|
|
127
|
+
matchedKey: modelHit?.matched ?? providerHit?.matched ?? "(defaults)",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Scale one resolved config down by `factor` (never below the usable floor). */
|
|
132
|
+
export function scaleConfig(resolved, factor) {
|
|
133
|
+
if (!(factor < 1)) return resolved;
|
|
134
|
+
return {
|
|
135
|
+
...resolved,
|
|
136
|
+
maxRpm: Math.max(1, Math.round(resolved.maxRpm * factor)),
|
|
137
|
+
burstSize: Math.max(1, Math.round(resolved.burstSize * factor)),
|
|
138
|
+
refillRate: Math.max(0.01, resolved.refillRate * factor),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Per-model 429 cooldown.
|
|
144
|
+
*
|
|
145
|
+
* A rate limiter that only counts requests cannot know that the provider
|
|
146
|
+
* throttled us for a reason of its own (a shared quota, a burst it did not
|
|
147
|
+
* like). Treating a `RATE_LIMIT` failure from the provider as a signal keeps
|
|
148
|
+
* the next calls from walking into the same wall: the model's rate is scaled
|
|
149
|
+
* down for `cooldownMs`, then restored after `recoverAfter` clean calls.
|
|
150
|
+
*
|
|
151
|
+
* Pure state machine: the caller supplies `now`, so tests need no clock.
|
|
152
|
+
*/
|
|
153
|
+
export class CooldownTracker {
|
|
154
|
+
/** @type {Map<string, { until: number, factor: number, clean: number }>} */
|
|
155
|
+
#state = new Map();
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {{ enabled?: boolean, cooldownMs?: number, factor?: number, recoverAfter?: number }} options
|
|
159
|
+
*/
|
|
160
|
+
constructor(options = {}) {
|
|
161
|
+
this.enabled = options.enabled !== false;
|
|
162
|
+
/** How long a throttled model stays slowed down. */
|
|
163
|
+
this.cooldownMs = Number.isFinite(options.cooldownMs) ? options.cooldownMs : 30_000;
|
|
164
|
+
/** Rate multiplier applied while cooling (0 < factor < 1). */
|
|
165
|
+
this.factor = Number.isFinite(options.factor) ? Math.min(0.9, Math.max(0.05, options.factor)) : 0.5;
|
|
166
|
+
/** Clean calls required before the full rate is restored. */
|
|
167
|
+
this.recoverAfter = Number.isFinite(options.recoverAfter) ? Math.max(1, options.recoverAfter) : 3;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Re-read policy fields from the live config. */
|
|
171
|
+
configure(options = {}) {
|
|
172
|
+
if (typeof options.enabled === "boolean") this.enabled = options.enabled;
|
|
173
|
+
if (Number.isFinite(options.cooldownMs)) this.cooldownMs = options.cooldownMs;
|
|
174
|
+
if (Number.isFinite(options.factor)) this.factor = Math.min(0.9, Math.max(0.05, options.factor));
|
|
175
|
+
if (Number.isFinite(options.recoverAfter)) this.recoverAfter = Math.max(1, options.recoverAfter);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Current rate multiplier for `key` (1 when not cooling).
|
|
180
|
+
*
|
|
181
|
+
* @param {string} key
|
|
182
|
+
* @param {number} [now]
|
|
183
|
+
*/
|
|
184
|
+
factorFor(key, now = Date.now()) {
|
|
185
|
+
const entry = this.#state.get(key);
|
|
186
|
+
if (entry === undefined) return 1;
|
|
187
|
+
if (entry.until > now) return entry.factor;
|
|
188
|
+
// Cooldown elapsed: stay scaled down until enough clean calls have passed.
|
|
189
|
+
return entry.clean >= this.recoverAfter ? 1 : entry.factor;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Record a provider-side `RATE_LIMIT` failure.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} key
|
|
196
|
+
* @param {number} [now]
|
|
197
|
+
* @param {number} [retryAfterMs] provider hint, when known
|
|
198
|
+
* @returns {{ until: number, factor: number } | undefined}
|
|
199
|
+
*/
|
|
200
|
+
recordFailure(key, now = Date.now(), retryAfterMs = undefined) {
|
|
201
|
+
if (!this.enabled) return undefined;
|
|
202
|
+
const hold = Number.isFinite(retryAfterMs) && retryAfterMs > 0
|
|
203
|
+
? Math.max(this.cooldownMs, retryAfterMs)
|
|
204
|
+
: this.cooldownMs;
|
|
205
|
+
const entry = { until: now + hold, factor: this.factor, clean: 0 };
|
|
206
|
+
this.#state.set(key, entry);
|
|
207
|
+
return entry;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Record a call that completed without a provider-side 429, retiring the
|
|
212
|
+
* cooldown once enough of them have accumulated.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} key
|
|
215
|
+
* @param {number} [now]
|
|
216
|
+
*/
|
|
217
|
+
recordSuccess(key, now = Date.now()) {
|
|
218
|
+
const entry = this.#state.get(key);
|
|
219
|
+
if (entry === undefined) return;
|
|
220
|
+
if (entry.until > now) return; // still cooling: a success does not end it early
|
|
221
|
+
entry.clean += 1;
|
|
222
|
+
if (entry.clean >= this.recoverAfter) this.#state.delete(key);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** True while `key` is inside its cooldown window. */
|
|
226
|
+
isCooling(key, now = Date.now()) {
|
|
227
|
+
const entry = this.#state.get(key);
|
|
228
|
+
return entry !== undefined && entry.until > now;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Milliseconds until the cooldown ends (0 when not cooling). */
|
|
232
|
+
remainingMs(key, now = Date.now()) {
|
|
233
|
+
const entry = this.#state.get(key);
|
|
234
|
+
return entry === undefined ? 0 : Math.max(0, entry.until - now);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Drop every cooldown (config reset / panel action). */
|
|
238
|
+
clear() {
|
|
239
|
+
this.#state.clear();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Drop the state of one model. */
|
|
243
|
+
forget(key) {
|
|
244
|
+
this.#state.delete(key);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** JSON-safe view for the status channel. */
|
|
248
|
+
snapshot(now = Date.now()) {
|
|
249
|
+
const models = {};
|
|
250
|
+
for (const [key, entry] of this.#state) {
|
|
251
|
+
models[key] = {
|
|
252
|
+
cooling: entry.until > now,
|
|
253
|
+
remainingMs: Math.max(0, entry.until - now),
|
|
254
|
+
factor: entry.factor,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return models;
|
|
258
|
+
}
|
|
259
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the host entry point.
|
|
3
|
+
*
|
|
4
|
+
* The plugin ships no build step, so these are hand-written against the shapes
|
|
5
|
+
* `lib/index.js` actually uses. The `Config` export is a schemastery schema
|
|
6
|
+
* (validated at load time by Cordis); it is typed as `unknown` rather than
|
|
7
|
+
* pretending to model schemastery's builder.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Limits shared by the `defaults`, `providers` and `models` tiers. */
|
|
11
|
+
export interface RateLimitTier {
|
|
12
|
+
/** Max concurrent in-flight requests for this tier. */
|
|
13
|
+
maxConcurrent?: number;
|
|
14
|
+
/** Max requests per minute for this tier. */
|
|
15
|
+
maxRpm?: number;
|
|
16
|
+
/** Burst capacity (token-bucket only). */
|
|
17
|
+
burstSize?: number;
|
|
18
|
+
/** Tokens refilled per second (token-bucket only). */
|
|
19
|
+
refillRate?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A `models` override. Keys may be exact (`provider/model`) or wildcarded (`provider/*`). */
|
|
23
|
+
export interface ModelOverride extends RateLimitTier {
|
|
24
|
+
/** Set `false` to exempt this model (or pattern) from limiting entirely. */
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Adaptive back-off applied after the provider answers 429. */
|
|
29
|
+
export interface CooldownConfig {
|
|
30
|
+
/** Master switch for the 429 cooldown. */
|
|
31
|
+
enabled?: boolean;
|
|
32
|
+
/** How long the reduced rate lasts, in milliseconds. */
|
|
33
|
+
cooldownMs?: number;
|
|
34
|
+
/** Rate multiplier while cooling, in `(0, 1)`. Defaults to `0.5`. */
|
|
35
|
+
factor?: number;
|
|
36
|
+
/** Clean calls required before the full rate is restored. */
|
|
37
|
+
recoverAfter?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Shape of this plugin's profile-entry config (all fields optional). */
|
|
41
|
+
export interface RateLimiterConfig {
|
|
42
|
+
/** Master switch for the limiter. */
|
|
43
|
+
enabled?: boolean;
|
|
44
|
+
/** Algorithm used for every model unless a tier says otherwise. */
|
|
45
|
+
strategy?: "token-bucket" | "sliding-window";
|
|
46
|
+
/** Global tier, applied when neither a provider nor a model tier matches. */
|
|
47
|
+
defaults?: RateLimitTier;
|
|
48
|
+
/** Per-provider tier, keyed by provider id (wildcards accepted). */
|
|
49
|
+
providers?: Record<string, RateLimitTier>;
|
|
50
|
+
/** Per-model tier, keyed by `provider/model` (wildcards accepted). */
|
|
51
|
+
models?: Record<string, ModelOverride>;
|
|
52
|
+
/** Adaptive back-off after provider-side throttling. */
|
|
53
|
+
cooldown?: CooldownConfig;
|
|
54
|
+
/** `queue` waits for capacity; `reject` fails the call immediately. */
|
|
55
|
+
onThrottled?: "queue" | "reject";
|
|
56
|
+
/** How long a queued call may wait before it is refused. */
|
|
57
|
+
maxQueueWaitMs?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Structural subset of the Cordis `Context` that `apply` relies on.
|
|
62
|
+
*
|
|
63
|
+
* Declared locally so consumers do not need `@deepseek-ai/cordis` installed to
|
|
64
|
+
* type-check against this package.
|
|
65
|
+
*/
|
|
66
|
+
export interface HostContext {
|
|
67
|
+
logger?: (scope: string) => {
|
|
68
|
+
info(...args: unknown[]): void;
|
|
69
|
+
warn(...args: unknown[]): void;
|
|
70
|
+
};
|
|
71
|
+
on(event: string, listener: (...args: any[]) => any): () => void;
|
|
72
|
+
effect(effect: () => (() => void) | void, label?: string): void;
|
|
73
|
+
inject(dependencies: string[], callback: (ctx: any) => void): void;
|
|
74
|
+
fiber?: unknown;
|
|
75
|
+
[key: string]: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Plugin name as registered in `cordis.patch.yml`. */
|
|
79
|
+
export declare const name: string;
|
|
80
|
+
|
|
81
|
+
/** Settings namespace / profile entry id. */
|
|
82
|
+
export declare const SETTINGS_NS: string;
|
|
83
|
+
|
|
84
|
+
/** schemastery schema for the profile entry's `config`. */
|
|
85
|
+
export declare const Config: unknown;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Cordis plugin entry point. Registers the `llm/stream` interceptor, the
|
|
89
|
+
* settings presentation opt-out and the status RPC channel.
|
|
90
|
+
*/
|
|
91
|
+
export declare function apply(ctx: HostContext, config?: RateLimiterConfig): void;
|
package/lib/index.js
CHANGED
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
import { TokenBucketStrategy } from "./strategies/token-bucket.js";
|
|
23
23
|
import { SlidingWindowStrategy } from "./strategies/sliding-window.js";
|
|
24
24
|
import { Config } from "./types/config.js";
|
|
25
|
+
import {
|
|
26
|
+
CooldownTracker,
|
|
27
|
+
resolveModelConfig,
|
|
28
|
+
scaleConfig,
|
|
29
|
+
} from "./config-resolve.js";
|
|
25
30
|
import { CHANNEL, createChannelRoute, createStatusChannel } from "./status-rpc.js";
|
|
26
31
|
|
|
27
32
|
const name = "llm-multi-rate-limiter";
|
|
@@ -38,6 +43,10 @@ const SETTINGS_NS = "llm-multi-rate-limiter";
|
|
|
38
43
|
const EVENT_RING_LIMIT = 64;
|
|
39
44
|
/** Events carried by one snapshot (the panel shows these). */
|
|
40
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;
|
|
41
50
|
|
|
42
51
|
/* ── Helpers ─────────────────────────────────────────────────────────── */
|
|
43
52
|
|
|
@@ -46,7 +55,9 @@ const FALLBACK = {
|
|
|
46
55
|
enabled: true,
|
|
47
56
|
strategy: "token-bucket",
|
|
48
57
|
defaults: { maxConcurrent: 5, maxRpm: 60, burstSize: 10, refillRate: 1 },
|
|
58
|
+
providers: {},
|
|
49
59
|
models: {},
|
|
60
|
+
cooldown: { enabled: true, cooldownMs: 30_000, factor: 0.5, recoverAfter: 3 },
|
|
50
61
|
onThrottled: "queue",
|
|
51
62
|
maxQueueWaitMs: 60_000,
|
|
52
63
|
};
|
|
@@ -74,41 +85,21 @@ function read(ref, fallback) {
|
|
|
74
85
|
return ref;
|
|
75
86
|
}
|
|
76
87
|
|
|
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
88
|
/**
|
|
102
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
|
|
103
95
|
*/
|
|
104
|
-
function ensureLimiter(limiters, key,
|
|
105
|
-
const merged = resolveModelConfig(cfg, key);
|
|
96
|
+
function ensureLimiter(limiters, key, merged, strategyName) {
|
|
106
97
|
const existing = limiters.get(key);
|
|
107
98
|
if (existing) {
|
|
108
99
|
existing.update(merged);
|
|
109
100
|
return existing;
|
|
110
101
|
}
|
|
111
|
-
const strategy =
|
|
102
|
+
const strategy = strategyName === "sliding-window"
|
|
112
103
|
? new SlidingWindowStrategy(merged)
|
|
113
104
|
: new TokenBucketStrategy(merged);
|
|
114
105
|
limiters.set(key, strategy);
|
|
@@ -117,21 +108,44 @@ function ensureLimiter(limiters, key, cfg) {
|
|
|
117
108
|
|
|
118
109
|
/**
|
|
119
110
|
* Build a terminal StreamChunk for rate-limiting failures.
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* `
|
|
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.
|
|
124
121
|
*/
|
|
125
|
-
function terminalChunk(modelKey, signal) {
|
|
122
|
+
function terminalChunk(modelKey, code, signal) {
|
|
126
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
|
+
};
|
|
127
130
|
return {
|
|
128
131
|
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
|
+
reason: aborted ? { kind: "aborted", failure } : { kind: "error", failure },
|
|
132
133
|
};
|
|
133
134
|
}
|
|
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
|
+
|
|
135
149
|
/* ── Plugin entry ────────────────────────────────────────────────────── */
|
|
136
150
|
|
|
137
151
|
/**
|
|
@@ -151,7 +165,9 @@ function apply(ctx, config) {
|
|
|
151
165
|
enabled: read(config?.enabled, FALLBACK.enabled),
|
|
152
166
|
strategy: read(config?.strategy, FALLBACK.strategy),
|
|
153
167
|
defaults: read(config?.defaults, FALLBACK.defaults),
|
|
168
|
+
providers: read(config?.providers, FALLBACK.providers),
|
|
154
169
|
models: read(config?.models, FALLBACK.models),
|
|
170
|
+
cooldown: read(config?.cooldown, FALLBACK.cooldown),
|
|
155
171
|
onThrottled: read(config?.onThrottled, FALLBACK.onThrottled),
|
|
156
172
|
maxQueueWaitMs: read(config?.maxQueueWaitMs, FALLBACK.maxQueueWaitMs),
|
|
157
173
|
};
|
|
@@ -159,6 +175,8 @@ function apply(ctx, config) {
|
|
|
159
175
|
|
|
160
176
|
/** @type {Map<string, TokenBucketStrategy | SlidingWindowStrategy>} */
|
|
161
177
|
const limiters = new Map();
|
|
178
|
+
/** Adaptive back-off state after provider 429s. */
|
|
179
|
+
const cooldown = new CooldownTracker(FALLBACK.cooldown);
|
|
162
180
|
|
|
163
181
|
// ── Live statistics (read by the status RPC channel) ───────
|
|
164
182
|
// Counters are plain numbers so a snapshot is always JSON-safe.
|
|
@@ -168,6 +186,7 @@ function apply(ctx, config) {
|
|
|
168
186
|
rejected: 0,
|
|
169
187
|
timeouts: 0,
|
|
170
188
|
aborted: 0,
|
|
189
|
+
throttledByProvider: 0,
|
|
171
190
|
totalWaitMs: 0,
|
|
172
191
|
};
|
|
173
192
|
/** Bumped on every recorded event and on reset — the panel's change cursor. */
|
|
@@ -187,7 +206,15 @@ function apply(ctx, config) {
|
|
|
187
206
|
const models = {};
|
|
188
207
|
for (const [key, limiter] of limiters) {
|
|
189
208
|
try {
|
|
190
|
-
|
|
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
|
+
};
|
|
191
218
|
} catch {
|
|
192
219
|
// A strategy that cannot report status must not break the whole panel.
|
|
193
220
|
}
|
|
@@ -198,6 +225,10 @@ function apply(ctx, config) {
|
|
|
198
225
|
enabled: cfg.enabled !== false,
|
|
199
226
|
strategy: cfg.strategy,
|
|
200
227
|
onThrottled: cfg.onThrottled,
|
|
228
|
+
cooldown: {
|
|
229
|
+
enabled: cfg.cooldown?.enabled !== false,
|
|
230
|
+
models: cooldown.snapshot(),
|
|
231
|
+
},
|
|
201
232
|
totals: { ...totals },
|
|
202
233
|
models,
|
|
203
234
|
events: eventRing.slice(-SNAPSHOT_EVENT_COUNT),
|
|
@@ -208,9 +239,42 @@ function apply(ctx, config) {
|
|
|
208
239
|
function resetTotals() {
|
|
209
240
|
for (const key of Object.keys(totals)) totals[key] = 0;
|
|
210
241
|
eventRing.length = 0;
|
|
242
|
+
cooldown.clear();
|
|
211
243
|
rev += 1;
|
|
212
244
|
}
|
|
213
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
|
+
|
|
214
278
|
// ── Settings presentation (dynamic inject) ─────────────────
|
|
215
279
|
// This plugin ships its own browser page, so it opts out of any
|
|
216
280
|
// schema-generated page. Wrapped in ctx.inject so a host WITHOUT a settings
|
|
@@ -248,7 +312,7 @@ function apply(ctx, config) {
|
|
|
248
312
|
// 'cannot get property "webServer" without inject'. Registering the
|
|
249
313
|
// route on our own fiber (which injects webServer below) avoids that
|
|
250
314
|
// while still using the framework's own 403/401 fence.
|
|
251
|
-
// Still unfixed in 0.1
|
|
315
|
+
// Still unfixed in 0.2.1-rc.2 (dsh-client-connection/lib/index.js:1013
|
|
252
316
|
// keeps `inject = ["credentials"]`), so the fallback still fires.
|
|
253
317
|
ctx.inject(["connection"], (scopedCtx) => {
|
|
254
318
|
const connection = scopedCtx.get("connection");
|
|
@@ -350,12 +414,20 @@ function apply(ctx, config) {
|
|
|
350
414
|
|
|
351
415
|
try {
|
|
352
416
|
const cfg = readConfig();
|
|
417
|
+
cooldown.configure(cfg.cooldown);
|
|
353
418
|
|
|
354
419
|
if (!cfg.enabled || cfg.models?.[modelKey]?.enabled === false) {
|
|
355
420
|
// Global switch off, or per-model opt-out: zero-touch pass-through.
|
|
356
421
|
passThrough = true;
|
|
357
422
|
} else {
|
|
358
|
-
|
|
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();
|
|
359
431
|
totals.requests += 1;
|
|
360
432
|
|
|
361
433
|
if (cfg.onThrottled === "reject") {
|
|
@@ -371,7 +443,8 @@ function apply(ctx, config) {
|
|
|
371
443
|
totals.rejected += 1;
|
|
372
444
|
recordEvent({ event: "rejected", model: modelKey });
|
|
373
445
|
logger?.warn("model %C rejected — rate limit reached", modelKey);
|
|
374
|
-
|
|
446
|
+
// Local decision: not a retryable provider condition.
|
|
447
|
+
refused = terminalChunk(modelKey, "RATE_LIMIT_REJECTED", options.signal);
|
|
375
448
|
}
|
|
376
449
|
} else {
|
|
377
450
|
// Queue mode — park until a slot opens.
|
|
@@ -396,7 +469,8 @@ function apply(ctx, config) {
|
|
|
396
469
|
"model %C %s after %d ms",
|
|
397
470
|
modelKey, aborted ? "aborted" : "queue timeout", permit.waitMs,
|
|
398
471
|
);
|
|
399
|
-
|
|
472
|
+
// Own code: a queue timeout must not be retried into another queue.
|
|
473
|
+
refused = terminalChunk(modelKey, "RATE_LIMIT_QUEUE_TIMEOUT", options.signal);
|
|
400
474
|
}
|
|
401
475
|
}
|
|
402
476
|
}
|
|
@@ -433,19 +507,43 @@ function apply(ctx, config) {
|
|
|
433
507
|
// ── Phase 2: run the real LLM call ─────────────────────────
|
|
434
508
|
// Queue mode does not take a concurrent slot inside acquire().
|
|
435
509
|
if (!slotTaken) limiter.acquireSlot();
|
|
510
|
+
const activeLimiter = limiter;
|
|
436
511
|
try {
|
|
437
|
-
|
|
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);
|
|
438
534
|
} finally {
|
|
439
|
-
|
|
535
|
+
activeLimiter.releaseSlot();
|
|
440
536
|
recordEvent({ event: "completed", model: modelKey });
|
|
441
537
|
}
|
|
442
538
|
});
|
|
443
539
|
|
|
444
540
|
// ── Teardown ──
|
|
445
541
|
ctx.effect(() => () => {
|
|
542
|
+
clearInterval(sweepTimer);
|
|
446
543
|
disposeLlmListener();
|
|
447
544
|
for (const limiter of limiters.values()) limiter.dispose();
|
|
448
545
|
limiters.clear();
|
|
546
|
+
cooldown.clear();
|
|
449
547
|
}, "rate-limiter: dispose llm/stream listener");
|
|
450
548
|
|
|
451
549
|
logger?.info("rate limiter active — llm/stream interceptor registered");
|