@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.
@@ -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 (this.#waiters.length > 0 && this.#count() < this.#cfg.maxRpm && this.#concurrent < this.#cfg.maxConcurrent) {
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
- /** Mark one concurrent slot as in-use. */
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 (this.#waiters.length > 0 && this.#tokens >= 1 && this.#concurrent < this.#cfg.maxConcurrent) {
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
  }
@@ -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-model overrides — key is "provider/model".
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.0",
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
- ".": "./lib/index.js",
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
  ]