dsh-router-core 0.3.1 → 0.3.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CARVIN
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,4 +1,18 @@
1
- # dsh-router
1
+ <h1 align="center">dsh-router</h1>
2
+
3
+ <p align="center">DeepSeek Harness 的 OpenAI 兼容路由插件</p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/dsh-router-core"><img src="https://img.shields.io/npm/v/dsh-router-core?style=flat-square&logo=npm&label=npm" alt="npm version"></a>
7
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-10b981?style=flat-square" alt="MIT license"></a>
8
+ </p>
9
+
10
+ <p align="center">
11
+ <a href="#快速安装">快速安装</a> ·
12
+ <a href="#面板设置--路由">面板</a> ·
13
+ <a href="#api-端点openai-兼容3080v1">API 端点</a> ·
14
+ <a href="docs/suppliers.md">供应商开发</a>
15
+ </p>
2
16
 
3
17
  **插件版的 9router** —— 不是另开一个网关服务,而是直接作为 DSH 插件嵌进 DSH web,
4
18
  在 `http://localhost:3080/v1` 上原生暴露 OpenAI 兼容端点,把请求路由到内部供应商。
@@ -7,30 +21,44 @@
7
21
 
8
22
  ![设置 → 路由 面板:概览(用量看板)、供应商、组合、端点与密钥](docs/screenshot.png)
9
23
 
10
- ## 为什么更优雅
11
-
12
- - **零额外进程**:dsh-router 就是 DSH 插件,随 `dsh web` 启停,天然同源
13
- (`/router/api/*` 无 CORS、面板嵌在设置里),不像 9router 要独立跑一个
14
- Next.js 服务再对接;
15
- - **供应商即插即拔**:内置供应商(如 opencode / openrouter / nvidia)随插件分发;
16
- 更多供应商 = 装一个 DSH 插件(`dsh-router-*`)经 cordis service
17
- `router.suppliers` 注册,面板自动出现、热加载/卸载;也可以放一个自定义
18
- js 文件到 `~/.dsh/profiles/web/suppliers/` 就注册一个新供应商——
19
- 无需改核心代码、无需重编译;
20
- - **模型不内置**:供应商只实现差异化能力(列模型/调上游/登录),模型拉取与
21
- 缓存由核心统一管,不写死、不过时;
22
- - **策略只写一次**:组合回退、账号池(选号/冷却/禁用/连续错误累计)、响应写入、
23
- 凭证存储、模型管理都由核心提供。供应商 js 只对**单个账号**调一次上游并报告
24
- 成败,不自己遍历账号、不维护冷却表——否则每个插件都会长出一份互相不一致的
25
- 实现,而核心也就无从判断「该不该换号」;
26
- - **凭证 SQLite 单库**:`auths/credentials.sqlite`,供应商凭证不透明 blob,
27
- 核心统一生命周期,干净可备份;
28
- - **复用 9router 思路**:面板布局、组合 fallback、连接池/账号池、API key
29
- 管理都贴近 9router,但按 DSH「一切皆插件」的方式重组得更轻。
24
+ ## 快速安装
25
+
26
+ 需要支持 `dsh plugin` profile 插件机制的 DeepSeek Harness、Node.js `>= 20`,以及 `web` profile。
27
+
28
+ ```bash
29
+ dsh plugin --profile web add dsh-router-core
30
+ ```
31
+
32
+ 然后**重启 `dsh web`**。打开设置面板,左侧导航「模型」下面会出现 **路由**。
33
+
34
+ > 更多供应商:DSH 插件形态的供应商各自发 npm 包,同样
35
+ > `dsh plugin --profile web add <包名>` 即可;供应商接入与开发见
36
+ > [`docs/suppliers.md`](docs/suppliers.md)。
37
+ >
38
+ > 本地开发版:不用 npm,直接 `dependencies` 加
39
+ > `"dsh-router-core": "link:/path/to/dsh-router"` 指向本地仓库。
30
40
 
