@dsh-plugins/dsh-llm-hub 0.7.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 ADDED
@@ -0,0 +1,374 @@
1
+ # 更新日志
2
+
3
+ 本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
4
+
5
+ ## [0.7.0] - 2026-09-17
6
+
7
+ ### 新增
8
+
9
+ - **外部 harness 子代理**:把本机装好的 `codex` / `claude` / `agy` 注册成 DSH 子代理
10
+ 提供方,会话里多出 `subagent_codex` / `subagent_claude_code` / `subagent_antigravity`
11
+ 三个委派工具,各自烧各自的订阅额度。
12
+
13
+ **没装的不会出现** —— provider 只在可执行文件真的存在时才注册,而
14
+ `dsh-tool-subagent` 对缺失的 provider 只打一条 info、把工具行延迟到 provider 出现
15
+ 才注册(实测 `dsh-tool-subagent/lib/index.js:575`)。所以别人机器上没装 codex,
16
+ `subagent_codex` 根本不进工具目录,宿主照常启动,不需要任何开关。
17
+
18
+ 探测走 `ctx.subprocess.resolveExecutable`,不自己扫 PATH:shell alias 会骗过
19
+ `command -v`(本机 `agy` 就是带 `--dangerously-skip-permissions` 的 alias),
20
+ 而 launchd 起的宿主读不到 `.zshrc` 的 PATH。
21
+
22
+ 几个钉死的边界(每个都有测试覆盖):
23
+ - `agy` **必须**带 `--dangerously-skip-permissions`:headless print 模式下它自动
24
+ 拒绝 `command` 权限,任何碰文件或命令的任务都 exit 0 且零输出 —— 不报错,只是
25
+ 「成功」地什么都没干。
26
+ - **exit 0 + 空输出一律报错**,不折成 `completed`(否则父 agent 拿空答案往下走)。
27
+ - `codex` 带 `--skip-git-repo-check`(父 cwd 不一定是 git 仓库,缺了直接拒跑)。
28
+ - stderr 只留 8 KiB 尾巴(agy 的 glog 在日志目录不可写时能喷 300+ 行)。
29
+ - 同名 provider 已被官方 bundle 占用时跳过并继续注册剩下的,不中断。
30
+
31
+ 内核符号(`dsh-subagent` / `dsh-session`)用**动态** import:它们由宿主提供、仓库
32
+ 目录里解析不到,静态 import 会让仓库内测试 `MODULE_NOT_FOUND`,也会让宿主少一个
33
+ 导出时整个插件加载失败(连余额和模型发现一起没)。探测与注册这条启动路径完全不碰
34
+ 内核 import —— 空能力声明就地内联。
35
+
36
+ ⚠️ 升级注意:如果你在 `~/.dsh/.agent-presets/*/agent.cordis.yml` 里手工加过
37
+ `tool-subagent-codex` 这类行,删掉它们 —— 同一个 `toolName` 不能注册两次。
38
+
39
+ ### 修复
40
+
41
+ - **发布工作流的 npm 幂等探测查错了包**:`npm view "dsh-llm-hub@$VER"` 少了 scope,而
42
+ npm 上还留着一个同名无 scope 的旧包(`dsh-llm-hub@0.6.1`)。探它等于探错对象 ——
43
+ 本包每个新版都被判成「不存在」,于是重跑 workflow 会再 publish 一次、撞 npm 的 403
44
+ `cannot publish over previously published versions`,把一次已经成功的发布报成失败。
45
+ 注释里写的「npm 上已有该版本就跳过」此前是句空话。
46
+
47
+ - **`npm run check` 漏文件**:原来硬写 `lib/index.js` + `lib/client.js` 两个,新增
48
+ `lib/harness.js` 时漏了 —— 而 check 正是 publish 的第一道闸门。改成遍历 `lib/*.js`,
49
+ 以后加文件不会再漏。同理给发布产物校验清单补上 `lib/harness.js`(漏了会发出一个
50
+ 不含核心功能的包,而那个清单存在的唯一理由就是防这个)。
51
+
52
+ ## [0.6.5] - 2026-09-17
53
+
54
+ ### 文档
55
+
56
+ - README.md / README.en.md:发布示例里 `gh workflow run publish.yml -f tag=v…`
57
+ 的占位 tag 从 `v0.6.2` 改成 `v0.6.4`(跟着当前最新 release 走,避免读者照抄过老版本)。
58
+
59
+ ## [0.6.4] - 2026-09-17
60
+
61
+ ### 新增
62
+
63
+ - **`models: []` 自动 discover 写回**:当用户在 `settings.yaml` 里把 modelgo 这类
64
+ pi-ai 风格 provider 的 `models:` 留成空列表,plugin 启动时主动去对应网关
65
+ `GET <baseURL>/models` 拉一份目录写回去 —— settings.yaml 里就不再留 `models: []`
66
+ 这种「我不知道该填什么」的占位。下次 reload / 重启不需要重做这件事:pi-ai 自己的
67
+ onChange 会按 settings 重新注册 model 目录。
68
+
69
+ 写回是「跨 namespace 兜底」:plugin 自己的 settings NS 是 `llm-deepseek`,但
70
+ 写回的目标是 `llm-pi-ai.providers.*` —— plugin 不是 pi-ai 的 owner,只是借用
71
+ settings 服务给 pi-ai 段补一份目录。
72
+
73
+ 几个钉死的边界条件(每个都有测试覆盖):
74
+ - 用户已显式填的 `models: [a, b]` **不**被覆盖。
75
+ - 缺 `baseURL` / `apiKeyEnv` 时直接跳过,不报错。
76
+ - fetch 失败 / 状态非 2xx / JSON 解析失败 → best-effort 跳过,下次 boot 再试。
77
+ - 写回只在 plugin 启动时跑一次;不订阅 `settings/onChange`(避免自己写回后被自己
78
+ 的广播二次触发)。
79
+ - **TOCTOU 防护**:fetch 期间用户在外部编辑 settings.yaml 填上了 `models`,
80
+ 写入前会再 `get` 一次当前段,把已非空的 provider 从 `updates` 里过滤掉,避免
81
+ fetch 前的快照结果覆盖用户的并发编辑。
82
+
83
+ ### 测试
84
+
85
+ - 新增 `test/host-settings-patch.test.mjs`:7 条用例覆盖上述每条边界条件 + 启动时
86
+ boot patch 真把空列表填上 + fetch 期间用户外部填了 `models` 时不被覆盖(TOCTOU)。
87
+
88
+ ## [0.6.3] - 2026-09-17
89
+
90
+ ### 修复
91
+
92
+ - **插件加载失败**:`lib/client.js` 注册的 id 是 `dsh-llm-hub`,而包名已经是
93
+ `@webkubor/dsh-llm-hub`。宿主按 `package.json` 的 `name` 找注册,对不上就报
94
+ `loaded without registering @webkubor/dsh-llm-hub via __ModuleLoader__.load`。
95
+
96
+ 来源是 0.6.2 那次 scope 迁移(commit f907589):package.json 改了,这里漏了。
97
+
98
+ 代价比看起来大——DSH 把所有插件打进**同一个 client bundle**,一个注册失败
99
+ 整个 bundle 一起废,用户看到的是「Failed to load plugins」加一长串五十几个包名,
100
+ **完全看不出是哪个插件的锅**。
101
+
102
+ ### 测试
103
+
104
+ - `test/client-cards.test.mjs` 里本来就有「id 必须与 package.json 的 name 一致」这条断言,
105
+ 说法完全正确,**但期望值写死成了旧包名** —— 迁移时它非但没拦住,反而成了钉住旧名的锚。
106
+ 改成从 `package.json` 读,以后包名再变自动跟随。
107
+ - 新增 `test/plugin-id.test.mjs`:独立守卫同一条约束,外加「产物里不能出现顶层
108
+ import/export」(宿主是 `factory(require)` 形态,顶层模块语法会让 bundle 解析失败)。
109
+
110
+ ## [0.6.2] - 2026-09-16
111
+
112
+ ### 测试
113
+
114
+ - 把可用性判定的回归测试搬进仓库:`test/` 21 例,**零依赖**(只用 `node:test` + `node:assert`),
115
+ CI 加一步 `npm test`,`test/` 不进发布产物。
116
+ - 判定链很长(凭据 → 探测 → 余额 → 运行期),任何一环退化都不会有编译错误,只会静默地把
117
+ 能用的模型藏起来、或把不能用的留在下拉里。所以每条用例都钉住一个**真实踩过的坑**:
118
+ 凭据缺失摘除 / 401·402 / 现金余额 0 / 配额「余量 0%」与「已用 100%」两种语义 /
119
+ 拿不准一律保留(fail-open)/ 隐藏后仍会被重探并能恢复 / 隐藏集合变化时广播 /
120
+ 不监听自己的广播 / traceable 代理包装下过滤器仍装得上 / 运行期遥测与成功恢复 /
121
+ 非鉴权失败不隐藏 / 卡片状态片按需出现 / 页脚计数与重探 / 读取与重探各自独立的在途链。
122
+
123
+ ## [0.6.1] - 2026-09-16
124
+
125
+ ### 文档
126
+
127
+ - 配图跟上 v0.6.0 的界面:pi-ai 卡片图从 `v052` 换到 `v060`(卡片已收口成「事实一行 +
128
+ 动作一行」),并给「模型下拉可用性」小节补上配图 —— 卡片上的「已从下拉隐藏」状态片
129
+ 与页脚的「已隐藏 1 · 重新探测全部」。两张图同步进 `docs/images/`,README 中英两份都改。
130
+
131
+ ## [0.6.0] - 2026-09-16
132
+
133
+ ### 新增
134
+
135
+ - **模型下拉只留当前真能用的分组。** composer 的下拉以前列出所有已配置 provider,与能不能
136
+ 调通无关:key 过期、余额耗尽、网关 401 都照常在那儿,点了才报错。现在「确凿不可用」的会被
137
+ 摘掉,判据只认确凿证据(凭据解析不到 / 网关 401·403·402 / 余额确凿为 0 / 配额确凿用尽 /
138
+ 真实请求因鉴权或欠费失败),**拿不准一律保留** —— 误藏一个能用的比多显示一个不能用的更糟。
139
+ - 新增 `GET /api/dsh-llm-hub/availability`(读缓存,过期顺手后台重探)与
140
+ `POST /api/dsh-llm-hub/availability/recheck`(强制全量重探)。
141
+ - 被隐藏的 provider 在设置页照常可见:它的卡片动作条上多一枚红色「已从下拉隐藏」状态片
142
+ (原因在悬停提示里),页脚给出全局的「已隐藏 N」与「重新探测全部」。
143
+ 不另设逐条重述的面板 —— 卡片上本来就有状态,再来一份只是重复。
144
+ - 运行期遥测:监听 `agent/request-error`(`INVALID_CREDENTIAL` / `QUOTA_EXCEEDED`)与
145
+ `llm/stream` 的终态,真实请求失败即时隐藏、成功即时恢复。
146
+ - 恢复是自动的:`settings/document-updated` 让判定缓存立刻作废,改好 key / 充值后下一次读取
147
+ 即重探(这条以前漏了 —— `apiKeyEnv` 这类字段不在 provider 目录事实里,`llm/adapters-updated`
148
+ 根本不会发,于是「充值了却不恢复」)。
149
+
150
+ ### 界面
151
+
152
+ - **provider 卡片收口,省掉一行多的空白。** pi-ai 卡原来是两个各自带边框+内边距的盒子
153
+ (事实行末尾挂「探测网关」,宽度不够就换行;再另起一盒放「拉取目录」),一张卡白吃两行。
154
+ 现在事实与动作在同一个盒子里,「探测网关 / 拉取目录 / 复制 id」排成一条工具条,
155
+ 按钮改成有边框的小胶囊(宿主自己的按钮就是这个形态)。2026-09-16 owner 指着截图:
156
+ 「有点浪费空间,有的就是一个文字占一行」。
157
+ - **去掉逐条重述的可用性面板**(初版曾挂在 `settings.models.footer`)。它把每张卡片上已有的
158
+ 状态又念了一遍,只有「被摘掉了」和「刷新」是新增信息 —— 前者收进卡片的状态片,后者并进页脚。
159
+ owner:「这不是很多余吗,上面不都是显示了吗」。
160
+
161
+ ### 修复
162
+
163
+ - **探针请求加了超时(6s)。** `fetch` 默认没有超时,网关接了 TCP 却不回包会把整轮
164
+ `Promise.all` 吊死:`probing` 永远 true、判定再也不刷新,面板停在旧结论上。
165
+ - **判定目标改从设置段取,不再读 `llm.listProviders()`。** 后者正是过滤器的输出,
166
+ 拿它当目标,被隐藏的 provider 就再也不会进入下一轮探测 —— 「隐藏即永久」。
167
+ 2026-09-16 实测:minimax 被判不可用后,之后每一轮探测的目标里都没有它。
168
+ - **替换 `llm.listProviders` 的成功校验改看属性描述符。** cordis 的服务访问返回 traceable
169
+ 代理,每次读方法都是新的包装对象,`!==` 永远成立 —— 上一版因此把自己误判成「赋值未生效」,
170
+ 顺手清掉了探针用的未过滤表,等于亲手制造了上面那条「隐藏即永久」。
171
+ - 探针结果的清理不再误删「这一轮没被当成目标」的 provider 判定。
172
+ - 面板不再把半份名单当成全部:探针在途时明确显示「探测中…」,客户端带退避地跟进一次。
173
+ - 余额百分比字段的 `0` 不再被当成「没填」。共用的 `capacity()` 只认正数,
174
+ MiniMax 的「剩余 0%」被静默丢掉,于是余额为 0 的账号照样留在下拉里。
175
+
176
+ ## [0.5.2] - 2026-09-15
177
+
178
+ ### 修复
179
+
180
+ - 配额查不到时不再把上游那句话原样印在卡片上。智谱的行上一直写着「当前用户不存在
181
+ coding plan」——读到的人既不知道发生了什么,也不知道该做什么(owner:「显示这个没意义」)。
182
+ 没有配额就不显示配额,这一行上真正有用的是**协议与接入地址**,它们照常在;
183
+ 上游原因移到行的 `title`,排查时仍能看到。
184
+
185
+ ## [0.5.1] - 2026-09-15
186
+
187
+ ### 修复
188
+
189
+ - README 里有两段互相矛盾的安装说明,首屏那段还写着 `cd ~/.dsh/profiles/web && npm i`
190
+ —— 写死了作者自己的 profile 名,别人照抄会装到错地方。现在只留一处正确的。
191
+ 同时删掉「升级到 0.2.0 后的验证」这份三个版本前的一次性清单。
192
+
193
+ ## [0.5.0] - 2026-09-15
194
+
195
+ ### 新增
196
+
197
+ - **pi-ai 行显示协议与接入地址**。这两项决定了这个 provider 到底连去哪、用哪套报文
198
+ (`openai-completions` / `anthropic-messages` / …),出问题时第一眼要看的就是它们,
199
+ 之前只能去翻 `settings.yaml`。协议直接显示;地址只显示域名,完整 URL 放 `title` ——
200
+ 一行放不下,而域名已经够回答「连的是不是我以为的那个网关」。
201
+
202
+ - **页脚「分享插件」**。复制的是一段完整说明:一句介绍 + 三步安装 + 仓库链接,
203
+ 发给别人就能照着装上。
204
+
205
+ ### 修复
206
+
207
+ - 分享文案原先是 `cd ~/.dsh/profiles/web && npm i dsh-llm-hub` —— 写死了我自己的
208
+ profile 名,而且**装进 node_modules 不等于接进 boot graph**:少了
209
+ `dsh.profile.bundles` 那行,插件根本不会加载。对方照着做装不上,等于分享没用。
210
+ README 的安装段同步改成 `dsh plugin --profile <name> add`。
211
+
212
+ ## [0.4.1] - 2026-09-15
213
+
214
+ ### 新增
215
+
216
+ - **模型页页脚:版本 + GitHub + 问题反馈**。别的 DSH 插件都有反馈入口,这个没有——
217
+ 用户遇到问题无处可说,闭不上环。新增 `GET /api/dsh-llm-hub/meta` 返回包名、版本、
218
+ 仓库与 issues 地址,页脚(`settings.models.footer`)渲染成一行。
219
+
220
+ 版本号从 `package.json` 读,**不在前端硬编码**——硬编码的版本每次发版都要记得改,
221
+ 而忘记改的那次没人会发现。
222
+
223
+ ### 修复
224
+
225
+ - meta 路由第一版写了 `require('../package.json')`,本包是 ESM(`type: module`),
226
+ 装上去直接 `require is not defined`。改用 `import.meta.url` + `readFileSync`。
227
+ 这类错只在运行时暴露,语法检查看不出来。
228
+
229
+ ## [0.4.0] - 2026-09-15
230
+
231
+ 拉到了目录却只能「复制全部 id」——最后一公里一直留给人自己粘。这版把它走完。
232
+
233
+ ### 新增
234
+
235
+ - **目录选择器**:拉取目录后直接列出网关在售的模型,逐个勾选,点「保存到配置」
236
+ 写回 `llm-pi-ai.providers.<id>.models`。已配置的默认勾上并标注,人只需要动增量。
237
+ - **已知网关默认地址**:settings 里没填 baseURL 时,用服务商的官方地址兜底
238
+ (智谱国内/国际、MiniMax、Moonshot、StepFun)。自建网关不进这张表——它们的
239
+ 地址因人而异,猜不得。
240
+ - `status` 增加 `modelIds`(已配置的 id 列表,前端据此默认勾选)与 `baseURLSource`
241
+ (`settings` / `known` / `none`,用来区分地址是人填的还是兜底来的)。
242
+
243
+ ### 变更
244
+
245
+ - **目录拉取不再只对 modelgo 开放**。「网关上到底有哪些模型」对每个 provider 都有用:
246
+ 智谱手填 8 个、网关在售 16 个,不拉一次根本不知道漏了什么。
247
+ - 智谱不再显示「未配置 baseURL,无法探测」。这句话和「余额查得通」同时出现过——
248
+ 同一个 provider,余额那条路知道它在哪(适配器自带 hostFor),探测这条路说不知道。
249
+ 两边现在共用同一张已知网关表。
250
+ - 文案继续去技术化:「未配置 baseURL,无法探测;模型为手填目录」→
251
+ 「没填服务地址,探测不了;模型只能手填」。
252
+
253
+ ### 写入纪律
254
+
255
+ 唯一的写操作,只接受 POST + 同源,且**只动 `models` 一个字段**:已配置模型的
256
+ `contextWindow` / `maxTokens` / `input` / `reasoningEfforts` 等人填的定义原样保留,
257
+ 不会因为一次勾选被抹平。实测勾选 MiniMax-M2.7 保存后,MiniMax-M3 的完整定义一字未动。
258
+
259
+ ## [0.3.1] - 2026-09-15
260
+
261
+ ### 修复
262
+
263
+ - **「状态读取失败」满屏**:`status === null` 是「还没拉回来」的初始态,却和真失败
264
+ 共用一个分支。页面一打开、或 DSH 刚重启导致这次 fetch 断掉时,每一行 pi-ai 都写着
265
+ 「状态读取失败」,看着像插件坏了。现在加载中显示「读取中…」,只有真失败才报失败。
266
+
267
+ ## [0.3.0] - 2026-09-15
268
+
269
+ ### 新增
270
+
271
+ - **provider 余额 / 配额卡**:pi-ai 行下方显示各家的余量,四个适配器:
272
+ | provider | 接口 | 显示 |
273
+ |---|---|---|
274
+ | minimax | `/v1/token_plan/remains` | 套餐余量百分比(日 / 周) |
275
+ | zhipu | `/api/monitor/usage/quota/limit` | coding 套餐配额 |
276
+ | moonshot | `/v1/users/me/balance` | 现金余额 |
277
+ | stepfun | `/v1/accounts` | 现金 + 代金券 |
278
+
279
+ 自建网关(ModelGo 等)没有开放计费路由,如实报「不支持」,**绝不猜端点**。
280
+
281
+ > ⚠️ 这个功能的代码其实在 0.2.0 就已随包发布,但当时被误提交进一个标题为
282
+ > `docs: 0.2.0 发版材料` 的 commit(`git add -A` 把工作区里的代码一起带走了),
283
+ > CHANGELOG 与 README 都没提过它。此处补记,并正式计入 0.3.0。
284
+
285
+ ### 修复
286
+
287
+ - **zhipu 的余额接口从来没通过**:`path: () => '/api/monitor/...'` 把 base 参数整个
288
+ 丢掉,于是 `hostFor` 明明给对了 `https://open.bigmodel.cn`,fetch 拿到的仍是相对
289
+ 路径,直接抛 `Failed to parse URL`。补回 base 后正常返回上游的
290
+ 「当前用户不存在coding plan」。
291
+ - **错误提示不再暴露技术细节**。设置页是给用人看的,之前卡片上直接印
292
+ `could not reach /api/monitor/usage/quota/limit: Failed to parse URL from ...`,
293
+ 读的人既不知道发生了什么,也不知道该做什么。现在 `reason` 一律是人话
294
+ (「未填写服务地址,查不了余额」「连不上服务商」「API key 可能无效或没有查询余额的
295
+ 权限」「这家服务商没有提供余额查询接口」),技术细节移到不展示的 `detail` 字段。
296
+ - 拼出的 URL 不是绝对地址时提前拦截并给出人话 —— 适配器是一张表,表里任何一行
297
+ 写错都不该让用户看见一句 `Failed to parse URL`。
298
+
299
+ ## [0.2.3] - 2026-09-14
300
+
301
+ 纯文档版本,代码零改动。
302
+
303
+ ### 变更
304
+
305
+ - README 首屏从 66 行压到 34 行。0.2.1 那版按基线补齐了要素,但**把要素堆满了首屏**:
306
+ 5 个 for-the-badge 大徽章挤成一排(其中「0/month 下载量」「runtime deps 0」反而减分)、
307
+ 中文页里还压着一整行英文副标题、"三个能力"三条长句换行后糊成一团。
308
+ - 现在首屏只留四样:banner、三枚细徽章、一张对比矩阵、一个安装命令。
309
+ 对比矩阵去掉 ✅/❌ 图标改用文字对照,列更齐、扫读更快。
310
+ - 三条能力的内容并入对比矩阵,不再重复讲一遍。
311
+
312
+ ## [0.2.2] - 2026-09-14
313
+
314
+ 纯品牌资产版本,代码零改动。
315
+
316
+ ### 新增
317
+
318
+ - **Banner**(1600×500)与 **Logo**(512×512)—— 开源基线要求的品牌资产,此前缺失。
319
+ 设计不用氛围图:banner 右侧是产品真实输出的抽象化(三个 provider 的探测结果:
320
+ 绿点/灰点、模型数、延迟),logo 是探测波纹 + 发光核心。与同宿主的
321
+ dsh-bloom-theme 保持深色基调,但视觉元素是本项目自己的灵魂而非配色展示。
322
+ - README 首图换成 banner,产品截图移到「用法」段(各司其职:首图讲定位,截图证明输出)
323
+
324
+ ## [0.2.1] - 2026-09-14
325
+
326
+ 纯文档版本,代码零改动。
327
+
328
+ ### 变更
329
+
330
+ - README 按开源项目基线重写(`cs rule open_source_project_baseline` 的 README 金字塔):
331
+ 产品截图首图 + 居中标题与定位 + 徽章 + 「为什么需要它」对比矩阵 + 三条能力 + 30 秒上手,
332
+ 技术细节全部保留、移到首屏以下
333
+ - Quickstart 改成**可直接运行**的真实步骤(含接入 `dsh.profile.bundles` 那一步,
334
+ 漏掉它插件装了也不加载)
335
+ - 中英文 README 结构对齐
336
+
337
+ ## [0.2.0] - 2026-09-14
338
+
339
+ 给 `llm-pi-ai` 段里的每个 provider(modelgo / minimax / zai-coding-cn …)补上官方
340
+ 适配器天然做不到的那一半:**网关到底通不通、上面到底有多少模型**。
341
+
342
+ 官方 pi-ai 适配器占着自己的 discovery 坑,而 `LISTABLE_PROTOCOLS` 又不含
343
+ `anthropic-messages` —— modelgo 这类网关的「获取可用模型」按钮天生失效。本版旁路补上,
344
+ 不与官方适配器竞争注册。
345
+
346
+ ### 新增
347
+
348
+ - **网关可达性探测**:provider 卡片下方常驻一行 `pi-ai · 显示名 · 已配 N 个模型 · Key ✓/✗`,
349
+ 点「探测网关」实测延迟与在售模型数
350
+ - **目录拉取**:modelgo 等支持目录的网关可一键拉全量模型 id,并「复制全部 id」
351
+ - **说清为什么不能探测**:没配 `baseURL` 的 provider 直接写明「无法探测;模型为手填目录」,
352
+ 而不是转圈或静默
353
+ - 三个只读接口:`/api/dsh-llm-hub/pi-ai/{status,probe,catalog}?provider=<id>`
354
+
355
+ ![pi-ai 行:可达性探测与目录拉取](https://img.webkubor.online/oss/dsh-llm-hub/models-piai-cards.png)
356
+
357
+ ### 修复
358
+
359
+ - **pi-ai 行曾整个不渲染**:宿主 `settings.models.provider-card` 传进来的 `provider`
360
+ 是 entry 对象(`{ provider, displayName, settingsNs, … }`),而组件按字符串取值,
361
+ 拿到对象就 `return null`。三个 API 端点全通、插件版本也对,页面上却什么都没有,
362
+ 控制台无报错 —— DeepSeek 那张卡不读这个字段,所以看不出问题。现已两种形态都接。
363
+
364
+ ### 已知限制
365
+
366
+ - 拉取到的模型目录只能看和复制,**还不能勾选写回 settings**;协议与 baseURL 也未在行内展示。
367
+ 两项都在 0.3.0 计划中。
368
+
369
+ ## [0.1.0] - 2026-09
370
+
371
+ - **DeepSeek 官方直连的模型发现**:自动拉取可用模型,免去手填
372
+ - **余额与可用性**:Models 页 DeepSeek 卡片下方常驻余额行,可手动刷新
373
+
374
+ ![DeepSeek 卡片上的余额行](https://img.webkubor.online/oss/dsh-llm-hub/models-deepseek-balance.png)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 webkubor
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.en.md ADDED
@@ -0,0 +1,266 @@
1
+ <p align="center">
2
+ <img src="https://img.webkubor.online/oss/dsh-llm-hub/banner.png" alt="dsh-llm-hub" width="100%" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/@dsh-plugins/dsh-llm-hub"><img src="https://img.shields.io/npm/v/@dsh-plugins/dsh-llm-hub?style=flat-square&color=4C7EF3&label=npm" alt="npm" /></a>
7
+ <img src="https://img.shields.io/badge/deps-0-5A9E6F?style=flat-square" alt="zero deps" />
8
+ <img src="https://img.shields.io/badge/license-MIT-777?style=flat-square" alt="MIT" />
9
+ &nbsp;·&nbsp; <a href="README.md">中文</a> · <a href="CHANGELOG.md">Changelog</a>
10
+ </p>
11
+
12
+ On DSH's Models page, the official adapters leave half the job undone. This plugin finishes it:
13
+
14
+ | | Official adapter | dsh-llm-hub |
15
+ |---|---|---|
16
+ | Which DeepSeek models exist | invisible | **one click, live list** |
17
+ | How much credit is left | invisible | **balance row on the card** |
18
+ | Is the gateway up, how fast | button does nothing | **measured latency & status** |
19
+ | How many models it serves | invisible | **71 measured** (11 hand-typed) |
20
+ | Why it can't be probed | no hint | **says "no baseURL"** |
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ dsh plugin --profile web add @dsh-plugins/dsh-llm-hub
26
+ ```
27
+
28
+ Add `@dsh-plugins/dsh-llm-hub` to `dsh.profile.bundles` in `~/.dsh/profiles/web/package.json`, then run `~/.dsh/restart.sh`.
29
+ Open **Settings → Models** — a new row appears under the provider cards.
30
+
31
+ <sub>A boot-graph change requires a restart; hot reload won't pick it up. `cordis.patch.yml` is inserted automatically by the bundle mechanism.</sub>
32
+
33
+ ---
34
+
35
+ ## What it fills in
36
+
37
+ DSH already ships every mechanism involved; what was missing is that the
38
+ official adapter never used them:
39
+
40
+ | Capability | Official mechanism | State on the direct route |
41
+ |---|---|---|
42
+ | Model discovery | `llm` service's `registerModelDiscovery(ns, discover)` + the Models page's "Fetch available models" | `@deepseek-ai/dsh-llm-deepseek` **never registers one** (`discover` has zero occurrences in both 0.1.2-rc.1 and 0.1.5-rc.2) |
43
+ | Provider-card extension | `settings.models.provider-card` (keyed by `settingsNs`) | No registrant → the area renders nothing |
44
+ | Account balance | DeepSeek `GET /user/balance` | Not exposed by the adapter |
45
+
46
+ **The discovery registry permits exactly one registration per settings
47
+ namespace** (a second throws `DUPLICATE_DISCOVERY`), and the `llm-deepseek`
48
+ namespace is unoccupied — so this plugin takes it. DSH's own
49
+ `slot-contract.d.ts` states plainly that those two slots exist for **plugins
50
+ distributed outside the repository**.
51
+
52
+ ## Usage
53
+
54
+ **Model discovery** — Settings → Models → the **DeepSeek** card → **"Fetch
55
+ available models"**. It live-fetches `https://api.deepseek.com/models` and
56
+ offers every advertised model for adoption.
57
+
58
+ **Balance** — a balance row appears under the same DeepSeek card (fetched on
59
+ mount, with a manual refresh).
60
+
61
+ **pi-ai bypass card** — under any pi-ai provider card (modelgo / minimax /
62
+ zai-coding-cn …): a standing row with the configured model count and key state,
63
+ a **Probe gateway** button reporting reachability, latency and the remote
64
+ catalog size, and — modelgo only — **Fetch catalog** with one-click id copy.
65
+ Providers without a `baseURL` (zai-coding-cn) show an honest "cannot probe"
66
+ hint instead.
67
+
68
+ ## Behaviour
69
+
70
+ ### Connection facts
71
+
72
+ `baseURL` and `apiKey` resolve in the same order the adapter itself uses, and
73
+ the `llm-deepseek` settings section is **re-read lazily on every call** — at
74
+ plugin-apply time the section may not be registered yet (a startup race), and
75
+ the adapter re-resolves its own connection facts per request:
76
+
77
+ | | Resolution order |
78
+ |---|---|
79
+ | baseURL | `request.baseURL` → the section's `baseURL` → `$DEEPSEEK_BASE_URL` → `https://api.deepseek.com` |
80
+ | apiKey | `request.apiKey` (a one-shot key typed into the form) → the credential named by the section's `apiKeyEnv` → that environment variable |
81
+
82
+ ### Balance route
83
+
84
+ `GET /api/dsh-llm-hub/balance` →
85
+ `{ ok, isAvailable, balances: [{ currency, total, granted, toppedUp }] }`
86
+
87
+ Amounts are kept **as the strings DeepSeek returns** (the upstream sends
88
+ strings; parsing them would invite floating-point drift). Only `GET`/`HEAD` are
89
+ accepted (otherwise 405), and cross-origin reads are refused (`Sec-Fetch-Site`
90
+ other than `same-origin`/`none` → 403) — a balance is account information and
91
+ should not be readable by a cross-site page even when the server is bound to
92
+ loopback.
93
+
94
+ ### UI mount point
95
+
96
+ `settings.models.provider-card` with `key = 'llm-deepseek'`. The owner prop
97
+ `keyConfigured` decides whether a request is made at all: with no key
98
+ configured the card shows a hint instead of fetching.
99
+
100
+ ## Model dropdown availability (only what works)
101
+
102
+ The composer's model dropdown lists **every configured provider** regardless of whether it can
103
+ actually be called: an expired key, an empty balance, or a gateway returning 401 all still show up
104
+ and only fail once you pick them. This plugin hides providers that are **confirmed unusable**:
105
+
106
+ Criteria are evidence-only (fail-open — anything inconclusive is kept; hiding a working model is
107
+ worse than showing a broken one):
108
+
109
+ | Signal | Hides when |
110
+ |---|---|
111
+ | Credential | the variable named by `apiKeyEnv` resolves nowhere (neither the credential store nor the environment) |
112
+ | Probe | the gateway answers the catalog endpoint with `401`/`403`/`402` |
113
+ | Balance | DeepSeek `/user/balance` reports `is_available=false` or a zero balance; a MiniMax/Zhipu quota is provably exhausted |
114
+ | Runtime | a real request failed with `INVALID_CREDENTIAL` / `QUOTA_EXCEEDED` (via `agent/request-error`) |
115
+
116
+ **Recovery is automatic**: after you fix the key or top up, `settings/document-updated` invalidates
117
+ the cache and the next read re-probes; a successful real request also clears the runtime mark at once.
118
+ The **"Re-check all"** button at the bottom of Settings → Models forces a full re-probe.
119
+
120
+ **Hidden providers do not disappear**: their cards stay in Settings → Models; the card just grows a
121
+ red "hidden from dropdown" chip in its action row (the reason lives in its tooltip). The footer carries
122
+ the global count and the **"Re-check all"** action. There is deliberately no separate panel restating
123
+ each provider — the cards already show their own state, so that would only be duplication.
124
+
125
+ ![The "hidden from dropdown" chip on the ModelGo card, and the footer's "hidden 1 · Re-check all"](https://img.webkubor.online/oss/dsh-llm-hub/v060/availability.png)
126
+
127
+ ### How it works, and the trade-off
128
+
129
+ Filtering happens in the **host half, on `ctx.llm.listProviders()`** — the only seam that covers every
130
+ consumer at once (composer dropdown, `/model` popup, subagent picker, ACP), and it is subtractive and
131
+ reverted when the plugin unloads. The settings page reads the configurable-provider directory
132
+ (`listConfigurableProviders`) instead, so it is unaffected.
133
+
134
+ Wrapping `ctx.modelDirectories` in the client half was tried and **fails**: it is a cordis
135
+ inject-tracking proxy whose methods come back as traceable proxies, so a plugin fiber without the
136
+ `remote.session` inject throws `cannot get property "remote.session" without inject` — measured on
137
+ 2026-09-16, it crashes the shipped model seat right out of the composer. The contract marks
138
+ `conversation.input.model` as a single slot with `replaceRisk: shadows-shipped-ui`; taking it over
139
+ means re-implementing the whole menu and tracking upstream forever, which is not worth it.
140
+
141
+ One more counter-intuitive trap: **judging must be driven by the settings section, never by
142
+ `listProviders()`** — the latter is the filter's own output, so treating it as the target means a
143
+ hidden provider never enters the next probe round: hidden forever. Likewise, a cordis service method
144
+ **cannot** be verified with `!==` (every access through a traceable proxy yields a new object); check
145
+ the property descriptor instead.
146
+
147
+ ## External harness subagents (they appear only if installed)
148
+
149
+ Registers the external agent CLIs **already installed on this machine** as DSH
150
+ subagent providers, so a session can hand a self-contained task to one of them —
151
+ each billed against its own subscription:
152
+
153
+ | Tool | Requires | Actually runs |
154
+ |---|---|---|
155
+ | `subagent_codex` | `codex` | `codex exec --skip-git-repo-check <task>` |
156
+ | `subagent_claude_code` | `claude` | `claude -p <task>` |
157
+ | `subagent_antigravity` | `agy` | `agy -p <task> --dangerously-skip-permissions` |
158
+
159
+ **What isn't installed doesn't show up.** A provider is registered only when its
160
+ executable actually resolves, and `dsh-tool-subagent` logs a single info line for
161
+ a missing provider while deferring the tool row until that provider appears. On a
162
+ machine without codex, `subagent_codex` never enters the tool catalog and the host
163
+ still starts normally — no switch, no configuration.
164
+
165
+ Detection goes through `ctx.subprocess.resolveExecutable` (the kernel's own
166
+ resolver, sharing the PATH view the child process will actually get) rather than
167
+ scanning PATH by hand: a login-shell alias fools `command -v` (`agy` is commonly
168
+ aliased with `--dangerously-skip-permissions`), and a launchd-started host never
169
+ reads `.zshrc` at all.
170
+
171
+ ### Pinned edge cases
172
+
173
+ - **`agy` must carry `--dangerously-skip-permissions`**: in headless print mode it
174
+ auto-denies the `command` permission, so any task touching files or commands
175
+ exits 0 with **no output** — no error, the subagent just "succeeds" having done
176
+ nothing.
177
+ - **Exit 0 with empty output always fails**, never folds to `completed` (otherwise
178
+ the parent agent proceeds on an empty answer).
179
+ - **`codex` carries `--skip-git-repo-check`**: the parent cwd is not necessarily a
180
+ git repository, and without the flag codex refuses to run.
181
+ - **Only an 8 KiB stderr tail is kept**: agy's glog bridge emits 300+ lines when its
182
+ log directory is not writable.
183
+ - The child **does not inherit parent context** and advertises no start-time
184
+ capabilities (persona, tool filter, depth cap, structured output cannot be
185
+ enforced inside another runtime), so the kernel rejects requests needing them up
186
+ front instead of silently ignoring them.
187
+
188
+ ### Coexisting with the official bundles and preset rows
189
+
190
+ - The official `@deepseek-ai/dsh-subagent-codex` / `-claude-code` register providers
191
+ under the same names (`codex` / `claude-code`). **First one wins**: this plugin logs
192
+ one info line, skips, and keeps registering the rest. Remove those bundles from the
193
+ profile to let this plugin provide all three.
194
+ - ⚠️ If you hand-added rows like `tool-subagent-codex` in
195
+ `~/.dsh/.agent-presets/*/agent.cordis.yml`, **delete them** — the same `toolName`
196
+ cannot be registered twice.
197
+
198
+ ### Why kernel symbols are imported dynamically
199
+
200
+ `@deepseek-ai/dsh-subagent` / `-session` are supplied by the host (resolved through
201
+ `.dsh-module-fallback` inside a profile) and **cannot be resolved from the repository**.
202
+ A top-level static import would fail both ways: `MODULE_NOT_FOUND` when running the
203
+ repo's tests, and — should a host version ever drop one of those exports — a plugin
204
+ that fails to load entirely, taking balance and model discovery down with it. Lazy
205
+ loading confines that risk to this feature. Detection and registration never touch a
206
+ kernel import at all: the empty capability advertisement is inlined.
207
+
208
+ ## Known limitation
209
+
210
+ **Discovery candidates cannot carry `inputModalities`.** The llm service keeps
211
+ only `id`/`name`/`contextWindow`/`maxTokens`. So a `deepseek-flash` added
212
+ through the button lands as a **text-only** entry, while it actually accepts
213
+ image input. Patch it by hand afterwards:
214
+
215
+ ```yaml
216
+ llm-deepseek:
217
+ models:
218
+ - id: deepseek-flash
219
+ inputModalities: [ text, image ]
220
+ ```
221
+
222
+ This is a limit of the harness's discovery contract itself (the official pi-ai
223
+ route has it too); no plugin can fix it.
224
+
225
+ ## Development
226
+
227
+ ```sh
228
+ npm run check # syntax of both halves
229
+ npm test # regression tests (node:test, zero dependencies)
230
+ npm run deploy # sync into the web profile
231
+ ```
232
+
233
+ Tests live in `test/` and use only `node:test` + `node:assert`; CI runs them. Each case pins a
234
+ **bug that was actually hit** — the availability chain (credential → probe → balance → runtime)
235
+ is long, and degrading any link produces no compile error: it just silently hides a working model
236
+ or leaves a broken one in the dropdown.
237
+
238
+ ### Releasing
239
+
240
+ Pushing a `v*` tag triggers `.github/workflows/publish.yml`: it re-runs syntax + regression
241
+ tests + the publish-artifact check **before** publishing (rather than trusting that some earlier
242
+ CI run passed), then `npm publish`, then creates the GitHub Release from the matching CHANGELOG
243
+ section when one is missing. Requires an `NPM_TOKEN` repository secret.
244
+
245
+ A missed or failed publish can be retried without re-pushing the tag:
246
+
247
+ ```sh
248
+ gh workflow run publish.yml -f tag=v0.7.0
249
+ ```
250
+
251
+ Idempotency comes from two checks — the tag must match `package.json`'s version, and an
252
+ already-published version is skipped. Release existence is probed separately, so "npm succeeded
253
+ but no Release was created" is recoverable by re-running.
254
+
255
+ - **Host half** `lib/index.js`: ESM (the cordis loader reads it as ESM).
256
+ - **Client half** `lib/client.js`: **source is the artifact**, a classic script
257
+ (no top-level `import`/`export`) registered through
258
+ `window.__ModuleLoader__.load({ id, factory })`. Its `id` **must exactly equal
259
+ package.json's `name`**, or DSH refuses the registration. React arrives via
260
+ `factory(require)` and is never bundled into the artifact. At the current size
261
+ no build step is warranted; if it ever splits into several files, add esbuild
262
+ (`format: 'iife'`, with React and friends marked external).
263
+
264
+ ## License
265
+
266
+ MIT