@leaf233/dsh-llm-rate-limiter 0.2.1 → 0.3.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 CHANGED
@@ -4,6 +4,194 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.3.0] - 2026-09-28
8
+
9
+ **Requires DSH 0.1.7-rc.1 or newer.** For DSH 0.1.5-rc.3, stay on `0.2.x`.
10
+
11
+ ### Changed (breaking)
12
+
13
+ DSH 0.1.7 replaced both client services this plugin used for its settings UI, and
14
+ removed the host namespace API. All four breakpoints are source-verified against
15
+ the installed 0.1.7-rc.1 tree:
16
+
17
+ - **Client: `settingsScope` → `configForms`.** `SettingsScopeController` is gone;
18
+ the page now hands each `plugins.item` entry a `ConfigForm` as
19
+ `props.form` (`dsh-client-ui-plugin-manager/lib/client.js:2627-2634`, `:2832`).
20
+ The snapshot shape is unchanged (`{status, value, base, user, revision, writable,
21
+ mode}`), so the card reads `props.form.state.value` exactly as it read
22
+ `settingsScope.getSnapshot().value`.
23
+ - **Client slot: `settings.plugin.item` → `plugins.item`**, declared by
24
+ `dsh-client-ui-plugin-manager` (`slot-contract.d.ts:90`). The page draws the
25
+ card chrome itself and asks the entry for `view: "summary"` and
26
+ `view: "page"`; the plugin renders the one-liner and the body respectively.
27
+ `id` replaces `key` (a list slot requires `id`, `dsh-client-ui-slots/lib/index.js:182`),
28
+ and `order: 900` parks the card after the official pages (10/20/30/40).
29
+ - **Client inject: `["slots", "locale", "configForms"]`**, and the plugin now
30
+ registers its own locale dictionary — an entry that `declares locale namespace`
31
+ with no dictionary installed throws `SlotAssemblyError`
32
+ (`dsh-client-ui-renderer/lib/client.js:723`). Previously the plugin declared
33
+ `locale: name` without ever registering it.
34
+ - **Host: `ctx.settings.register()` / `scope.watch()` removed.** Configuration is
35
+ now the profile entry's own `Config`, which the plugin exports
36
+ (`export { apply, name, SETTINGS_NS, Config }`) and reads through volatile
37
+ references. Every editable leaf is marked `.volatile()`.
38
+
39
+ ### Fixed
40
+
41
+ - **The plugin no longer hangs in `pending` on DSH 0.1.7.** It previously waited
42
+ for a `settingsScope` service the framework no longer provides.
43
+
44
+ ### Changed
45
+
46
+ - **`models` is volatile as ONE BLOCK** — `z.dict(ModelOverride).volatile()`,
47
+ never `z.dict(ModelOverride.volatile())`. The latter is rejected by
48
+ schemastery ("volatile fields require a fixed object path without an enclosing
49
+ volatile field", `schemastery/lib/index.mjs:246`). Because
50
+ `isVolatilePath` returns true on reaching ANY volatile node without inspecting
51
+ its descendants (`dsh-settings/lib/index.js:153-158`), nested writes such as
52
+ `["models", "provider/model", "maxRpm"]` still work — the plugin's per-model
53
+ editor is unaffected.
54
+ - Configuration is now read **at each use** (`config.enabled.get()`) rather than
55
+ cached. A volatile-only settings write updates the references in place via the
56
+ loader's `_commitVolatile()` (`cordis-plugin-loader/lib/index.js:393`)
57
+ **without re-running `apply()`**, so a cached snapshot would go stale.
58
+ - The plugin opts out of schema-generated pages with
59
+ `settings.configure({ auto: false }, ctx.fiber)` from an optional
60
+ `ctx.inject(["settings"], ...)` child, following the shipped idiom
61
+ (`dsh-settings/README.md:39`; `dsh-client-locale/lib/index.js:24`). It is not
62
+ currently required — nothing shipped consumes `autoGenerate` — but it is cheap
63
+ and matches the official pages.
64
+ - `dependencies`: `@deepseek-ai/schemastery` `^3.18.0` → `^3.18.4`.
65
+ `.volatile()` does not exist in 3.18.2 (0 occurrences; 28 in 3.18.4).
66
+ - `peerDependencies`: added `"@deepseek-ai/dsh-settings": ">=0.1.7-rc.1"` and
67
+ raised schemastery to `>=3.18.4`.
68
+ - The DSH gate is a **dsh-* subpackage** peer, not the `@deepseek-ai/dsh`
69
+ umbrella. `evaluatePluginCompatibility()` treats both identically — verified by
70
+ importing the real exported function and comparing its verdict for
71
+ `@deepseek-ai/dsh` against `@deepseek-ai/dsh-settings` across
72
+ `0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.6-alpha.2 / 0.1.7-rc.1 / 0.1.7-rc.2` (identical
73
+ every time: blocked on 0.1.5, allowed from 0.1.7-rc.1). The umbrella form costs
74
+ far more: a `pnpm install --lockfile-only` comparison measured **777,850 bytes**
75
+ for `@deepseek-ai/dsh` versus **27,725 bytes** with the subpackage — the umbrella
76
+ resolves the entire DSH dependency graph (~600 packages) into the lockfile.
77
+ Marking it `optional` in `peerDependenciesMeta` does NOT help (measured:
78
+ 777,588 bytes, effectively identical), because the gate reads
79
+ `peerDependencies` and never consults that field
80
+ (`dsh-app-boot/lib/index.js:289-301`) while pnpm's `autoInstallPeers` still
81
+ materializes it. This also matches the shipped convention: `dsh-free-search`,
82
+ `dsh-context`, and `@leaf233/dsh-model-relay` all gate on dsh-* subpackages.
83
+ Lockfile growth for this release is **+464/-8 lines (~26 KB)**, down from 8,015
84
+ lines with the umbrella peer.
85
+ - `devDependencies` holds only `@deepseek-ai/dsh-settings` — the one package the
86
+ schema test needs in order to exercise the real helpers. The previously listed
87
+ `dsh-client-store` / `dsh-client-ui-primitives` / `dsh-client-ui-slots`
88
+ entries were imported by nothing and were dropped.
89
+ - `dsh.client.inject` now also declares `dsh-client-locale` and
90
+ `dsh-client-ui-plugin-manager`.
91
+
92
+ ### Removed
93
+
94
+ - **`dsh.compatibility.dshReleases`** — a dead field. It has zero occurrences in
95
+ both the 0.1.5 and 0.1.7 trees; the real compatibility gate is
96
+ `evaluatePluginCompatibility()` (`dsh-app-boot/lib/index.js:286-313`), which
97
+ reads only `peerDependencies` matching `@deepseek-ai/dsh` / `dsh-*`.
98
+ - `lib/client.js`'s `RateLimiterConfig` export is now `Config` (also re-exported
99
+ from `lib/types/index.js`).
100
+
101
+ ### Added
102
+
103
+ - **`test-config-schema.mjs` (36 assertions)** — pins the contract the settings
104
+ page depends on: `Config` yields a non-empty `volatileForm`; `models` is
105
+ whole-block volatile and nested paths stay writable; `dict(inner.volatile())`
106
+ is refused; and a Config with no volatile field is dropped — the regression
107
+ guard against deleting a `.volatile()` mark.
108
+ - `test-host-integration.mjs` §13 proves volatile references are read in place:
109
+ it mutates the reference the plugin already holds and asserts the change
110
+ reaches the snapshot with no remount.
111
+ - `test-client-bundle.mjs` now covers the page protocol: both views, six write
112
+ paths through `props.form.mutate` (including the nested per-model case),
113
+ read-only/unavailable/missing-form degradation, and the `whileServed` gate.
114
+ - **`README.zh.md`** — full Simplified Chinese README, following the DSH house
115
+ convention of a `README.md` + `README.zh.md` pair (every official package
116
+ ships both). Both files carry a language switcher line under the badges, and
117
+ the Chinese file is listed in `package.json`'s `files` so it ships to npm.
118
+ Code blocks, tables, and section structure are 1:1 with the English original
119
+ (verified: 16 fence lines, 48 table rows, 1 YAML block each).
120
+ - The "Via file" section in both languages now describes 0.1.7 accurately:
121
+ configuration persists as the entry's own `config` in the profile's Cordis
122
+ patch, and a legacy `settings.yaml` is imported by DSH once at first start
123
+ (then renamed) rather than being the live store.
124
+
125
+ ### Fixed (tooling and docs)
126
+
127
+ - **`test-config-schema.mjs` now exercises the REAL framework helpers.** Its
128
+ resolution previously asked for `@deepseek-ai/dsh-settings/lib/types/schema.js`
129
+ directly, which can never resolve — that path is not in the package's
130
+ `exports` map (only `.`, `./types`, `./src/*`), so the suite silently fell back to a
131
+ local re-implementation on every run. It now resolves the package ROOT and
132
+ appends the file path, converts it with `pathToFileURL()` (a native Windows
133
+ path makes `import()` fail with `ERR_UNSUPPORTED_ESM_URL_SCHEME`), and
134
+ `@deepseek-ai/dsh-settings` is a devDependency so the real path is reachable.
135
+ Confirmed by the run header switching from "\u25cb not resolvable — using
136
+ equivalent local helpers" to "\u25cf using the real @deepseek-ai/dsh-settings
137
+ schema helpers" (36/36 pass). The fallback branch now prints a
138
+ `DSH_REAL_PACKAGES` hint, so a silent fallback is never mistaken for
139
+ framework-level proof.
140
+ - **CI now runs `test-config-schema.mjs`** and `node --check`s **all seven**
141
+ shipped lib modules (previously 5 of 6 suites and 3 files). The contract that
142
+ decides whether the settings card exists at all — an entry with no volatile
143
+ field is dropped (`dsh-settings/lib/index.js:419`) — had no regression
144
+ protection in CI, while being the one failure mode that leaves the rate
145
+ limiter working and the card silently missing.
146
+ - **CI Node matrix is now `[20, 22, 24]`** (was `[18, 20, 22]`): 18 is EOL and 24
147
+ is the version the plugin is developed and run on.
148
+ - **`tools/verify-install.mjs` skips its install half instead of failing** when
149
+ the plugin is not present in the profile, and falls back to importing the
150
+ source tree. Exit codes are now 0 = fully verified, 1 = a check failed,
151
+ 2 = source-tree checks passed but the install half was skipped (the same
152
+ contract as `tools/verify-live-route.mjs`). A local run without an installed
153
+ profile goes from 2 misleading failures to **31 passed / 0 failed / exit 2**.
154
+ - Removed the dangling `COMPATIBILITY.md` references left when that file was
155
+ deleted: the `package.json` `files` entry, the README badge link in both
156
+ languages, and the closing "see COMPATIBILITY.md" links (now pointing at
157
+ `CHANGELOG.md` and the inline source comments). The historical `CHANGELOG.md`
158
+ mention under `[0.1.0]` is intentionally kept, and the `lib/index.js` header
159
+ comment no longer points at the deleted file.
160
+
161
+ ### Notes
162
+
163
+ - `test-3rpm.mjs` is deliberately **left out of the `test` script**. It prints
164
+ conclusions rather than making assertions, and it re-implements
165
+ `resolveModelConfig` instead of importing it (`test-3rpm.mjs:15-28`), so it
166
+ cannot detect a drift between itself and `lib/index.js`. It still runs
167
+ cleanly against 0.3.0. Converting it into a real assertion suite would mean
168
+ exporting the resolver or driving the interceptor — out of scope for this
169
+ migration.
170
+
171
+ ### Verified
172
+
173
+ - All six suites green on DSH `0.1.7-rc.1` packages with Cordis 4.0.4,
174
+ schemastery 3.18.4, Node 24.21.0: **19 + 61 + 106 + 80 + 73 + 36 = 375
175
+ assertions, 0 failures**.
176
+ - `node tools/verify-install.mjs`: 37 passed, 0 failed.
177
+ - Against the **real** `@deepseek-ai/dsh-settings` service: `describe()` returns
178
+ the entry with all six volatile fields, `autoGenerate: false`, and
179
+ `models` unwrapped to a plain object; a nested
180
+ `["models", "c/d", "maxRpm"]` mutation passes `write()`'s volatile gate while
181
+ a non-volatile path is still refused (12/12).
182
+ - The status channel needs no change: the `dsh-client-connection` `inject`
183
+ mismatch that forces the fallback is **still unfixed in 0.1.7-rc.1**
184
+ (`dsh-client-connection/lib/index.js:798` keeps `inject = ["credentials"]`),
185
+ so the v0.2.1 dual-path carrier continues to work as designed.
186
+ - Producer: **deepseek-v4.1-flash**.
187
+
188
+ ### Migration
189
+
190
+ - On DSH 0.1.7+: install `0.3.0`. Your existing `llm-rate-limiter` configuration
191
+ in the profile's settings/cordis patch is read as the entry's own `config`;
192
+ no manual migration was performed by the plugin.
193
+ - On DSH 0.1.5-rc.3: keep `0.2.x`.
194
+
7
195
  ## [0.2.1] - 2026-09-25