41
+ ## 它解决什么问题
42
+
43
+ | 能力 | 说明 |
44
+ |---|---|
45
+ | 零额外进程 | 就是 DSH 插件,随 `dsh web` 启停,天然同源(`/router/api/*` 无 CORS、面板嵌在设置里)。 |
46
+ | 供应商即插即拔 | 内置供应商随插件分发;更多供应商 = 装一个 DSH 插件(`dsh-router-*`)或放一个 js 文件到 `~/.dsh/profiles/web/suppliers/`。 |
47
+ | 模型不内置 | 供应商只实现差异化能力,模型拉取与缓存由核心统一管,不写死、不过时。 |
48
+ | 策略只写一次 | 组合回退、账号池(选号/冷却/禁用/连续错误累计)、响应写入、凭证存储、模型管理都由核心提供。供应商 js 只对**单个账号**调一次上游并报告成败,不自己遍历账号、不维护冷却表——否则每个插件都会长出一份互相不一致的实现,而核心也就无从判断「该不该换号」。 |
49
+ | 凭证单库 | `auths/credentials.sqlite`,供应商凭证不透明 blob,核心统一生命周期,干净可备份。 |
50
+ | 组合即模型 | 建好的组合自动带出为 DSH 模型目录里的 `router` provider 选项,设置 → 模型直接选组合名即可。 |
51
+ | 用量可观测 | 面板概览看板:周期切换、汇总卡、趋势折线、Top 榜、最近请求。 |
52
+
53
+ > 面板布局、组合 fallback、连接池/账号池、API key 管理都贴近
54
+ > [9router](https://github.com/decolua/9router),但按 DSH「一切皆插件」的方式
55
+ > 重组得更轻。
56
+ >
31
57
  > 供应商开发与接入规范见 [`docs/suppliers.md`](docs/suppliers.md)
32
58
  > (契约 / 加载顺序 / 模型统一策略 / 内置供应商参考实现)。
33
59
 
60
+ ## 面板(设置 → 路由)
61
+
34
62
  面板挂在 **设置 → 路由**(官方 `settings.section` 座位,排在「模型」下面):
35
63
 
36
64
  - **概览** — 用量看板(默认页):
@@ -43,7 +71,11 @@
43
71
  - Top 榜:按供应商 / 按模型(请求数带失败计数);
44
72
  - 最近请求:时间 / 模型 / 供应商 / in↑ out↓ / 耗时,显示最近 10 条;
45
73
  - **清空** — 清掉全部用量统计(不影响供应商、账号、组合配置);
46
- - 数据落盘 `data/usage.json`(按天聚合 + 最近 500 条明细 + 累计计数)。
74
+ - 数据落盘 `data/usage.json`(按天聚合 + 每天 24 个小时桶 + 最近 500 条明细 +
75
+ 累计计数)。今日/7 天/30 天读天桶、24 小时读小时桶,都不受明细环容量限制;
76
+ 明细环只服务「最近请求」列表。
77
+ 小时桶从新数据开始累积,升级前那几天的天内分布查不到(明细环只剩 500 条
78
+ 回溯不回去),那段历史的小时柱状图留空、24 小时口径按整桶计入 —— 不编数据。
47
79
  token 口径:上游返回 `usage` 就用真值(分散在多帧时按字段取最大值合并);
48
80
  上游不发时按 ~4 字符/token 估算,面板上标 `~`。**失败请求不估算**——
49
81
  它没到上游,编造输入 token 只会把总量灌水;
@@ -162,32 +194,14 @@ curl -X POST http://localhost:3080/v1/chat/completions \
162
194
  | `TW2A_STATE_FILE` | `data/state.json` | 状态持久化(同目录放 keys.json / combos.json / supplier-config.json) |
163
195
  | `TW2A_API_KEY` | 空 | /v1 Bearer 鉴权(与库内 key 等效) |
164
196
 
165
- ## 构建
166
-
167
- ```bash
168
- pnpm install
169
- pnpm build # lib/index.js(host) + lib/client.js / lib/client-registry.js(browser)
170
- pnpm typecheck
171
- ```
172
-
173
- ## 安装(DSH)
174
-
175
- 用 dsh CLI 装到 profile(web 是 DSH 插件宿主):
176
-
177
- ```bash
178
- dsh plugin --profile web add dsh-router-core
179
- ```
197
+ ## 与 DSH 的边界
180
198
 
181
- 该命令会在 profile 里 `pnpm add`,并自动把 `dsh-router-core` 加入
182
- `dsh.profile.bundles`(包声明了 `dsh.bundle.patch`,即 `cordis.patch.yml`)。
183
-
184
- 然后**重启 `dsh web`**。打开设置面板,左侧导航「模型」下面会出现 **路由**。
185
-
186
- > 更多供应商:DSH 插件形态的供应商各自发 npm 包,同样 `dsh plugin --profile web add <包名>`
187
- > 即可;供应商接入与开发见 [`docs/suppliers.md`](docs/suppliers.md)。
188
- >
189
- > 本地开发版:不用 npm,直接 `dependencies` 加
190
- > `"dsh-router-core": "link:/path/to/dsh-router"` 指向本地仓库。
199
+ - dsh-router 复用 DSH 的 Web Server 与设置面板座位,不启动第二个应用或代理系统。
200
+ - 供应商 js 不改 DSH 的 prompt、工具 schema 或权限;它只负责「把上游协议翻译成
201
+ OpenAI 形态」,路由/回退/存储归核心。
202
+ - 数据分两处:`data/` 下的状态与用量 JSON(删了只是没统计了),以及
203
+ `auths/credentials.sqlite`(删了要重新登录所有供应商)。
204
+ - 内置 patch 仅支持 DSH 的 `web` profile。
191
205
 
192
206
  ## 前提
193
207
 
@@ -195,6 +209,18 @@ dsh plugin --profile web add dsh-router-core
195
209
  - 供应商接入与开发见 [`docs/suppliers.md`](docs/suppliers.md);
196
210
  - 重启 DSH 后 `/v1/*` 即生效;面板管理账号、模型与密钥。
197
211
 
212
+ ## 开发
213
+
214
+ ```bash
215
+ pnpm install
216
+ pnpm build # lib/index.js(host) + lib/client.js / lib/client-registry.js(browser)
217
+ pnpm typecheck
218
+ pnpm test # node --test "src/**/*.test.ts"
219
+ ```
220
+
221
+ 需要一个供应商最小实现作参考时,看 [`examples/suppliers/echo.js`](examples/suppliers/echo.js);
222
+ 完整契约、加载顺序与模型策略见 [`docs/suppliers.md`](docs/suppliers.md)。
223
+
198
224
  ## 致谢
199
225
 
200
226
  感谢以下项目给的灵感:
@@ -202,3 +228,7 @@ dsh plugin --profile web add dsh-router-core
202
228
  - [decolua/9router](https://github.com/decolua/9router) —— 本地 AI 路由网关,面板/组合/连接池/凭证等思路的来源;
203
229
  - [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) —— DSH「一切皆插件」的宿主框架;
204
230
  - [omdsh-dev/dsh-mnemon](https://github.com/omdsh-dev/dsh-mnemon) —— DSH 插件形态与侧边栏入口的参考。
231
+
232
+ ## 许可证
233
+
234
+ [MIT](LICENSE)
Binary file
package/docs/suppliers.md CHANGED
@@ -76,7 +76,7 @@ js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给
76
76
 
77
77
  **`no_such_model` 是必须的,不能拿 `unavailable` 顶替。** 后者在核心策略里
78
78
  会计一次连续错误——组合里每有一个别人家的模型,就会给无关账号攒一次错误,
79
- 攒够 3 次把它冷却 10 分钟,表现为「组合越用越没号可用」。所以「这个模型不属于我」
79
+ 攒够 3 次把它冷却 5 分钟,表现为「组合越用越没号可用」。所以「这个模型不属于我」
80
80
  一定要报 `no_such_model`,核心才知道该换供应商而不是换账号。
81
81
 
82
82
  **js 只说「现在怎么了」,不说「该怎么办」。** 冷却多久、是否禁用、要不要换号
@@ -108,7 +108,7 @@ type ChatOnceResult =
108
108
  | `rate_limit` | 冷却 1 分钟 |
109
109
  | `quota` | 冷却 10 分钟 |
110
110
  | `session_dead` | **永久禁用**(需重新登录才能恢复) |
111
- | `unavailable` / `transport` / `unknown` | 计一次连续错误,攒够 3 次冷却 10 分钟 |
111
+ | `unavailable` / `transport` / `unknown` | 计一次连续错误,攒够 3 次冷却 5 分钟 |
112
112
  | `no_such_model` | **不惩罚账号**,核心直接跳过整个供应商换下一个 |
113
113
 
114
114
  - 非流式若聚合后发现**空响应**(无 content / reasoning / tool_calls),请返回
@@ -128,6 +128,22 @@ type ChatOnceResult =
128
128
  - 要让「上游吐到一半才断」也能回退,必须在写响应头前缓冲判定,代价是
129
129
  首字节延迟,目前没做
130
130
 
131
+ ### 终止帧 `[DONE]` 由核心兜底
132
+
133
+ 客户端(dsh-llm adapter)严格等 `data: [DONE]` 才认为流正常结束;缺了就抛
134
+ `SSE payload stream ended without [DONE]`,整轮判失败——已经流式吐出去的内容
135
+ **全部作废**。
136
+
137
+ 核心是 SSE 的唯一写入方,所以这个兜底由核心做,不要求 js 合成:
138
+
139
+ - 上游流结束时**没发过** `[DONE]` → 核心补发一个(降级为「拿到截断内容并
140
+ 正常收尾」,而不是整轮白干)
141
+ - 上游**已经发了** → 不再重复发(重复 `[DONE]` 同样坑客户端)
142
+ - **一个字节都没写出就断** → **不补**,留给换号/换模型回退(补了就把这次
143
+ 失败坐实成一个空响应,组合回退就没了)
144
+
145
+ js 因此不必自己合成终止帧(合成也不算错,核心会认)。
146
+
131
147
  所以 js 的流转换要**尽早产出第一个字节**,别攒着。攒着不仅延迟高,还会把
132
148
  自己的失败伪装成「可回退」,让组合以为没成功而反复重试别的模型。
133
149
 
@@ -296,7 +312,7 @@ export function dispose() {}
296
312
 
297
313
  存裸 id 时,核心只能遍历所有供应商逐个试。插件用 `unavailable` 回答
298
314
  「不是我的」,而这个状态在池策略里**计一次连续错误**——组合里每有一个别人家的
299
- 模型就给无关账号攒一次,攒够 3 次冷却 10 分钟,表现为「组合越用越没号可用」。
315
+ 模型就给无关账号攒一次,攒够 3 次冷却 5 分钟,表现为「组合越用越没号可用」。
300
316
 
301
317
  存了供应商 id 就不需要问了:直接调那一个。查不到供应商(拼错/供应商没加载)
302
318
  直接失败,**不兜底遍历**——兜底会让拼错的 id 静默落到别的供应商上,更难查。
@@ -243,16 +243,21 @@ window.__ModuleLoader__.load({
243
243
  cache: "no-store"
244
244
  })).json();
245
245
  if (data.ok && data.total > 0) {
246
- const failed = (data.results ?? []).filter((r) => r.status !== "ok" && r.status !== "already");
247
246
  let msg = `签到完成:${data.succeeded}/${data.total} 成功`;
248
247
  const already = data.already ?? 0;
249
248
  if (already > 0) msg += ` · ${already} 今日已签`;
250
- if (failed.length > 0) {
251
- const first = failed[0];
252
- msg += ` · ${first?.message ?? "有失败"}`;
253
- }
254
249
  showToast(msg);
255
- } else showToast(data.error ?? (data.total === 0 ? "没有可签到的链接" : "签到失败"));
250
+ } else {
251
+ const firstFail = (data.results ?? []).find((r) => r.status !== "ok" && r.status !== "already");
252
+ const reason = firstFail ? `${firstFail.message ?? "有链接签到失败"}` : "";
253
+ const already = data.already ?? 0;
254
+ const head = data.total > 0 ? `签到未完成:${data.succeeded}/${data.total} 成功` : "没有可签到的链接";
255
+ showToast(data.error ?? [
256
+ head,
257
+ already > 0 ? `${already} 今日已签` : "",
258
+ reason
259
+ ].filter(Boolean).join(" · "));
260
+ }
256
261
  onRefresh();
257
262
  } catch (err) {
258
263
  showToast(`签到失败:${err.message}`);
@@ -607,7 +612,10 @@ window.__ModuleLoader__.load({
607
612
  children: supplier.icon !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
608
613
  className: "dshr-providerImg",
609
614
  src: supplier.icon,
610
- alt: ""
615
+ alt: "",
616
+ onError: (e) => {
617
+ e.currentTarget.style.display = "none";
618
+ }
611
619
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon$4, {
612
620
  d: I$4.link,
613
621
  size: 22
@@ -3376,7 +3384,10 @@ window.__ModuleLoader__.load({
3376
3384
  children: [supplier.icon !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
3377
3385
  className: "dshr-supplierIcon",
3378
3386
  src: supplier.icon,
3379
- alt: ""
3387
+ alt: "",
3388
+ onError: (e) => {
3389
+ e.currentTarget.style.display = "none";
3390
+ }
3380
3391
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3381
3392
  className: "dshr-supplierName",
3382
3393
  children: supplier.name
@@ -3386,9 +3397,9 @@ window.__ModuleLoader__.load({
3386
3397
  children: [supplierAccounts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3387
3398
  className: "dshr-muted",
3388
3399
  children: [
3389
- supplierAccounts.length,
3390
- "/",
3391
3400
  healthy,
3401
+ "/",
3402
+ supplierAccounts.length,
3392
3403
  " 健康"
3393
3404
  ]
3394
3405
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
package/lib/client.js CHANGED
@@ -243,16 +243,21 @@ window.__ModuleLoader__.load({
243
243
  cache: "no-store"
244
244
  })).json();
245
245
  if (data.ok && data.total > 0) {
246
- const failed = (data.results ?? []).filter((r) => r.status !== "ok" && r.status !== "already");
247
246
  let msg = `签到完成:${data.succeeded}/${data.total} 成功`;
248
247
  const already = data.already ?? 0;
249
248
  if (already > 0) msg += ` · ${already} 今日已签`;
250
- if (failed.length > 0) {
251
- const first = failed[0];
252
- msg += ` · ${first?.message ?? "有失败"}`;
253
- }
254
249
  showToast(msg);
255
- } else showToast(data.error ?? (data.total === 0 ? "没有可签到的链接" : "签到失败"));
250
+ } else {
251
+ const firstFail = (data.results ?? []).find((r) => r.status !== "ok" && r.status !== "already");
252
+ const reason = firstFail ? `${firstFail.message ?? "有链接签到失败"}` : "";
253
+ const already = data.already ?? 0;
254
+ const head = data.total > 0 ? `签到未完成:${data.succeeded}/${data.total} 成功` : "没有可签到的链接";
255
+ showToast(data.error ?? [
256
+ head,
257
+ already > 0 ? `${already} 今日已签` : "",
258
+ reason
259
+ ].filter(Boolean).join(" · "));
260
+ }
256
261
  onRefresh();
257
262
  } catch (err) {
258
263
  showToast(`签到失败:${err.message}`);
@@ -607,7 +612,10 @@ window.__ModuleLoader__.load({
607
612
  children: supplier.icon !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
608
613
  className: "dshr-providerImg",
609
614
  src: supplier.icon,
610
- alt: ""
615
+ alt: "",
616
+ onError: (e) => {
617
+ e.currentTarget.style.display = "none";
618
+ }
611
619
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon$4, {
612
620
  d: I$4.link,
613
621
  size: 22
@@ -3376,7 +3384,10 @@ window.__ModuleLoader__.load({
3376
3384
  children: [supplier.icon !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
3377
3385
  className: "dshr-supplierIcon",
3378
3386
  src: supplier.icon,
3379
- alt: ""
3387
+ alt: "",
3388
+ onError: (e) => {
3389
+ e.currentTarget.style.display = "none";
3390
+ }
3380
3391
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3381
3392
  className: "dshr-supplierName",
3382
3393
  children: supplier.name
@@ -3386,9 +3397,9 @@ window.__ModuleLoader__.load({
3386
3397
  children: [supplierAccounts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3387
3398
  className: "dshr-muted",
3388
3399
  children: [
3389
- supplierAccounts.length,
3390
- "/",
3391
3400
  healthy,
3401
+ "/",
3402
+ supplierAccounts.length,
3392
3403
  " 健康"
3393
3404
  ]
3394
3405
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {