@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
|
@@ -14,6 +14,14 @@ export class SlidingWindowStrategy {
|
|
|
14
14
|
/** @type {{ windowMs: number, maxRpm: number, maxConcurrent: number }} */
|
|
15
15
|
#cfg;
|
|
16
16
|
#concurrent = 0;
|
|
17
|
+
/**
|
|
18
|
+
* Slots handed to a waiter that has not called `acquireSlot()` yet.
|
|
19
|
+
*
|
|
20
|
+
* `#drain()` wakes several waiters in one pass and each of them takes its
|
|
21
|
+
* concurrent slot only after the promise settles; counting reservations keeps
|
|
22
|
+
* one release from unlocking the entire queue past `maxConcurrent`.
|
|
23
|
+
*/
|
|
24
|
+
#reserved = 0;
|
|
17
25
|
/** @type {number[]} — timestamps (Date.now()) inside the window */
|
|
18
26
|
#timestamps = [];
|
|
19
27
|
/** @type {Array<{ resolve: (r: { granted: boolean }) => void, abort: () => void }>} */
|
|
@@ -45,7 +53,12 @@ export class SlidingWindowStrategy {
|
|
|
45
53
|
if (this.#cfg.maxConcurrent <= 0) return Promise.resolve({ granted: false });
|
|
46
54
|
|
|
47
55
|
return new Promise((resolve) => {
|
|
56
|
+
// A waiter can be granted by `#drain()` in the same tick its timeout
|
|
57
|
+
// fires; settling once keeps the window/slot accounting exact.
|
|
58
|
+
let settled = false;
|
|
48
59
|
const onDone = (result) => {
|
|
60
|
+
if (settled) return;
|
|
61
|
+
settled = true;
|
|
49
62
|
clearTimeout(timer);
|
|
50
63
|
signal?.removeEventListener("abort", onAbort);
|
|
51
64
|
const idx = this.#waiters.indexOf(entry);
|
|
@@ -86,7 +99,12 @@ export class SlidingWindowStrategy {
|
|
|
86
99
|
return { granted: false };
|
|
87
100
|
}
|
|
88
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Mark one concurrent slot as in-use (converting a `#drain()` reservation
|
|
104
|
+
* when there is one, so the slot is never counted twice).
|
|
105
|
+
*/
|
|
89
106
|
acquireSlot() {
|
|
107
|
+
if (this.#reserved > 0) this.#reserved -= 1;
|
|
90
108
|
this.#concurrent += 1;
|
|
91
109
|
}
|
|
92
110
|
|
|
@@ -102,6 +120,7 @@ export class SlidingWindowStrategy {
|
|
|
102
120
|
countInWindow: this.#count(),
|
|
103
121
|
maxRpm: this.#cfg.maxRpm,
|
|
104
122
|
concurrent: this.#concurrent,
|
|
123
|
+
reserved: this.#reserved,
|
|
105
124
|
maxConcurrent: this.#cfg.maxConcurrent,
|
|
106
125
|
queued: this.#waiters.length,
|
|
107
126
|
};
|
|
@@ -139,8 +158,15 @@ export class SlidingWindowStrategy {
|
|
|
139
158
|
|
|
140
159
|
/** Drain waiters as long as capacity is available. */
|
|
141
160
|
#drain() {
|
|
142
|
-
while (
|
|
161
|
+
while (
|
|
162
|
+
this.#waiters.length > 0
|
|
163
|
+
&& this.#count() < this.#cfg.maxRpm
|
|
164
|
+
&& this.#concurrent + this.#reserved < this.#cfg.maxConcurrent
|
|
165
|
+
) {
|
|
143
166
|
this.#timestamps.push(Date.now());
|
|
167
|
+
// Reserve the concurrent slot for the waiter we are about to wake; it is
|
|
168
|
+
// converted into a live slot by `acquireSlot()` a microtask later.
|
|
169
|
+
this.#reserved += 1;
|
|
144
170
|
const waiter = this.#waiters.shift();
|
|
145
171
|
waiter?.resolve();
|
|
146
172
|
}
|
|
@@ -18,6 +18,15 @@ export class TokenBucketStrategy {
|
|
|
18
18
|
#tokens;
|
|
19
19
|
#lastRefill;
|
|
20
20
|
#concurrent = 0;
|
|
21
|
+
/**
|
|
22
|
+
* Slots handed to a waiter that has not called `acquireSlot()` yet.
|
|
23
|
+
*
|
|
24
|
+
* `#drain()` resolves several waiters in one pass, and each of them takes its
|
|
25
|
+
* concurrent slot only after the promise settles. Counting reservations keeps
|
|
26
|
+
* the drain loop from granting more waiters than `maxConcurrent` allows —
|
|
27
|
+
* without this, one `releaseSlot()` could release the whole queue at once.
|
|
28
|
+
*/
|
|
29
|
+
#reserved = 0;
|
|
21
30
|
/** @type {Array<{ resolve: (r: { granted: boolean }) => void, abort: () => void }>} */
|
|
22
31
|
#waiters = [];
|
|
23
32
|
#timer;
|
|
@@ -63,7 +72,12 @@ export class TokenBucketStrategy {
|
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
return new Promise((resolve) => {
|
|
75
|
+
// A waiter can be granted by `#drain()` in the same tick its timeout
|
|
76
|
+
// fires; settling once keeps the token/slot accounting exact.
|
|
77
|
+
let settled = false;
|
|
66
78
|
const onDone = (result) => {
|
|
79
|
+
if (settled) return;
|
|
80
|
+
settled = true;
|
|
67
81
|
clearTimeout(timer);
|
|
68
82
|
signal?.removeEventListener("abort", onAbort);
|
|
69
83
|
const idx = this.#waiters.indexOf(entry);
|
|
@@ -108,8 +122,14 @@ export class TokenBucketStrategy {
|
|
|
108
122
|
return { granted: false };
|
|
109
123
|
}
|
|
110
124
|
|
|
111
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* Mark one concurrent slot as in-use.
|
|
127
|
+
*
|
|
128
|
+
* When the slot was already reserved by `#drain()`, this converts the
|
|
129
|
+
* reservation into a live slot instead of taking a second one.
|
|
130
|
+
*/
|
|
112
131
|
acquireSlot() {
|
|
132
|
+
if (this.#reserved > 0) this.#reserved -= 1;
|
|
113
133
|
this.#concurrent += 1;
|
|
114
134
|
}
|
|
115
135
|
|
|
@@ -127,6 +147,7 @@ export class TokenBucketStrategy {
|
|
|
127
147
|
burstSize: this.#cfg.burstSize,
|
|
128
148
|
refillRate: this.#cfg.refillRate,
|
|
129
149
|
concurrent: this.#concurrent,
|
|
150
|
+
reserved: this.#reserved,
|
|
130
151
|
maxConcurrent: this.#cfg.maxConcurrent,
|
|
131
152
|
queued: this.#waiters.length,
|
|
132
153
|
};
|
|
@@ -159,8 +180,15 @@ export class TokenBucketStrategy {
|
|
|
159
180
|
|
|
160
181
|
/** Drain waiters as long as we have tokens and concurrency room. */
|
|
161
182
|
#drain() {
|
|
162
|
-
while (
|
|
183
|
+
while (
|
|
184
|
+
this.#waiters.length > 0
|
|
185
|
+
&& this.#tokens >= 1
|
|
186
|
+
&& this.#concurrent + this.#reserved < this.#cfg.maxConcurrent
|
|
187
|
+
) {
|
|
163
188
|
this.#tokens -= 1;
|
|
189
|
+
// Reserve the concurrent slot for the waiter we are about to wake; it is
|
|
190
|
+
// converted into a live slot by `acquireSlot()` a microtask later.
|
|
191
|
+
this.#reserved += 1;
|
|
164
192
|
const waiter = this.#waiters.shift();
|
|
165
193
|
waiter?.resolve();
|
|
166
194
|
}
|
package/lib/types/config.js
CHANGED
|
@@ -34,6 +34,20 @@ const ModelOverride = Schema.object({
|
|
|
34
34
|
enabled: Schema.boolean().description("Enable/disable rate limiting for this model"),
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Per-provider overrides — key is the provider id (e.g. `"openai"`).
|
|
39
|
+
*
|
|
40
|
+
* Sits between the global `defaults` and the per-model tier: one provider's
|
|
41
|
+
* models usually share a single quota, so this is where that quota belongs.
|
|
42
|
+
* Wildcards are accepted here too (`"mb-int-*"`).
|
|
43
|
+
*/
|
|
44
|
+
const ProviderOverride = Schema.object({
|
|
45
|
+
maxConcurrent: Schema.number().step(1).min(1).description("Max concurrent requests for this provider"),
|
|
46
|
+
maxRpm: Schema.number().step(1).min(1).description("Max requests per minute for this provider"),
|
|
47
|
+
burstSize: Schema.number().step(1).min(1).description("Burst capacity for this provider (token-bucket)"),
|
|
48
|
+
refillRate: Schema.number().min(0.1).description("Tokens refilled per second (token-bucket)"),
|
|
49
|
+
});
|
|
50
|
+
|
|
37
51
|
/**
|
|
38
52
|
* Per-model defaults — applied to every model unless overridden.
|
|
39
53
|
* The object is volatile AS A WHOLE so `config.defaults.get()` yields one
|
|
@@ -46,6 +60,20 @@ const Defaults = Schema.object({
|
|
|
46
60
|
refillRate: Schema.number().min(0.1).default(1).description("Tokens per second (token-bucket)"),
|
|
47
61
|
});
|
|
48
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Adaptive back-off after the provider answers 429.
|
|
65
|
+
*
|
|
66
|
+
* The limiter cannot see the provider's own quota arithmetic, so a `RATE_LIMIT`
|
|
67
|
+
* failure is treated as a hint: the model's rate is scaled by `factor` for
|
|
68
|
+
* `cooldownMs`, then restored after `recoverAfter` clean calls.
|
|
69
|
+
*/
|
|
70
|
+
const Cooldown = Schema.object({
|
|
71
|
+
enabled: Schema.boolean().default(true).description("Slow a model down after the provider returns 429"),
|
|
72
|
+
cooldownMs: Schema.number().step(1).min(1000).default(30_000).description("How long the reduced rate lasts"),
|
|
73
|
+
factor: Schema.number().min(0.05).max(0.9).step(0.05).default(0.5).description("Rate multiplier while cooling"),
|
|
74
|
+
recoverAfter: Schema.number().step(1).min(1).default(3).description("Clean calls before the full rate returns"),
|
|
75
|
+
});
|
|
76
|
+
|
|
49
77
|
export const Config = Schema.object({
|
|
50
78
|
/** Master switch */
|
|
51
79
|
enabled: Schema.boolean().default(true).volatile().description("Enable the rate limiter globally"),
|
|
@@ -60,7 +88,17 @@ export const Config = Schema.object({
|
|
|
60
88
|
defaults: Defaults.default({}).volatile().description("Default limits applied to all models"),
|
|
61
89
|
|
|
62
90
|
/**
|
|
63
|
-
* Per-
|
|
91
|
+
* Per-provider overrides — key is the provider id, wildcards allowed.
|
|
92
|
+
*
|
|
93
|
+
* Volatile as a whole block, for the same reason as `models` below: two
|
|
94
|
+
* volatile layers on one path are rejected by schemastery.
|
|
95
|
+
*/
|
|
96
|
+
providers: Schema.dict(ProviderOverride).default({}).volatile()
|
|
97
|
+
.description("Per-provider rate limit overrides"),
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Per-model overrides — key is "provider/model"; `*` matches any run of
|
|
101
|
+
* characters inside one path segment, so `openai/*` covers every OpenAI model.
|
|
64
102
|
*
|
|
65
103
|
* WHOLE-BLOCK volatile: `dict(inner).volatile()`, never
|
|
66
104
|
* `dict(inner.volatile())`. The latter marks the dict's `inner` while the
|
|
@@ -74,6 +112,9 @@ export const Config = Schema.object({
|
|
|
74
112
|
*/
|
|
75
113
|
models: Schema.dict(ModelOverride).default({}).volatile().description("Per-model rate limit overrides"),
|
|
76
114
|
|
|
115
|
+
/** Adaptive back-off after a provider-side 429 */
|
|
116
|
+
cooldown: Cooldown.default({}).volatile().description("Slow down after the provider returns 429"),
|
|
117
|
+
|
|
77
118
|
/** Queue behaviour when a request is throttled */
|
|
78
119
|
onThrottled: Schema.union([
|
|
79
120
|
Schema.const("queue").description("Wait in queue until a slot opens"),
|
package/package.json
CHANGED
|
@@ -1,17 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naced/llm-multi-rate-limiter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Per-model LLM call rate limiter for DeepSeek Harness with a Settings section UI, queueing and a live status panel",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
|
+
"types": "./lib/index.d.ts",
|
|
7
8
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
9
13
|
"./client": "./lib/client.js",
|
|
10
14
|
"./package.json": "./package.json"
|
|
11
15
|
},
|
|
12
16
|
"engines": {
|
|
17
|
+
"node": ">=20",
|
|
13
18
|
"dsh": ">=0.2.1-rc.2"
|
|
14
19
|
},
|
|
20
|
+
"author": "Naced <869521569@qq.com>",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/NacedWang/dsh-llm-multi-rate-limiter.git"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/NacedWang/dsh-llm-multi-rate-limiter#readme",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/NacedWang/dsh-llm-multi-rate-limiter/issues"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
15
33
|
"dsh": {
|
|
16
34
|
"bundle": {
|
|
17
35
|
"patch": "./cordis.patch.yml"
|
|
@@ -26,7 +44,11 @@
|
|
|
26
44
|
}
|
|
27
45
|
},
|
|
28
46
|
"scripts": {
|
|
29
|
-
"build": "node --check lib/index.js && node --check lib/client.js"
|
|
47
|
+
"build": "node --check lib/index.js && node --check lib/client.js && node --check lib/config-resolve.js",
|
|
48
|
+
"test": "node --test",
|
|
49
|
+
"lint": "eslint .",
|
|
50
|
+
"check": "npm run build && npm run lint && npm test",
|
|
51
|
+
"prepublishOnly": "npm run build && npm test"
|
|
30
52
|
},
|
|
31
53
|
"keywords": [
|
|
32
54
|
"dsh",
|
|
@@ -37,7 +59,6 @@
|
|
|
37
59
|
"sliding-window",
|
|
38
60
|
"cordis-plugin"
|
|
39
61
|
],
|
|
40
|
-
"license": "MIT",
|
|
41
62
|
"peerDependencies": {
|
|
42
63
|
"@deepseek-ai/cordis": ">=4.0.0",
|
|
43
64
|
"@deepseek-ai/dsh-settings": ">=0.1.7-rc.1",
|
|
@@ -47,14 +68,15 @@
|
|
|
47
68
|
"@deepseek-ai/schemastery": "^3.18.4"
|
|
48
69
|
},
|
|
49
70
|
"devDependencies": {
|
|
50
|
-
"@deepseek-ai/dsh-settings": "^0.1.7-rc.1"
|
|
71
|
+
"@deepseek-ai/dsh-settings": "^0.1.7-rc.1",
|
|
72
|
+
"eslint": "^9.15.0"
|
|
51
73
|
},
|
|
52
74
|
"files": [
|
|
53
75
|
"lib/",
|
|
54
76
|
"cordis.patch.yml",
|
|
55
77
|
"package.json",
|
|
56
|
-
"README.md",
|
|
57
78
|
"README.zh.md",
|
|
79
|
+
"README.md",
|
|
58
80
|
"CHANGELOG.md",
|
|
59
81
|
"LICENSE"
|
|
60
82
|
]
|