@leaf233/dsh-llm-rate-limiter 0.2.0 → 0.2.1
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 +22 -0
- package/COMPATIBILITY.md +79 -8
- package/README.md +41 -5
- package/lib/index.js +72 -10
- package/lib/status-rpc.js +227 -9
- package/package.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ 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.2.1] - 2026-09-25
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **Live status panel stopped working on DSH 0.1.5-rc.3** — the channel silently failed to mount and the panel sat on "○ 重试中" forever. Root cause is upstream: DSH 0.1.5-rc.3 changed `@deepseek-ai/dsh-client-connection`'s own `inject` from `["webServer", "credentials"]` to `["credentials"]`, while `HostConnectionService.register()` still dereferences `owner.webServer`. Cordis rebinds a cross-fiber service's `ctx` to the *reader's* fiber, so `connection.rpc.handle()` threw `cannot get property "webServer" without inject` — which the plugin's own try/catch swallowed.
|
|
11
|
+
- The plugin now falls back to a **self-registered `kind: "prefix"` route** on its own fiber (which can see `webServer`) and reuses `connection.requestRejection()` for the 403/401 fence. Both carriers speak the identical wire protocol, so **`lib/client.js` is unchanged**.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `createChannelRoute({ channel, handler, reject })` and `endpointFromPath()` in `lib/status-rpc.js` — a Connection-RPC-compatible webserver prefix route, mirroring the framework's `rpcFetchHandler` request-shape decisions (404 / 415 / 413 / 400 / `gateway/bad-request` / 500 / 200)
|
|
15
|
+
- `test-status-route.mjs` (73 assertions) in four layers: route grammar, request-shape decisions, a simulated 0.1.5 regression (proves both the fallback *and* the preference for `rpc.handle` when it works), and the real 0.1.5 package (auto-skips when DSH is absent)
|
|
16
|
+
- `tools/verify-live-route.mjs` (19 assertions) — boots the real `dsh-host-webserver` on a real socket plus the real `dsh-client-connection`, then issues real HTTP requests; exits 2 (skipped) without a DSH install
|
|
17
|
+
- `pnpm run verify:live` script
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- `dsh.compatibility.dshReleases` now declares `0.1.5-rc.3` as compatible alongside `0.1.2-rc.1`
|
|
21
|
+
- The status channel's mount log now names the active carrier (`connection.rpc` vs `self-registered route`), and a failed path-1 registration is reported as a warning instead of being swallowed
|
|
22
|
+
- If `connection.requestRejection()` is unavailable or throws, the plugin **refuses to mount** rather than publishing an unauthenticated route
|
|
23
|
+
|
|
24
|
+
### Notes
|
|
25
|
+
- Path 1 (`connection.rpc.handle`) is still preferred: it keeps route ownership, request validation, and withdrawal with the framework. The fallback exists only for hosts where path 1 is broken.
|
|
26
|
+
- When DSH fixes the upstream `inject` mismatch, path 1 succeeds again and the fallback goes unused — no plugin change required.
|
|
27
|
+
- Verified on: DSH `0.1.5-rc.3` (client-connection 0.1.5-rc.3, host-webserver 0.1.5-rc.3), Cordis 4.0.2, Node 24.21.0. Test totals: 19 + 61 + 70 + 77 + 73 = **300 assertions**, plus 31 in `tools/verify-install.mjs` and 19 in `tools/verify-live-route.mjs`. Producer: **deepseek-v4.1-flash**.
|
|
28
|
+
|
|
7
29
|
## [0.2.0] - 2026-09-13
|
|
8
30
|
|
|
9
31
|
### Added
|
package/COMPATIBILITY.md
CHANGED
|
@@ -1,8 +1,73 @@
|
|
|
1
1
|
# dsh-llm-rate-limiter — DSH 版本兼容性检测报告
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
>
|
|
5
|
-
> 测试方式:
|
|
3
|
+
> 初检日期: 2026-07-28 · 复检日期: **2026-09-25**
|
|
4
|
+
> 已验证 DSH 版本: **0.1.2-rc.1**、**0.1.5-rc.3**
|
|
5
|
+
> 测试方式: 源码分析 + 可执行回归测试(`test-status-route.mjs`、`tools/verify-live-route.mjs` 走真实 socket)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 〇、0.1.5-rc.3 回归与修复(2026-09-25 复检)
|
|
10
|
+
|
|
11
|
+
### 现象
|
|
12
|
+
升级到 DSH **0.1.5-rc.3** 后,状态面板永久停在「○ 重试中」,实时数据不再刷新;限速本身与设置页配置完全正常。
|
|
13
|
+
|
|
14
|
+
### 根因(已用真实包 + 真实 Cordis 复现,非推断)
|
|
15
|
+
DSH 0.1.5-rc.3 把 **`@deepseek-ai/dsh-client-connection` 这个框架插件自身**的 `inject` 从
|
|
16
|
+
`["webServer", "credentials"]` 改成了 `["credentials"]`(lib/index.js:736),
|
|
17
|
+
但其 `HostConnectionService.register()` 仍然直接解引用 `owner.webServer`:
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
// dsh-client-connection/lib/index.js:618
|
|
21
|
+
return owner.effect(() => owner.webServer.register(route), `... rpc channel`);
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
而 `connection.rpc` 的 getter 取 `const owner = this.ctx`,Cordis 的 `createTraceable`
|
|
25
|
+
会把跨 fiber 读取的 service 的 `ctx` 重绑定到**读取者**的 fiber
|
|
26
|
+
(实测 `owner.fiber === consumer.fiber` 为 true)。因此 `owner.webServer` 的解析从读取者的
|
|
27
|
+
inject 出发 —— 读取者只 inject 了 `["connection"]`,于是抛:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
cannot get property "webServer" without inject
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
该异常发生在插件 `ctx.inject(["connection"], ...)` 回调内,被插件自己的 try/catch 吞掉,
|
|
34
|
+
只留一条 `logger.warn`,于是通道**静默未注册**。
|
|
35
|
+
|
|
36
|
+
### 线上证据
|
|
37
|
+
| 探测 | 修复前 | 含义 |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `GET /api/xxx` | 401 | `/api` 路由存在,仅认证被拒 |
|
|
40
|
+
| `GET /llm-rate-limiter/snapshot` | **404** | 该前缀**无路由**(走 SPA fallback) |
|
|
41
|
+
| `POST /llm-rate-limiter/snapshot` | **405** | 同上 |
|
|
42
|
+
|
|
43
|
+
而 `dsh --profile web --dump-config` 中插件条目仍存在 → 插件确实被加载,只是通道注册失败。
|
|
44
|
+
|
|
45
|
+
### 交叉验证(隔离变量)
|
|
46
|
+
| 实验 | 结果 |
|
|
47
|
+
|---|---|
|
|
48
|
+
| 0.1.2 代码 + fiber `[webServer,credentials]` | ✅ 注册成功 |
|
|
49
|
+
| 0.1.2 代码 + fiber `[credentials]` | ❌ 失败 |
|
|
50
|
+
| 0.1.5 代码 + fiber `[webServer,credentials]` | ✅ 注册成功 |
|
|
51
|
+
| 0.1.5 代码 + fiber `[credentials]`(真实形态) | ❌ 失败 |
|
|
52
|
+
|
|
53
|
+
→ 差异**只由 inject 列表决定**,与两版 `register` 代码差异无关。另实测:仅给**消费者**外层 fiber 加
|
|
54
|
+
`webServer`(或嵌套 `ctx.inject(["connection","webServer"])`)**均无效** —— 因为 `owner` 绑定的是
|
|
55
|
+
connection service 自身那条 fiber。
|
|
56
|
+
|
|
57
|
+
### 修复(v0.2.1,方案 E)
|
|
58
|
+
保留路径 1 为首选,新增路径 2 兜底,两者**同一套 wire 协议**,浏览器端零改动:
|
|
59
|
+
|
|
60
|
+
| 路径 | 触发条件 | 做法 |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| 1(首选) | `connection.rpc.handle()` 正常 | 框架持有路由、请求校验与 fiber 级撤销 |
|
|
63
|
+
| 2(兜底) | 路径 1 抛错 | 插件在**自己的 fiber**(能看到 `webServer`)注册 `kind:"prefix"` 路由,并复用 `connection.requestRejection()` 保留 403/401 栅栏 |
|
|
64
|
+
|
|
65
|
+
- 若 `connection.requestRejection()` 缺失或抛错,插件**拒绝挂载**,绝不发布未鉴权路由
|
|
66
|
+
- 上游修复该 inject 不一致后,路径 1 会自动重新生效,无需再改插件
|
|
67
|
+
|
|
68
|
+
### 回归测试
|
|
69
|
+
- `test-status-route.mjs`(73 断言)四层:路由语法、请求形态判定、**模拟 0.1.5 回归**(同时验证「兜底生效」与「路径 1 可用时优先」)、真实 0.1.5 包(无 DSH 时自动跳过)
|
|
70
|
+
- `tools/verify-live-route.mjs`(19 断言):真实 `dsh-host-webserver` 真 socket + 真实 `dsh-client-connection`,发真实 HTTP 请求;无 DSH 时退出码 2(跳过)
|
|
6
71
|
|
|
7
72
|
---
|
|
8
73
|
|
|
@@ -34,7 +99,8 @@
|
|
|
34
99
|
|
|
35
100
|
| # | API | 所属包 | 本地存在 | 稳定性评估 |
|
|
36
101
|
|---|-----|--------|----------|------------|
|
|
37
|
-
| 1 | `ctx.inject(["connection"], cb)` + `conn.rpc.handle(channel, handler)` | dsh-client-connection |
|
|
102
|
+
| 1 | `ctx.inject(["connection"], cb)` + `conn.rpc.handle(channel, handler)` | dsh-client-connection | ⚠️ 0.1.2 可用 / **0.1.5 抛错** | 首选路径。0.1.5-rc.3 起因该包自身 `inject` 缺 `webServer` 而抛 `cannot get property "webServer" without inject`,故 v0.2.1 增加路径 2 兜底 |
|
|
103
|
+
| 1b | `conn.requestRejection(req)` + `ctx.inject(["webServer"], cb)` + `webServer.register({kind:"prefix",...})` | dsh-client-connection + dsh-host-webserver | ✅ 0.1.2 / 0.1.5 | **v0.2.1 兜底路径**。复用框架的 403/401 栅栏,自行注册前缀路由;wire 协议与路径 1 逐字一致 |
|
|
38
104
|
| 2 | handler 签名 `(endpoint, payload, signal) => envelope` | dsh-client-connection | ✅ `rpcFetchHandler`(605-631) | 框架强制 POST + `application/json`(否则 404/415),并自动套 `requestRejection`(Host/Origin 不信→403,未认证→401) |
|
|
39
105
|
| 3 | envelope `{ok:true,value}` / `{ok:false,error:{code,message,details}}` | dsh-client-connection | ✅ 与 `clientRequestSchema` / `errorResponse` 一致 | 契约字面量,dsh-context 逐字复制同一范式 |
|
|
40
106
|
| 4 | 客户端 `ctx.get("connection")?.rpc.call(channel, endpoint, payload, signal)` | dsh-client-connection | ✅ `createWebConnectionRpc.call`(client.js 4606-4628) | 传输失败 reject;返回 `result`(即 envelope)。经 `rpcCallOf` 反射读取,服务缺席/敌意时降级为 `undefined` |
|
|
@@ -143,8 +209,13 @@
|
|
|
143
209
|
- [ ] `settings.register` 返回值结构是否变化
|
|
144
210
|
- [ ] `settingsScope.set/mutate` 接口是否变化
|
|
145
211
|
- [ ] `settings.plugin.item` slot 是否仍可注入
|
|
146
|
-
- [ ]
|
|
147
|
-
- [ ]
|
|
148
|
-
- [ ]
|
|
149
|
-
- [ ]
|
|
212
|
+
- [ ] **状态通道**:`connection.rpc.handle` 是否仍抛 `webServer` 相关错误(0.1.5-rc.3 会)
|
|
213
|
+
- [ ] **状态通道**:`connection.requestRejection` 仍为公开 API(兜底路径的鉴权依赖它)
|
|
214
|
+
- [ ] **状态通道**:`webServer.register({kind:"prefix"})` 仍接受前缀路由
|
|
215
|
+
- [ ] **状态通道**:`snapshot` / `reset` 端点往返成功(面板显示"● 实时"而非"重试中")
|
|
216
|
+
- [ ] **状态通道**:折叠卡片后 DevTools Network 无 `/llm-rate-limiter/snapshot` 轮询
|
|
217
|
+
- [ ] **状态通道**:无 connection 服务的组合下,卡片配置区仍可用且面板显示降级文案
|
|
218
|
+
- [ ] **状态通道**:直接 `POST /llm-rate-limiter/snapshot` 应返回 401(路由存在且被栅栏保护),而非 404/405(路由缺失)
|
|
219
|
+
|
|
220
|
+
> 上述清单已由 `test-status-route.mjs` + `tools/verify-live-route.mjs` 自动覆盖,升级 DSH 后先跑 `pnpm test && pnpm run verify:live`。
|
|
150
221
|
4. **如果 cordis 升级到 5.0+**:整个 `apply(ctx)` 接口、`ctx.effect()`、`ctx.on()` 签名可能重写,需要全面适配
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](COMPATIBILITY.md)
|
|
6
6
|
[](package.json)
|
|
7
|
-
[](test-status-route.mjs)
|
|
8
8
|
|
|
9
9
|
Per-model LLM call rate limiter for [DeepSeek Harness](https://github.com/deepseek-ai/dsh) with queue/reject support and interactive GUI configuration.
|
|
10
10
|
|
|
@@ -39,7 +39,8 @@ Expanding the card shows a live panel at the top of its body:
|
|
|
39
39
|
|
|
40
40
|
| Aspect | Behaviour |
|
|
41
41
|
|--------|-----------|
|
|
42
|
-
| Data channel |
|
|
42
|
+
| Data channel | Channel `/llm-rate-limiter` — authenticated (401/403 fence), POST+JSON, auto-cleaned with the plugin fiber |
|
|
43
|
+
| Carrier | Prefers the framework's `connection.rpc.handle()`; falls back to a self-registered prefix route that reuses `connection.requestRejection()` when the framework path is broken (see below) |
|
|
43
44
|
| Cadence | 1 s polling while the card is expanded; backs off 2 s → 4 s → 8 s after failures |
|
|
44
45
|
| Collapsed card | The panel unmounts, so **no polling runs at all** |
|
|
45
46
|
| Endpoints | `snapshot` (live counters) and `reset` (zero the statistics) |
|
|
@@ -49,6 +50,30 @@ Expanding the card shows a live panel at the top of its body:
|
|
|
49
50
|
|
|
50
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.
|
|
51
52
|
|
|
53
|
+
### Carrier fallback (DSH 0.1.5-rc.3)
|
|
54
|
+
|
|
55
|
+
DSH 0.1.5-rc.3 changed `@deepseek-ai/dsh-client-connection`'s own `inject` from
|
|
56
|
+
`["webServer", "credentials"]` to `["credentials"]`, but its
|
|
57
|
+
`HostConnectionService.register()` still dereferences `owner.webServer`.
|
|
58
|
+
Cordis rebinds a cross-fiber service's `ctx` to the *reader's* fiber, so
|
|
59
|
+
`connection.rpc.handle()` throws:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
cannot get property "webServer" without inject
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The plugin now detects that and mounts the same channel itself:
|
|
66
|
+
|
|
67
|
+
| Path | When | How |
|
|
68
|
+
|------|------|-----|
|
|
69
|
+
| 1 (preferred) | `connection.rpc.handle()` works | The framework owns the route, request validation, and fiber-scoped withdrawal |
|
|
70
|
+
| 2 (fallback) | Path 1 throws | The plugin registers a `kind: "prefix"` route on its own fiber (which *can* see `webServer`) and reuses `connection.requestRejection()` for the 403/401 fence |
|
|
71
|
+
|
|
72
|
+
Both carriers speak the identical wire protocol, so **the browser half is
|
|
73
|
+
unchanged** — the panel cannot tell which one is live. If
|
|
74
|
+
`connection.requestRejection()` is unavailable, the plugin refuses to mount
|
|
75
|
+
rather than publishing an unauthenticated route.
|
|
76
|
+
|
|
52
77
|
|
|
53
78
|
---
|
|
54
79
|
|
|
@@ -189,8 +214,18 @@ cd dsh-llm-rate-limiter
|
|
|
189
214
|
# Install deps
|
|
190
215
|
pnpm install
|
|
191
216
|
|
|
192
|
-
# Run
|
|
193
|
-
|
|
217
|
+
# Run the full suite (300 assertions across 5 files)
|
|
218
|
+
pnpm test
|
|
219
|
+
|
|
220
|
+
# Individual suites
|
|
221
|
+
node test-strategies.mjs # 19 — rate-limit algorithms
|
|
222
|
+
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
|
|
225
|
+
node test-status-route.mjs # 73 — self-registered route + the 0.1.5 regression
|
|
226
|
+
|
|
227
|
+
# Live HTTP proof against a real DSH install (real socket, real webserver)
|
|
228
|
+
pnpm run verify:live # 19 — exits 2 (skipped) when DSH is absent
|
|
194
229
|
|
|
195
230
|
# Run E2E rate-limit test
|
|
196
231
|
node test-3rpm.mjs
|
|
@@ -207,7 +242,8 @@ The plugin uses a live symlink when installed via `link:` — edits to `lib/` ta
|
|
|
207
242
|
|
|
208
243
|
| DSH Version | Status | Notes |
|
|
209
244
|
|-------------|--------|-------|
|
|
210
|
-
| 0.1.
|
|
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 |
|
|
211
247
|
| 0.2.x | ⚠️ Untested | May need API adjustments |
|
|
212
248
|
| Cordis 5+ | ⚠️ Untested | Major version change likely requires rewrite |
|
|
213
249
|
|
package/lib/index.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { TokenBucketStrategy } from "./strategies/token-bucket.js";
|
|
22
22
|
import { SlidingWindowStrategy } from "./strategies/sliding-window.js";
|
|
23
23
|
import { RateLimiterConfig } from "./types/config.js";
|
|
24
|
-
import { CHANNEL, createStatusChannel } from "./status-rpc.js";
|
|
24
|
+
import { CHANNEL, createChannelRoute, createStatusChannel } from "./status-rpc.js";
|
|
25
25
|
|
|
26
26
|
const name = "llm-rate-limiter";
|
|
27
27
|
const SETTINGS_NS = "llm-rate-limiter";
|
|
@@ -184,28 +184,90 @@ function apply(ctx) {
|
|
|
184
184
|
}, "rate-limiter: settings scope teardown");
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
-
// ── Status
|
|
187
|
+
// ── Status channel (dynamic inject) ────────────────────────
|
|
188
188
|
// Same defensive shape as the settings wiring: on a host without a
|
|
189
189
|
// connection service the callback never runs and the plugin stays silent.
|
|
190
|
-
//
|
|
190
|
+
// Either path below registers inside an effect owned by this fiber, so
|
|
191
|
+
// unloading the plugin withdraws the channel automatically.
|
|
192
|
+
//
|
|
193
|
+
// Two mounting paths exist, and both speak the same wire protocol, so the
|
|
194
|
+
// browser half never needs to know which one is live:
|
|
195
|
+
//
|
|
196
|
+
// 1. connection.rpc.handle(channel, handler) — the framework's generic
|
|
197
|
+
// channel registry (preferred: the framework owns the route, the
|
|
198
|
+
// request validation, and the withdrawal).
|
|
199
|
+
//
|
|
200
|
+
// 2. a self-registered prefix route reusing connection.requestRejection()
|
|
201
|
+
// — the fallback for hosts where path 1 is broken. DSH 0.1.5-rc.3
|
|
202
|
+
// changed dsh-client-connection's own `inject` from
|
|
203
|
+
// ["webServer", "credentials"] to ["credentials"] while its
|
|
204
|
+
// HostConnectionService.register() still dereferences
|
|
205
|
+
// `owner.webServer`, and Cordis rebinds a cross-fiber service's `ctx`
|
|
206
|
+
// to the *reader's* fiber — so rpc.handle() throws
|
|
207
|
+
// 'cannot get property "webServer" without inject'. Registering the
|
|
208
|
+
// route on our own fiber (which injects webServer below) avoids that
|
|
209
|
+
// while still using the framework's own 403/401 fence.
|
|
191
210
|
ctx.inject(["connection"], (scopedCtx) => {
|
|
192
211
|
const connection = scopedCtx.get("connection");
|
|
212
|
+
const channel = createStatusChannel({ buildSnapshot, resetTotals });
|
|
213
|
+
|
|
214
|
+
// ── Path 1: the framework's generic channel registry ──
|
|
193
215
|
const handle = typeof connection?.rpc?.handle === "function"
|
|
194
216
|
? connection.rpc.handle.bind(connection.rpc)
|
|
195
217
|
: undefined;
|
|
196
|
-
if (handle === undefined) return; // older host: degrade silently
|
|
197
218
|
|
|
219
|
+
if (handle !== undefined) {
|
|
220
|
+
try {
|
|
221
|
+
scopedCtx.effect(() => {
|
|
222
|
+
const unregister = handle(CHANNEL, channel);
|
|
223
|
+
return () => { unregister(); };
|
|
224
|
+
}, "rate-limiter: status rpc channel");
|
|
225
|
+
logger?.info("rate limiter status channel mounted via connection.rpc (%s)", CHANNEL);
|
|
226
|
+
return;
|
|
227
|
+
} catch (err) {
|
|
228
|
+
logger?.warn(
|
|
229
|
+
"connection.rpc channel registration failed (%s); falling back to a self-registered route",
|
|
230
|
+
err instanceof Error ? err.message : String(err),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Path 2: self-registered route (needs webServer on THIS fiber) ──
|
|
236
|
+
// The fence is mandatory: without connection.requestRejection() we would
|
|
237
|
+
// be publishing an unauthenticated route, so we refuse to mount at all.
|
|
238
|
+
// Probing once here turns a broken fence into a mount-time refusal instead
|
|
239
|
+
// of a per-request surprise.
|
|
198
240
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return
|
|
202
|
-
}
|
|
241
|
+
if (typeof connection?.requestRejection !== "function") {
|
|
242
|
+
logger?.warn("connection.requestRejection unavailable; status channel not mounted");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
connection.requestRejection({ headers: {} });
|
|
203
246
|
} catch (err) {
|
|
204
|
-
logger?.warn(
|
|
247
|
+
logger?.warn(
|
|
248
|
+
"connection.requestRejection unusable (%s); refusing to mount an unfenced status route",
|
|
249
|
+
err instanceof Error ? err.message : String(err),
|
|
250
|
+
);
|
|
205
251
|
return;
|
|
206
252
|
}
|
|
207
253
|
|
|
208
|
-
|
|
254
|
+
scopedCtx.inject(["webServer"], (webCtx) => {
|
|
255
|
+
const webServer = webCtx.get("webServer");
|
|
256
|
+
if (typeof webServer?.register !== "function") return; // no web carrier: degrade silently
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
webCtx.effect(() => webServer.register(createChannelRoute({
|
|
260
|
+
channel: CHANNEL,
|
|
261
|
+
handler: channel,
|
|
262
|
+
reject: (req) => connection.requestRejection(req),
|
|
263
|
+
})), "rate-limiter: status route (self-registered)");
|
|
264
|
+
} catch (err) {
|
|
265
|
+
logger?.warn("status channel registration failed: %s", err instanceof Error ? err.message : String(err));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
logger?.info("rate limiter status channel mounted via self-registered route (%s)", CHANNEL);
|
|
270
|
+
});
|
|
209
271
|
});
|
|
210
272
|
|
|
211
273
|
// ── LLM stream interceptor ─────────────────────────────────
|
package/lib/status-rpc.js
CHANGED
|
@@ -2,15 +2,27 @@
|
|
|
2
2
|
* dsh-llm-rate-limiter — status RPC channel (Host half).
|
|
3
3
|
*
|
|
4
4
|
* Exposes live rate-limiter statistics to the browser over the framework's
|
|
5
|
-
* generic Connection RPC channel
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* endpoints and shapes envelopes.
|
|
5
|
+
* generic Connection RPC channel. The framework supplies POST+JSON transport,
|
|
6
|
+
* the Host/Origin fence (403) and browser authentication (401); this module
|
|
7
|
+
* only answers endpoints and shapes envelopes.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Two mounting paths exist, and both speak the *same* wire protocol so the
|
|
10
|
+
* browser half never needs to know which one is active:
|
|
11
|
+
*
|
|
12
|
+
* 1. `connection.rpc.handle(channel, handler)` — the framework's own generic
|
|
13
|
+
* channel registry. Preferred: the framework owns the route, the request
|
|
14
|
+
* validation, and the fiber-scoped withdrawal.
|
|
15
|
+
*
|
|
16
|
+
* 2. `createChannelRoute({ channel, handler, reject })` — a self-registered
|
|
17
|
+
* prefix route for hosts where path 1 is broken. DSH 0.1.5-rc.3 changed
|
|
18
|
+
* `@deepseek-ai/dsh-client-connection`'s own `inject` from
|
|
19
|
+
* `["webServer", "credentials"]` to `["credentials"]` while
|
|
20
|
+
* `HostConnectionService.register()` still dereferences
|
|
21
|
+
* `owner.webServer`, so `rpc.handle` throws
|
|
22
|
+
* `cannot get property "webServer" without inject`. This route is
|
|
23
|
+
* registered on our own fiber (which *can* see `webServer`) and reuses the
|
|
24
|
+
* connection service's public `requestRejection()` fence, so the 403/401
|
|
25
|
+
* policy is still the framework's.
|
|
14
26
|
*
|
|
15
27
|
* Envelope contract (mirrors the Connection wire schema):
|
|
16
28
|
* success: { ok: true, value: <JSON-safe> }
|
|
@@ -25,6 +37,27 @@
|
|
|
25
37
|
*/
|
|
26
38
|
const CHANNEL = "/llm-rate-limiter";
|
|
27
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Endpoint grammar for one path segment, copied verbatim from the framework's
|
|
42
|
+
* `ENDPOINT_SEGMENT_PATTERN` in dsh-client-connection. Keeping it identical is
|
|
43
|
+
* what makes the self-registered route accept and reject exactly the same URLs
|
|
44
|
+
* as `rpc.handle` would.
|
|
45
|
+
*/
|
|
46
|
+
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Buffered-body cap for the self-registered route. Both endpoints are called
|
|
50
|
+
* with an empty payload, so this is a defensive memory bound rather than a
|
|
51
|
+
* real limit (the framework uses its own, much larger, carrier cap).
|
|
52
|
+
*/
|
|
53
|
+
const MAX_REQUEST_BODY_BYTES = 1024 * 1024;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Correlation id echoed when the request envelope cannot be parsed at all —
|
|
57
|
+
* the same literal the framework uses (`INVALID_REQUEST_RPC_ID`).
|
|
58
|
+
*/
|
|
59
|
+
const INVALID_REQUEST_RPC_ID = "invalid-request";
|
|
60
|
+
|
|
28
61
|
/**
|
|
29
62
|
* Build a failure envelope. Field-for-field the same shape the Connection
|
|
30
63
|
* host accepts, so callers can rely on `ok` discrimination alone.
|
|
@@ -64,4 +97,189 @@ function createStatusChannel({ buildSnapshot, resetTotals }) {
|
|
|
64
97
|
};
|
|
65
98
|
}
|
|
66
99
|
|
|
67
|
-
|
|
100
|
+
/* ── self-registered route (the DSH 0.1.5-rc.3 fallback) ───────────────── */
|
|
101
|
+
|
|
102
|
+
/** Narrow an unknown JSON value to a plain object. */
|
|
103
|
+
function isRecord(value) {
|
|
104
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract the endpoint from a channel-relative pathname, mirroring the
|
|
109
|
+
* framework's `endpointFromPath`: a single `channel/<endpoint>` segment whose
|
|
110
|
+
* segments all satisfy {@link ENDPOINT_SEGMENT_PATTERN}.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} channel - the channel prefix (e.g. `/llm-rate-limiter`).
|
|
113
|
+
* @param {string} pathname - request pathname.
|
|
114
|
+
* @returns {string | undefined} the endpoint, or undefined when the URL is not
|
|
115
|
+
* a well-formed member of this channel.
|
|
116
|
+
*/
|
|
117
|
+
function endpointFromPath(channel, pathname) {
|
|
118
|
+
if (!pathname.startsWith(`${channel}/`)) return undefined;
|
|
119
|
+
const endpoint = pathname.slice(channel.length + 1);
|
|
120
|
+
const segments = endpoint.split("/");
|
|
121
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT_PATTERN.test(segment))) return undefined;
|
|
122
|
+
return endpoint;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read and decode the request body, refusing anything above `limit` bytes so a
|
|
127
|
+
* hostile caller cannot make the host buffer without bound.
|
|
128
|
+
*
|
|
129
|
+
* @param {import("node:http").IncomingMessage} req - node request stream.
|
|
130
|
+
* @param {number} limit - maximum accepted byte count.
|
|
131
|
+
* @returns {Promise<string>} the decoded UTF-8 body.
|
|
132
|
+
* @throws {Error & { code: "TOO_LARGE" }} when the body exceeds `limit`.
|
|
133
|
+
*/
|
|
134
|
+
async function readBody(req, limit) {
|
|
135
|
+
const chunks = [];
|
|
136
|
+
let received = 0;
|
|
137
|
+
for await (const chunk of req) {
|
|
138
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
139
|
+
received += buffer.byteLength;
|
|
140
|
+
if (received > limit) {
|
|
141
|
+
const error = new Error("request body too large");
|
|
142
|
+
error.code = "TOO_LARGE";
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
chunks.push(buffer);
|
|
146
|
+
}
|
|
147
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Write one `server-response` envelope as JSON — byte-for-byte the shape
|
|
152
|
+
* `Response.json(...)` produces on the framework's own route.
|
|
153
|
+
*
|
|
154
|
+
* @param {import("node:http").ServerResponse} res - response to own.
|
|
155
|
+
* @param {string} rpcId - correlation id to echo.
|
|
156
|
+
* @param {object} result - the `{ ok, value }` / `{ ok, error }` envelope.
|
|
157
|
+
*/
|
|
158
|
+
function sendEnvelope(res, rpcId, result) {
|
|
159
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
160
|
+
res.end(JSON.stringify({ type: "server-response", rpcId, result }));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build a webserver prefix route that speaks the Connection RPC wire protocol.
|
|
165
|
+
*
|
|
166
|
+
* This is the fallback carrier for {@link createStatusChannel} on hosts where
|
|
167
|
+
* `connection.rpc.handle` throws. Every request-shape decision mirrors the
|
|
168
|
+
* framework's `rpcFetchHandler` so the browser half is byte-compatible with the
|
|
169
|
+
* preferred path:
|
|
170
|
+
*
|
|
171
|
+
* | condition | response |
|
|
172
|
+
* |---|---|
|
|
173
|
+
* | fence/auth rejected by `reject` | `403` (or `401`) + `forbidden`/`unauthorized` |
|
|
174
|
+
* | non-POST, or URL outside the channel | `404 not found` |
|
|
175
|
+
* | `content-type` not `application/json` | `415` |
|
|
176
|
+
* | body over {@link MAX_REQUEST_BODY_BYTES} | `413` |
|
|
177
|
+
* | body not JSON | `400 body is not JSON` |
|
|
178
|
+
* | envelope not a `client-request` | `gateway/bad-request` envelope |
|
|
179
|
+
* | `method` ≠ endpoint | `gateway/bad-request` envelope |
|
|
180
|
+
* | handler threw | `500 handler failure: ...` |
|
|
181
|
+
* | otherwise | `200` + `server-response` envelope |
|
|
182
|
+
*
|
|
183
|
+
* @param {object} deps
|
|
184
|
+
* @param {string} deps.channel - channel prefix to own (e.g. `/llm-rate-limiter`).
|
|
185
|
+
* @param {(endpoint: string, payload: unknown, signal?: AbortSignal) => Promise<object>} deps.handler - endpoint dispatcher.
|
|
186
|
+
* @param {(req: import("node:http").IncomingMessage) => number | undefined} deps.reject - the framework's `connection.requestRejection` fence; returns an HTTP status to refuse with, or undefined to allow.
|
|
187
|
+
* @returns {{ kind: "prefix", path: string, handler: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => Promise<void> }}
|
|
188
|
+
*/
|
|
189
|
+
function createChannelRoute({ channel, handler, reject }) {
|
|
190
|
+
return {
|
|
191
|
+
kind: "prefix",
|
|
192
|
+
path: channel,
|
|
193
|
+
handler: async (req, res) => {
|
|
194
|
+
// 1. The framework's own fence: Host/Origin trust (403) then browser
|
|
195
|
+
// authentication (401). Reusing it keeps the security policy identical.
|
|
196
|
+
const rejection = reject(req);
|
|
197
|
+
if (rejection !== undefined) {
|
|
198
|
+
res.writeHead(rejection);
|
|
199
|
+
res.end(rejection === 401 ? "unauthorized" : "forbidden");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 2. POST-only, and only inside this channel (framework: 404 otherwise).
|
|
204
|
+
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
205
|
+
const endpoint = endpointFromPath(channel, url.pathname);
|
|
206
|
+
if (req.method !== "POST" || endpoint === undefined) {
|
|
207
|
+
res.writeHead(404);
|
|
208
|
+
res.end("not found");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 3. JSON only (framework: 415).
|
|
213
|
+
const mediaType = String(req.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
214
|
+
if (mediaType !== "application/json") {
|
|
215
|
+
res.writeHead(415);
|
|
216
|
+
res.end("content type must be application/json");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 4. Buffer the body under a defensive cap, then parse it.
|
|
221
|
+
let raw;
|
|
222
|
+
try {
|
|
223
|
+
raw = await readBody(req, MAX_REQUEST_BODY_BYTES);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (error?.code === "TOO_LARGE") {
|
|
226
|
+
res.writeHead(413, { connection: "close" });
|
|
227
|
+
res.end();
|
|
228
|
+
req.destroy?.();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
let body;
|
|
234
|
+
try {
|
|
235
|
+
body = JSON.parse(raw);
|
|
236
|
+
} catch {
|
|
237
|
+
res.writeHead(400);
|
|
238
|
+
res.end("body is not JSON");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 5. Validate the client-request envelope; echo the correlation id
|
|
243
|
+
// whenever it is a string, exactly like the framework does.
|
|
244
|
+
const rpcId = typeof body?.rpcId === "string" ? body.rpcId : INVALID_REQUEST_RPC_ID;
|
|
245
|
+
if (!isRecord(body) || body.type !== "client-request" || typeof body.rpcId !== "string" || typeof body.method !== "string") {
|
|
246
|
+
sendEnvelope(res, rpcId, failure("gateway/bad-request", "invalid client-request message", { issues: [] }));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (body.method !== endpoint) {
|
|
250
|
+
sendEnvelope(res, rpcId, failure(
|
|
251
|
+
"gateway/bad-request",
|
|
252
|
+
`method ${JSON.stringify(body.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
|
|
253
|
+
{ issues: [] },
|
|
254
|
+
));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 6. Dispatch. Tie the signal to the response lifetime, as the framework's
|
|
259
|
+
// http bridge does, so a dropped client can cancel the work.
|
|
260
|
+
const abort = new AbortController();
|
|
261
|
+
res.on?.("close", () => {
|
|
262
|
+
if (!res.writableEnded) abort.abort();
|
|
263
|
+
});
|
|
264
|
+
let result;
|
|
265
|
+
try {
|
|
266
|
+
result = await handler(endpoint, body.payload, abort.signal);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
res.writeHead(500);
|
|
269
|
+
res.end(`handler failure: ${String(error)}`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
sendEnvelope(res, rpcId, result);
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export {
|
|
278
|
+
CHANNEL,
|
|
279
|
+
MAX_REQUEST_BODY_BYTES,
|
|
280
|
+
createChannelRoute,
|
|
281
|
+
createStatusChannel,
|
|
282
|
+
endpointFromPath,
|
|
283
|
+
failure,
|
|
284
|
+
success,
|
|
285
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leaf233/dsh-llm-rate-limiter",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Per-model LLM call rate limiter for DeepSeek Harness with queue support and a live status panel",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -21,13 +21,15 @@
|
|
|
21
21
|
},
|
|
22
22
|
"compatibility": {
|
|
23
23
|
"dshReleases": {
|
|
24
|
-
"0.1.2-rc.1": "compatible"
|
|
24
|
+
"0.1.2-rc.1": "compatible",
|
|
25
|
+
"0.1.5-rc.3": "compatible"
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
},
|
|
28
29
|
"scripts": {
|
|
29
30
|
"build": "echo 'no build needed'",
|
|
30
|
-
"test": "node test-strategies.mjs"
|
|
31
|
+
"test": "node test-strategies.mjs && node test-status-rpc.mjs && node test-client-bundle.mjs && node test-host-integration.mjs && node test-status-route.mjs",
|
|
32
|
+
"verify:live": "node tools/verify-live-route.mjs"
|
|
31
33
|
},
|
|
32
34
|
"repository": {
|
|
33
35
|
"type": "git",
|