8
196
 
9
197
  ### Fixed
@@ -71,4 +259,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
71
259
  - `maxQueueWaitMs` timeout for queue mode
72
260
  - 19 unit tests covering concurrency, burst, queue, abort, and hot-reload
73
261
  - E2E test (`test-3rpm.mjs`) verifying 3 rpm limit
74
- - DSH version compatibility analysis (COMPATIBILITY.md)
262
+ - DSH version compatibility analysis (COMPATIBILITY.md)
package/README.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@leaf233/dsh-llm-rate-limiter.svg)](https://www.npmjs.com/package/@leaf233/dsh-llm-rate-limiter)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
- [![DSH 0.1.x](https://img.shields.io/badge/DSH-0.1.x-brightgreen.svg)](COMPATIBILITY.md)
6
- [![Cordis 4.x](https://img.shields.io/badge/Cordis-%3E%3D4.0.2-brightgreen.svg)](package.json)
7
- [![Tests](https://img.shields.io/badge/tests-300%20passing-brightgreen.svg)](test-status-route.mjs)
5
+ ![DSH 0.1.7+](https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-brightgreen.svg)
6
+ [![Cordis 4.x](https://img.shields.io/badge/Cordis-%3E%3D4.0.0-brightgreen.svg)](package.json)
7
+ [![Tests](https://img.shields.io/badge/tests-375%20passing-brightgreen.svg)](test-config-schema.mjs)
8
+
9
+ English | [中文](README.zh.md)
8
10
 
9
11
  Per-model LLM call rate limiter for [DeepSeek Harness](https://github.com/deepseek-ai/dsh) with queue/reject support and interactive GUI configuration.
10
12
 
@@ -48,15 +50,13 @@ Expanding the card shows a live panel at the top of its body:
48
50
  | Counters | `requests` / `granted` / `rejected` / `timeouts` / `aborted` / `totalWaitMs`, plus the last 8 events (ring buffer of 64) |
49
51
  | Progress bars | Token bucket shows `tokens/burstSize`; sliding window shows `countInWindow/maxRpm`; both turn amber as the limit approaches |
50
52
 
51
- > 🧠 **From Hindsight memory (dsh-context-host-client)** — the channel idiom is dsh-context's: `ctx.inject(["connection"])` → `conn.rpc.handle(channel, handler)`, with the browser side resolving `ctx.get("connection")?.rpc.call` defensively so a missing service degrades instead of throwing.
52
-
53
- ### Carrier fallback (DSH 0.1.5-rc.3)
53
+ ### Carrier fallback (DSH 0.1.5-rc.3 onward)
54
54
 
55
55
  DSH 0.1.5-rc.3 changed `@deepseek-ai/dsh-client-connection`'s own `inject` from
56
56
  `["webServer", "credentials"]` to `["credentials"]`, but its
57
57
  `HostConnectionService.register()` still dereferences `owner.webServer`.
58
58
  Cordis rebinds a cross-fiber service's `ctx` to the *reader's* fiber, so
59
- `connection.rpc.handle()` throws:
59
+ `connection.rpc.handle()` throws: **still present in 0.1.7-rc.1** — see below.
60
60
 
61
61
  ```
62
62
  cannot get property "webServer" without inject
@@ -97,6 +97,9 @@ dsh plugin add ./path/to/dsh-llm-rate-limiter # default profile
97
97
 
98
98
  > The plugin must be added as a dependency in the profile's `package.json`.
99
99
  > The bundle entry (`cordis.patch.yml`) is auto-detected by `reconcilePlugins`.
100
+ >
101
+ > **0.3.0 requires DSH 0.1.7-rc.1 or newer.** Pin `@leaf233/dsh-llm-rate-limiter@0.2.x`
102
+ > for DSH 0.1.5. See the [compatibility table](#compatibility).
100
103
 
101
104
  ### Option 3: from GitHub
102
105
 
@@ -121,13 +124,20 @@ dsh plugin add <your-profile> github:Leafyezi233/dsh-llm-rate-limiter
121
124
  ### Via GUI
122
125
 
123
126
  1. Open DSH Web UI (`dsh web`)
124
- 2. Go to **Settings → Plugins**
125
- 3. Find **⚙ LLM 调用限速** card — click to expand
126
- 4. Configure defaults, per-model overrides, and throttle behavior
127
+ 2. Go to **Settings → Plugins** and open the **Official** group
128
+ 3. Find the **LLM 调用限速** card (`data-plugin-item="llm-rate-limiter"`) and open it
129
+ 4. Configure defaults, per-model overrides, and throttle behavior — changes save
130
+ through the page's own form, so the live status panel is right there
131
+
132
+ > The card lives in the **Official** group because DSH lists every `plugins.item`
133
+ > registrant there; the plugin's order is `900`, so it follows the five official
134
+ > settings pages.
127
135
 
128
136
  ### Via file
129
137
 
130
- Edit the profile's `settings.yaml` or use the GUI — changes are persisted to the DSH settings store:
138
+ Changes persist into the profile's Cordis patch, in the entry's own `config`
139
+ (0.1.7 reads configuration from the entry's exported `Config`; a legacy
140
+ `settings.yaml` is imported by DSH once at first start and then renamed). The shape is:
131
141
 
132
142
  ```yaml
133
143
  llm-rate-limiter:
@@ -214,15 +224,16 @@ cd dsh-llm-rate-limiter
214
224
  # Install deps
215
225
  pnpm install
216
226
 
217
- # Run the full suite (300 assertions across 5 files)
227
+ # Run the full suite (375 assertions across 6 files)
218
228
  pnpm test
219
229
 
220
230
  # Individual suites
221
231
  node test-strategies.mjs # 19 — rate-limit algorithms
222
232
  node test-status-rpc.mjs # 61 — channel handler + counters
223
- node test-client-bundle.mjs # 70 — real client bundle on a miniature React runtime
224
- node test-host-integration.mjs # 77 — real apply() wiring
233
+ node test-client-bundle.mjs # 106 — real client bundle on a miniature React runtime
234
+ node test-host-integration.mjs # 80 — real apply(ctx, config) wiring + volatile refs
225
235
  node test-status-route.mjs # 73 — self-registered route + the 0.1.5 regression
236
+ node test-config-schema.mjs # 36 — Config volatile contract the settings page needs
226
237
 
227
238
  # Live HTTP proof against a real DSH install (real socket, real webserver)
228
239
  pnpm run verify:live # 19 — exits 2 (skipped) when DSH is absent
@@ -240,14 +251,30 @@ The plugin uses a live symlink when installed via `link:` — edits to `lib/` ta
240
251
 
241
252
  ## Compatibility
242
253
 
243
- | DSH Version | Status | Notes |
244
- |-------------|--------|-------|
245
- | 0.1.2-rc.1 | ✅ Tested | Original target; `connection.rpc` carrier |
246
- | 0.1.5-rc.3 | ✅ Tested | Requires the carrier fallback (see above); verified over real HTTP |
247
- | 0.2.x | ⚠️ Untested | May need API adjustments |
248
- | Cordis 5+ | ⚠️ Untested | Major version change likely requires rewrite |
254
+ | DSH Version | Plugin version | Status | Notes |
255
+ |-------------|----------------|--------|-------|
256
+ | 0.1.2-rc.1 | 0.2.x | ✅ Tested | Original target; `connection.rpc` carrier |
257
+ | 0.1.5-rc.3 | 0.2.x | ✅ Tested | Requires the carrier fallback; verified over real HTTP |
258
+ | **0.1.7-rc.1+** | **0.3.x** | ✅ **Tested** | **`settingsScope` → `configForms`; `settings.plugin.item` → `plugins.item`; Config-based settings** |
259
+ | 0.2.x (DSH) | — | ⚠️ Untested | May need API adjustments |
260
+ | Cordis 5+ | — | ⚠️ Untested | Major version change likely requires rewrite |
261
+
262
+ ### Breaking change in 0.3.0
263
+
264
+ DSH 0.1.7 removed the two client services this plugin used for its settings UI:
265
+
266
+ | 0.1.5 | 0.1.7 |
267
+ |-------|-------|
268
+ | `settingsScope` service | `configForms` service |
269
+ | `settings.plugin.item` slot | `plugins.item` slot |
270
+ | host `ctx.settings.register(ns, schema, { base })` | the entry's own exported `Config` schema |
271
+ | host `scope.get()` / `scope.watch()` | `.volatile()` references, read with `.get()` |
272
+
273
+ **0.3.0 only supports DSH 0.1.7-rc.1+. Use `0.2.x` for 0.1.5.**
249
274
 
250
- See [COMPATIBILITY.md](COMPATIBILITY.md) for detailed API dependency analysis.
275
+ The four breakpoints this migration rests on are documented inline in
276
+ `lib/index.js` and `lib/types/config.js`, and the reasoning is recorded in
277
+ [CHANGELOG.md](CHANGELOG.md) under `[0.3.0]`.
251
278
 
252
279
  ---
253
280
 
package/README.zh.md ADDED
@@ -0,0 +1,284 @@
1
+ # dsh-llm-rate-limiter
2
+
3
+ [![npm](https://img.shields.io/npm/v/@leaf233/dsh-llm-rate-limiter.svg)](https://www.npmjs.com/package/@leaf233/dsh-llm-rate-limiter)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+ ![DSH 0.1.7+](https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-brightgreen.svg)
6
+ [![Cordis 4.x](https://img.shields.io/badge/Cordis-%3E%3D4.0.0-brightgreen.svg)](package.json)
7
+ [![Tests](https://img.shields.io/badge/tests-375%20passing-brightgreen.svg)](test-config-schema.mjs)
8
+
9
+ [English](README.md) | 中文
10
+
11
+ [DeepSeek Harness](https://github.com/deepseek-ai/dsh) 的**按模型 LLM 调用限速插件**,支持排队/拒绝两种节流策略,并带交互式图形配置界面。
12
+
13
+ ---
14
+
15
+ ## 功能
16
+
17
+ - **按模型限速** —— 每个 `provider/model` 独立的并发数、RPM 与突发容量限制
18
+ - **两种算法** —— 令牌桶(允许突发)或滑动窗口(平滑、严格 RPM)
19
+ - **排队模式** —— 被限流的请求排队等待,有槽位空出时放行
20
+ - **拒绝模式** —— 被限流的请求立即失败(可与 `dsh-llm-retry` 配合自动退避重试)
21
+ - **交互式界面** —— DSH 设置 → 插件页内的可展开配置卡片
22
+ - **实时状态面板** —— 实时计数器、按模型的进度条与事件日志(v0.2.0 起)
23
+ - **热更新** —— 配置改动立即生效,无需重启
24
+ - **全量检查** —— 拦截 `llm/stream` waterfall,覆盖每个 agent 轮次里的每一次 LLM 调用
25
+
26
+ ---
27
+
28
+ ## 状态面板(v0.2.0)
29
+
30
+ 打开卡片后,其正文顶部会显示实时面板:
31
+
32
+ ```
33
+ 📊 实时状态 ● 实时 [清零]
34
+ 请求 42 · 通过 38 · 拒绝 2 · 超时 1 · 中止 1 · 平均等待 214ms
35
+ deepseek/deepseek-chat [令牌桶] ▓▓▓▓▓▓▓░░░ 7.5/10 并发 2/5 排队 1
36
+ openai/gpt-4o [滑动窗口] ▓▓▓▓▓▓▓▓▓▓ 3/3 rpm 并发 1/5
37
+ 12:00:03 timeout openai/gpt-4o 等待 1m
38
+ 12:00:01 rejected openai/gpt-4o
39
+ 12:00:00 granted deepseek/deepseek-chat 等待 4.2s
40
+ ```
41
+
42
+ | 方面 | 行为 |
43
+ |------|------|
44
+ | 数据通道 | 通道 `/llm-rate-limiter` —— 带认证(401/403 栅栏),POST+JSON,随插件 fiber 自动清理 |
45
+ | 载体 | 优先用框架的 `connection.rpc.handle()`;框架路径失效时,退回到插件自注册的前缀路由并复用 `connection.requestRejection()`(见下文) |
46
+ | 轮询节奏 | 卡片展开时 1 秒轮询;连续失败后退避 2 秒 → 4 秒 → 8 秒 |
47
+ | 折叠状态 | 面板卸载,因此**完全不轮询** |
48
+ | 端点 | `snapshot`(实时计数)与 `reset`(清零统计) |
49
+ | 通道不可用时 | 显示「状态通道不可用」,卡片其余部分功能不受影响 |
50
+ | 计数器 | `requests` / `granted` / `rejected` / `timeouts` / `aborted` / `totalWaitMs`,外加最近 8 条事件(环形缓冲为 64) |
51
+ | 进度条 | 令牌桶显示 `tokens/burstSize`;滑动窗口显示 `countInWindow/maxRpm`;接近上限时变琥珀色 |
52
+
53
+ ### 载体兜底(DSH 0.1.5-rc.3 起)
54
+
55
+ DSH 0.1.5-rc.3 把 `@deepseek-ai/dsh-client-connection` **这个框架插件自身**的 `inject` 从
56
+ `["webServer", "credentials"]` 改成了 `["credentials"]`,但其
57
+ `HostConnectionService.register()` 仍然解引用 `owner.webServer`。
58
+ Cordis 会把跨 fiber 读取的 service 的 `ctx` 重绑定到**读取者**的 fiber,因此
59
+ `connection.rpc.handle()` 会抛错:**在 0.1.7-rc.1 中依然存在** —— 见下文。
60
+
61
+ ```
62
+ cannot get property "webServer" without inject
63
+ ```
64
+
65
+ 插件会检测到该错误,并自行挂载同一条通道:
66
+
67
+ | 路径 | 触发条件 | 做法 |
68
+ |------|---------|------|
69
+ | 1(首选) | `connection.rpc.handle()` 正常 | 由框架持有路由、请求校验与 fiber 级撤销 |
70
+ | 2(兜底) | 路径 1 抛错 | 插件在**自己的 fiber**(能看到 `webServer`)注册 `kind: "prefix"` 路由,并复用 `connection.requestRejection()` 作为 403/401 栅栏 |
71
+
72
+ 两条载体使用逐字相同的 wire 协议,因此**浏览器端无需改动** —— 面板也分辨不出
73
+ 当前是哪一条。若 `connection.requestRejection()` 不可用,插件会**拒绝挂载**,
74
+ 而不是发布一条未鉴权的路由。
75
+
76
+ ---
77
+
78
+ ## 安装
79
+
80
+ ### 方式 1:npm(推荐)
81
+
82
+ ```bash
83
+ dsh plugin add <your-profile> @leaf233/dsh-llm-rate-limiter
84
+ # 或在该 profile 目录内:
85
+ pnpm add @leaf233/dsh-llm-rate-limiter
86
+ ```
87
+
88
+ ### 方式 2:本地路径(开发)
89
+
90
+ ```bash
91
+ dsh plugin add <your-profile> ./path/to/dsh-llm-rate-limiter
92
+ # 或
93
+ dsh plugin add ./path/to/dsh-llm-rate-limiter # 默认 profile
94
+ ```
95
+
96
+ > 插件必须被加入该 profile 的 `package.json` 依赖中。
97
+ > bundle 入口(`cordis.patch.yml`)由 `reconcilePlugins` 自动识别。
98
+ >
99
+ > **0.3.0 要求 DSH 0.1.7-rc.1 或更新版本。** 若使用 DSH 0.1.5,请锁定
100
+ > `@leaf233/dsh-llm-rate-limiter@0.2.x`。见[兼容性表](#兼容性)。
101
+
102
+ ### 方式 3:从 GitHub 安装
103
+
104
+ ```bash
105
+ dsh plugin add <your-profile> github:Leafyezi233/dsh-llm-rate-limiter
106
+ ```
107
+
108
+ > ⚠️ **重要**:从 Git 安装的插件在首次安装时会被 pnpm 的 `allowBuilds` 限制拦下。
109
+ > 若安装失败,请查看报错信息中 pnpm 给出的确切键名,然后加入该 profile 的
110
+ > `pnpm-workspace.yaml`:
111
+ >
112
+ > ```yaml
113
+ > pnpm:
114
+ > allowBuilds:
115
+ > - '@leaf233/dsh-llm-rate-limiter'
116
+ > ```
117
+ >
118
+ > 然后重新执行安装命令。
119
+
120
+ ---
121
+
122
+ ## 配置
123
+
124
+ ### 通过图形界面
125
+
126
+ 1. 打开 DSH Web UI(`dsh web`)
127
+ 2. 进入**设置 → 插件**,展开 **Official** 分组
128
+ 3. 找到 **LLM 调用限速** 卡片(`data-plugin-item="llm-rate-limiter"`)并打开
129
+ 4. 配置全局默认值、按模型覆盖与节流行为 —— 改动经页面自带的表单保存,
130
+ 实时状态面板就在同一页
131
+
132
+ > 卡片出现在 **Official** 分组里,是因为 DSH 把所有 `plugins.item` 的注册者
133
+ > 都列在那里;本插件的 `order` 为 `900`,因此排在五个官方设置页之后。
134
+
135
+ ### 通过配置文件
136
+
137
+ 改动会被持久化到该 profile 的 Cordis patch 中该条目的 `config` 里
138
+ (0.1.7 起,插件以自己的 `Config` 作为配置来源;旧版 `settings.yaml` 由
139
+ DSH 在首次启动时导入一次并改名)。形状如下:
140
+
141
+ ```yaml
142
+ llm-rate-limiter:
143
+ enabled: true
144
+ strategy: token-bucket # "token-bucket" | "sliding-window"
145
+ defaults:
146
+ maxConcurrent: 5
147
+ maxRpm: 60
148
+ burstSize: 10 # 仅令牌桶
149
+ refillRate: 1 # 仅令牌桶(每秒补充的令牌数)
150
+ models:
151
+ "deepseek/deepseek-chat":
152
+ maxConcurrent: 8
153
+ maxRpm: 120
154
+ "openai/gpt-4o":
155
+ maxConcurrent: 2
156
+ maxRpm: 10
157
+ burstSize: 3
158
+ "anthropic/claude-3-5-sonnet":
159
+ enabled: false # 对该模型跳过限速
160
+ onThrottled: queue # "queue" | "reject"
161
+ maxQueueWaitMs: 60000
162
+ ```
163
+
164
+ ---
165
+
166
+ ## 设置项参考
167
+
168
+ | 字段 | 默认值 | 说明 |
169
+ |------|--------|------|
170
+ | `enabled` | `true` | 全局总开关。关闭时零开销直接放行。 |
171
+ | `strategy` | `"token-bucket"` | `"token-bucket"`(允许突发)或 `"sliding-window"`(平滑、严格 RPM) |
172
+ | `defaults.maxConcurrent` | `5` | 每个模型的最大同时请求数 |
173
+ | `defaults.maxRpm` | `60` | 每个模型每分钟最大请求数 |
174
+ | `defaults.burstSize` | `10` | 令牌桶容量 —— 一次最多可突发多少个请求 |
175
+ | `defaults.refillRate` | `1` | 每秒补充的令牌数(令牌桶)。未设置时由 `maxRpm / 60` 自动推导。 |
176
+ | `models.<key>.maxConcurrent` | — | 该模型的并发数覆盖 |
177
+ | `models.<key>.maxRpm` | — | 该模型的 RPM 覆盖 |
178
+ | `models.<key>.burstSize` | — | 该模型的突发容量覆盖 |
179
+ | `models.<key>.refillRate` | — | 该模型的补充速率覆盖 |
180
+ | `models.<key>.enabled` | — | 设为 `false` 可对该模型跳过限速 |
181
+ | `onThrottled` | `"queue"` | 请求触限时的行为:`"queue"`(等待)或 `"reject"`(立即失败) |
182
+ | `maxQueueWaitMs` | `60000` | 请求在队列中等待多久(毫秒)后放弃并被拒绝 |
183
+
184
+ > **注意**:当模型覆盖了 `maxRpm` 但未显式设置 `refillRate` 时,补充速率会
185
+ > 自动按 `maxRpm / 60`(每秒令牌数)推导。这样才能保证「设 maxRpm=3」真的限制到
186
+ > 每分钟 3 个请求。
187
+
188
+ ---
189
+
190
+ ## 算法对比
191
+
192
+ | | 令牌桶 | 滑动窗口 |
193
+ |---|--------|----------|
194
+ | **突发** | 允许(由 `burstSize` 控制) | 不允许 —— 严格平滑 |
195
+ | **恢复** | 令牌按 `refillRate`/秒补充 | 窗口持续滑动 |
196
+ | **最适合** | 容忍请求尖峰 | 有硬性每分钟上限的 API |
197
+ | **界面名称** | 令牌桶 (Token Bucket) | 滑动窗口 (Sliding Window) |
198
+
199
+ ---
200
+
201
+ ## 工作原理
202
+
203
+ ```
204
+ Agent 轮次
205
+ → LLM 调用(例如 deepseek/deepseek-chat)
206
+ → ctx.on("llm/stream") 拦截器
207
+ → 为该 provider/model 解析对应的限速器
208
+ → 令牌桶:还有令牌且并发未满?
209
+ → 是:消耗令牌、占用槽位、转发到 API
210
+ → 否(拒绝模式):立即返回 RATE_LIMIT 错误
211
+ → 否(排队模式):进入 waiters[] 等待令牌补充
212
+ → 请求完成 → 释放槽位 → 唤醒等待中的请求
213
+ → dsh-llm-retry 捕获 RATE_LIMIT → 指数退避 → 重试
214
+ ```
215
+
216
+ ---
217
+
218
+ ## 开发
219
+
220
+ ```bash
221
+ # 克隆
222
+ git clone https://github.com/Leafyezi233/dsh-llm-rate-limiter.git
223
+ cd dsh-llm-rate-limiter
224
+
225
+ # 安装依赖
226
+ pnpm install
227
+
228
+ # 跑完整测试套件(6 个文件,375 条断言)
229
+ pnpm test
230
+
231
+ # 单独运行各套件
232
+ node test-strategies.mjs # 19 —— 限速算法
233
+ node test-status-rpc.mjs # 61 —— 通道处理器与计数器
234
+ node test-client-bundle.mjs # 106 —— 真实客户端 bundle 跑在迷你 React 运行时上
235
+ node test-host-integration.mjs # 80 —— 真实 apply(ctx, config) 接线与 volatile 引用
236
+ node test-status-route.mjs # 73 —— 自注册路由与 0.1.5 回归
237
+ node test-config-schema.mjs # 36 —— 设置页所依赖的 Config volatile 契约
238
+
239
+ # 针对真实 DSH 安装的实机 HTTP 验证(真实 socket、真实 webserver)
240
+ pnpm run verify:live # 19 —— 无 DSH 时以退出码 2 跳过
241
+
242
+ # 运行端到端限速测试
243
+ node test-3rpm.mjs
244
+
245
+ # 安装到某个 DSH profile 里测试
246
+ dsh plugin add <your-profile> .
247
+ ```
248
+
249
+ 通过 `link:` 安装时,插件走实时符号链接 —— 对 `lib/` 的改动在浏览器硬刷新
250
+ (`Ctrl+Shift+R`)后即生效,无需重新安装。
251
+
252
+ ---
253
+
254
+ ## 兼容性
255
+
256
+ | DSH 版本 | 插件版本 | 状态 | 说明 |
257
+ |----------|---------|------|------|
258
+ | 0.1.2-rc.1 | 0.2.x | ✅ 已测 | 最初目标;`connection.rpc` 载体 |
259
+ | 0.1.5-rc.3 | 0.2.x | ✅ 已测 | 需要载体兜底;已通过实机 HTTP 验证 |
260
+ | **0.1.7-rc.1+** | **0.3.x** | ✅ **已测** | **`settingsScope` → `configForms`;`settings.plugin.item` → `plugins.item`;基于 Config 的设置** |
261
+ | 0.2.x(DSH) | — | ⚠️ 未测 | 可能需要调整 API |
262
+ | Cordis 5+ | — | ⚠️ 未测 | 大版本升级多半需要重写 |
263
+
264
+ ### 0.3.0 的破坏性变更
265
+
266
+ DSH 0.1.7 移除了本插件设置界面所用的两个客户端服务:
267
+
268
+ | 0.1.5 | 0.1.7 |
269
+ |-------|-------|
270
+ | `settingsScope` 服务 | `configForms` 服务 |
271
+ | `settings.plugin.item` 槽位 | `plugins.item` 槽位 |
272
+ | 宿主 `ctx.settings.register(ns, schema, { base })` | 条目自己导出的 `Config` schema |
273
+ | 宿主 `scope.get()` / `scope.watch()` | `.volatile()` 引用,用 `.get()` 读取 |
274
+
275
+ **0.3.0 只支持 DSH 0.1.7-rc.1+。使用 0.1.5 请安装 `0.2.x`。**
276
+
277
+ 本次迁移所依赖的四个断裂点,已就地写在 `lib/index.js` 与 `lib/types/config.js`
278
+ 的注释里;完整推理记录在 [CHANGELOG.md](CHANGELOG.md) 的 `[0.3.0]` 段落。
279
+
280
+ ---
281
+
282
+ ## 许可证
283
+
284
+ [MIT](LICENSE)