@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/CHANGELOG.md +78 -0
- package/LICENSE +22 -0
- package/README.md +112 -2
- package/README.zh.md +115 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +956 -0
- package/lib/config-resolve.js +259 -0
- package/lib/index.d.ts +91 -0
- package/lib/index.js +552 -0
- package/lib/status-rpc.js +285 -0
- package/lib/strategies/sliding-window.js +174 -0
- package/lib/strategies/token-bucket.js +196 -0
- package/lib/types/config.js +126 -0
- package/lib/types/index.js +1 -0
- package/package.json +81 -4
|
@@ -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;
|