@arcaneorion/dsh-model-channel-manager 0.3.0 → 0.3.8
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/README.md +71 -6
- package/package.json +10 -2
- package/src/client.js +280 -86
- package/src/health-store.js +169 -0
- package/src/index.js +441 -98
package/README.md
CHANGED
|
@@ -31,6 +31,13 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
|
|
|
31
31
|
- `dsh.profile.bundles` 加 `"@arcaneorion/dsh-model-channel-manager"`
|
|
32
32
|
- `pnpm install` 后重启 `dsh --profile web`
|
|
33
33
|
|
|
34
|
+
> **link: 方式的模块解析坑(0.3.8 实测)**:pnpm 对 link: 包不安装其依赖;且 Node ESM
|
|
35
|
+
> import 会把 symlink **realpath 化**——host 从 profile 路径加载插件时,`import 'zod'`
|
|
36
|
+
> 实际从**工作区真实路径**向上解析,工作区没有 node_modules 就报
|
|
37
|
+
> `Cannot find package 'zod'`。解法:工作区 `node_modules/` 里软链宿主侧已有实体
|
|
38
|
+
> (`zod` ← profile 顶层;`@deepseek-ai/dsh-storage-domain` ← pnpm `.pnpm` 实体;
|
|
39
|
+
> `.gitignore` 已含 `node_modules/`)。npm 安装方式(dependencies 正常解析)无此问题。
|
|
40
|
+
|
|
34
41
|
验证:
|
|
35
42
|
- host 日志出现 `[model-channel-manager] booted, groups: ...`
|
|
36
43
|
- `llm.providers` 出现 `roundrobin/<组id>`
|
|
@@ -58,7 +65,7 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
|
|
|
58
65
|
|
|
59
66
|
## 数据通道(全走公共 seam,无私有 RPC)
|
|
60
67
|
|
|
61
|
-
-
|
|
68
|
+
- 读配置/运行态 = `api.settings.describe()` 过滤命名空间
|
|
62
69
|
- 保存 provider = `api.settings.update({ns:'llm-pi-ai', patch:{providers}})`
|
|
63
70
|
- 保存轮询组 = `api.settings.update({ns:'model-channels', patch:{groups}})`
|
|
64
71
|
- ⚡测速 = `api.settings.update({ns:'model-channel-health', patch:{speedRequest:{group,nonce}}})`(host watcher 消费)
|
|
@@ -67,7 +74,28 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
|
|
|
67
74
|
- 上述调用形状仍保留 0.1 的样子:client 半内建门面 `makeLegacyApi` 把 0.2 的**位置参数 + RemoteResult** 适配回旧的**对象入参 + `{result:{ok,value}}`**,并把 `model-channels` / `model-channel-health` 合成回旧命名空间视图(真实承载是本插件行 id `model-channel-manager` 的实例配置)。
|
|
68
75
|
|
|
69
76
|
> **宿主边界(0.1 历史,0.2 已不适用)**:0.1 的 settings RPC 走 apiproxy 暴露白名单(`exposedNamespaces()` = LLM provider ns + `WEB_/PRODUCT_SETTINGS_NAMESPACES`),当时含该边界的宿主必须放行 `model-channels` / `model-channel-health`(本仓曾在 harness `dsh-host-apiproxy` 打 `PLUGIN_SETTINGS_NAMESPACES` 补丁)。
|
|
70
|
-
> **0.2 的 settings 命名空间就是 profile 行 id**,由 `@deepseek-ai/dsh-api-settings-controller` 的 `describe`
|
|
77
|
+
> **0.2 的 settings 命名空间就是 profile 行 id**,由 `@deepseek-ai/dsh-api-settings-controller` 的 `describe` 直接投影本行实例配置,没有该白名单环节;对应地,本插件的配置落在 `~/.dsh/profiles/web/cordis.patch.yml` 的 `model-channel-manager` 行 `config` 下。
|
|
78
|
+
|
|
79
|
+
## 健康数据存储(0.3.1 重构:事实数据归位 storageDomain)
|
|
80
|
+
|
|
81
|
+
> 背景:0.3.0 及之前,健康流水整字段存在 settings health 子树里,每 2s 防抖整段重写
|
|
82
|
+
> profile patch(实测 6350 行中 health 约占 3000 行),且与 volatile 快照覆盖互相踩——
|
|
83
|
+
> 刚记的账在落盘前被旧快照抹掉(审计 F11/F23,「测试成功不入账」的根因)。
|
|
84
|
+
|
|
85
|
+
- **权威存储**:`storageDomain` 的 `model_channel_health` 单元(dsh-base 已组合 json 后端,
|
|
86
|
+
root=`~/.dsh/storages/`),`per-record` 布局——一条渠道一个桶文档,`backup-and-skip`
|
|
87
|
+
容错。host 侧 `src/health-store.js` 封装:追加走原子写链(并发 `recordHealth` 不丢更新)、
|
|
88
|
+
7 天窗口过期、每桶 300 条截断。
|
|
89
|
+
- **settings 只存小投影**:`health.digest`(host 聚合好的摘要数组:total/success/ttft/latency/
|
|
90
|
+
token 三分项/lastTs)+ `digestAt`,client 健康页渲染用;不再下发原始流水。
|
|
91
|
+
- **聚合上移 host**:`buildDigest` 在 host 折叠(口径同旧 client:计费 token = input +
|
|
92
|
+
cacheRead + cacheWrite + output),client 不再拉全量 describe 做原始事件折叠。
|
|
93
|
+
- **存量迁移**:首次启动自动把 settings 里的 `records`/`speedResults` 搬入 domain(桶已存在
|
|
94
|
+
即跳过,幂等),同一次合并写里清空 settings 旧存量并落 `healthMigrated` 标记。
|
|
95
|
+
- **降级**:storageDomain 缺席的 profile 退化为纯内存(不持久化流水),不拒绝启动。
|
|
96
|
+
- **client 兼容**:旧 host(无 digest 字段)自动回落原始 records 路径,升级窗口不断供。
|
|
97
|
+
- **已知近似**:30m/24h 视图按「最近活跃渠道」过滤,数值仍是 7 天累计(UI 已标注);
|
|
98
|
+
精确分窗口需 host 出多份 digest,后续增强。
|
|
71
99
|
|
|
72
100
|
## 响应信封(重要)
|
|
73
101
|
|
|
@@ -113,11 +141,21 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
|
|
|
113
141
|
|
|
114
142
|
供应商卡片头部「改名」按钮可重命名 Provider ID(约束:小写字母开头,仅小写字母/数字/连字符):
|
|
115
143
|
|
|
116
|
-
- 轮询组候选池中引用该 ID 的 candidate 会自动同步为新 ID
|
|
144
|
+
- 轮询组候选池中引用该 ID 的 candidate 会自动同步为新 ID——**主 candidates 与全部 presets 都同步**(0.3.6 修复审计 F17/C05:宿主优先使用 `activePreset.candidates`,漏改它 = 删旧 provider 后组悬空)
|
|
117
145
|
- `apiKeyEnv` 凭据引用**保持不变**——凭据是 write-only 无法搬移,保持引用名原地不动即可让已存储 Key 继续生效
|
|
118
146
|
- 历史健康流水保留在原 ID 名下(历史存档不受影响)
|
|
119
147
|
- 改名后仍需点击右上「保存全部变更」落盘
|
|
120
148
|
|
|
149
|
+
## 组配置保存校验(0.3.6,审计 F16/H08)
|
|
150
|
+
|
|
151
|
+
保存前 client 预检,以下问题**直接拒绝提交**并列出:
|
|
152
|
+
|
|
153
|
+
- 组 ID 非法(仅小写字母/数字/连字符,字母或数字开头)
|
|
154
|
+
- 组 ID 重复
|
|
155
|
+
- 组无可用候选(候选需同时选 Provider 和模型)
|
|
156
|
+
|
|
157
|
+
host 侧兜底:`rewireRoutes` 发现配置组数 > 实际路由数时 `console.warn` 列出被丢弃的组 ID(此前是静默丢弃——H08:3 组保存、路由只有 1 条,界面仍显示「已保存」)。允许清空全部组(空列表合法)。
|
|
158
|
+
|
|
121
159
|
## 密钥写入(凭据引用虚拟化)
|
|
122
160
|
|
|
123
161
|
- 面板主视图只出现「API Key」输入框:**粘贴或输入后失焦即自动写入** DSH 凭据存储(`~/.dsh/.credentials.yaml`,0600,write-only 读不回),无手动按钮;清空输入框不会删除已存 key。上游 llm-pi-ai 的供应商 profile 只有 `apiKeyEnv` 一个密钥字段(凭据引用名),不存在内联 key 的选项——secrets 不进 settings.yaml、不随 `settings.describe` 下发,是有意的安全设计。
|
|
@@ -129,8 +167,9 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
|
|
|
129
167
|
## host 半内部接口
|
|
130
168
|
|
|
131
169
|
- settings 接入用 **`ctx.inject(['settings'], (sctx) => {...})`**(settings 服务异步初始化,apply 时 `ctx.get('settings')` 为 undefined——曾经整个引擎静默失效,命名空间从未注册)
|
|
132
|
-
- 配置 schema(schemastery):`model-channels` 的虚模型/candidates/strategy/timeoutMs/cooldownMs/maxRetriesPerCandidate/speedTest;`model-channel-health` 的
|
|
133
|
-
- 引擎:sticky/round-robin/primary
|
|
170
|
+
- 配置 schema(schemastery):`model-channels` 的虚模型/candidates/strategy/timeoutMs/cooldownMs/maxRetriesPerCandidate/speedTest;`model-channel-health` 的 runtime/speedRequest+lastHandledNonce/testRequest+testResults+lastTestHandledNonce/digest 小投影(records/speedResults 仅作 0.3.1 迁移的读取源,权威在 storageDomain)
|
|
171
|
+
- 引擎:sticky/round-robin/primary 三策略;**round-robin 选择时原子预留**(0.3.5:指针选定即推进,并发请求均分;旧实现成功后才推进,并发全打同一候选——审计 F13/H13);首响应超时 + 流中空闲超时(动态 = max(timeoutMs, min(120s, ttft×2)));单候选原地重试(指数退避)耗尽才换;全炸清冷却重试一轮;组级总预算 `totalBudgetMs`(默认 10 分钟,0.3.3);测速 ttft/latency/hybrid/smart 四键(smart = 0.5×ttft_norm + 0.3×(1−reliability) + 0.2×latency_norm,reliability 贝叶斯平滑 `(success+2.5)/(total+5)`);测速失败进冷却;请求隔离按组
|
|
172
|
+
- 自动测速(0.3.5,审计 F14/H07):`speedTest.enabled` 且组无测速结果时,**首次真实使用触发一次后台测速**(不阻塞请求);此前只有 boot 时的 `onFirstUse` 分支,常规路径无入口——开启开关后从未生效
|
|
134
173
|
- 虚拟模型元数据:`reasoning.efforts` 七档(off…max)、**defaultEffort=max**——原生 `/model` 弹窗对新模型的自动填档与展示跟随该声明;会话内显式档位的跨会话恢复由 selector 插件的档位记忆层负责(`modelDirectories` 拦截,存 `model-channels.effortMemory`)
|
|
135
174
|
- 迁移:startup 时从工作区 `.channel-manager/config.json` 一次性迁入 `model-channels`(无遗留则忽略);完成后写 `legacyMigrated` 哨兵防止「清空组后重启复活」;fs 未就绪时 5s×6 重试
|
|
136
175
|
- 遗留 `.channel-manager/` 目录不再使用
|
|
@@ -144,6 +183,11 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
|
|
|
144
183
|
|
|
145
184
|
## 已知限制
|
|
146
185
|
|
|
186
|
+
- **虚拟模型能力声明与候选实际能力无联动(审计 F08,0.3.4 已做最小切片)**:请求含图
|
|
187
|
+
或带 effort 时,宿主 `resolveModelInfo` 明确声明不支持的候选会被过滤(60s TTL 缓存;
|
|
188
|
+
`inputModalities` 缺失=未知不过滤,保持 failover;全滤退回原列表报真实错误)——
|
|
189
|
+
防住 H14(图片静默替换后假成功)与 H06(effort 强塞被拒)。**仍未做**:虚拟模型声明
|
|
190
|
+
元数据(vision/efforts/窗口)与候选能力的联动聚合,属后续专项
|
|
147
191
|
- 动态超时实现了首响应 + 流中空闲;全炸后「清冷却重试一轮」回溯,未实现「等待最早冷却」的睡眠分支
|
|
148
192
|
- 测速结果不入健康流水(pi 记);smart 键只统计真实请求
|
|
149
193
|
- **Token 字段只在新记录上出现**:host 升级重启前的存量健康记录无 token 字段,7 天视图对重启前的调用会低估 token(请求数/可用率不受影响);数据自重启后开始累积
|
|
@@ -151,6 +195,26 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
|
|
|
151
195
|
- 轮询渠道/健康统计面板需要 host 新代码(重启后生效);健康流水的数据在**实际请求过轮询组**后才出现
|
|
152
196
|
- `reasoningEfforts` 缺失(undefined)的 model 正确渲染(`|| {}` 兜底)
|
|
153
197
|
- 会话模型选择器搜索版已拆出为独立插件 `@arcaneorion/dsh-model-selector-search`(原生座位遮蔽、搜索、向上展开菜单、effort 档位未实现等边界见该仓 README);本插件不再注册任何座位
|
|
198
|
+
- `makeLegacyApi` 0.1 兼容门面仍保留(94 行、11 调用点):拆除要动 6 个功能路径的双层
|
|
199
|
+
信封,待 0.3.2 真实环境验证后再决定
|
|
200
|
+
- 30m/24h 健康视图是近似口径(按最近活跃过滤,数值为 7 天累计,UI 已标注);精确分窗口
|
|
201
|
+
需 host 出多份 digest
|
|
202
|
+
|
|
203
|
+
## 引擎超时与生命周期(0.3.3 重构:审计 F01/F02/F15 已修)
|
|
204
|
+
|
|
205
|
+
- **per-attempt AbortController**:每次候选尝试独立 signal,用户取消转发(`relayAbort`,
|
|
206
|
+
finally 移除防泄漏)+ 超时 abort(`attemptController.abort(raceErr)`)。pi-ai 适配器把
|
|
207
|
+
`options.signal` 经 `AbortSignal.any` 融进 watchdog 并传给上游 HTTP——abort 即真正
|
|
208
|
+
取消网络请求,不再有「return() 排在挂起的 next() 之后拖住 failover」(H02 复现的根因)
|
|
209
|
+
- **统一 finally 有界关闭**:streamAttempt / measureCandidate / runModelTest 三处流消费
|
|
210
|
+
路径,成功 return / 失败 / 消费者提前退出都走 `closeInner`(closed 防重入 + 3s 关闭
|
|
211
|
+
预算 race,预算超时补一发 abort)
|
|
212
|
+
- **组级总预算 `totalBudgetMs`**(默认 10 分钟,组配置可调):超预算不开新尝试,以
|
|
213
|
+
`CHANNEL_BUDGET_EXCEEDED` 终结——旧实现最坏 `2×N×(R+1)` 次尝试(默认 R=2 → 6N)
|
|
214
|
+
- **测速/测试改总时限**(F15):旧实现每 chunk 重置 guard(H12:timeoutMs=40 流每
|
|
215
|
+
20ms 输出,84ms 后仍成功),现在 `deadline` 固定总时限,超时 abort
|
|
216
|
+
- 回归:`tests/timeout-abort.test.cjs`(契约断言 + 挂起流行为级验证——3s 关闭预算内
|
|
217
|
+
完成,不等满 5s 挂起)
|
|
154
218
|
|
|
155
219
|
## 踩坑速记(本项目,按严重程度)
|
|
156
220
|
|
|
@@ -176,5 +240,6 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
|
|
|
176
240
|
20. **诊断临时实例必须独立 home(`DSH_HOME=/tmp/dsh-diag dsh ...`)**:临时实例与主实例共用 `~/.dsh` 会并发写同一会话日志与 `session_projcache.json`——两进程各自的 seq 计数器交错追加,日志出现重复 seq → `corrupt session log: seq gap in committed region` → 会话 resume 直接拒绝,表现为该会话内模型目录加载失败(选择器「暂无可用模型」)。修复:解压 jsonl 删掉多余事件即可(后续 seq 连续则天然对齐),用 `session-persistence-jsonl` 的 `scanLog` 校验后压缩回写;杀进程前务必备份。
|
|
177
241
|
21. **适配器契约以安装运行时的 d.ts 为准,不能照抄源码仓快照**:源码仓较新、rc.2 运行时的 `LlmAdapter` 多一个必需的 `prepareCall(provider, model, signal) → Promise<{model, stream}>`(主分发路径 llm.stream/llm.prepareCall 都先走它再 `adapterCall.stream(options)`;`adapter.stream` 在 rc.2 服务层从不直调)。缺它的症状极具迷惑性:注册/目录/菜单全正常,**真实发对话**才报 `registration.adapter.prepareCall is not a function`。实现对齐 llm-pi-ai 的快照模式:prepare 时捕获一份配置快照,元数据与 dispatch 都出自同一代。回归:`tests/adapter-contract.test.cjs`(T3 直接解析安装版 d.ts 的 LlmAdapter 方法集做契约同步)。
|
|
178
242
|
22. **0.2 配置写入是 HMR 独占事务**:`settings.update` → `configEditor.edit()` → `hmr.runExclusive()`;在 `loader/volatile-update` 回调里回写会抛 `HMR transactions cannot be nested`(实测一段会话内 15 次,面板“测试”结果永远落不了盘)。事务内创建的**任何**异步资源(`AsyncResource` / `setTimeout` / `setInterval`)都继承事务上下文,**只有 `AsyncLocalStorage.exit()` 能切出**:`ctx.get('hmr').executing.exit(fn)`(仅当 `getStore()` 为真时切)。写入会被 `runExclusive` 排进队列、在本次事务结束后执行;**监听器保持同步、不要在外层事务里 await 它**(队列串行,互等即死锁)。
|
|
179
|
-
23. **整字段落盘 +
|
|
243
|
+
23. **整字段落盘 + 内存态被配置快照覆盖**(0.3.1 已根治):`settings.update` 是整字段替换;旧实现 `reloadFromConfig()` 每次 volatile-update 都用配置快照整体覆盖 `state.records`,而健康 flush 有 2s 防抖 → **刚记下的一笔在落盘前就被内存覆盖**(症状:面板“测试”成功不入账,失败反被全局拦截器的 catch 记上)。当时的修法是 `pendingRecords` 缓冲补账;0.3.1 起权威数据搬入 storageDomain(见「健康数据存储」),settings 只存 digest 小投影,此竞态从数据模型层消除。
|
|
180
244
|
24. **nonce 落盘时机与启动竞态**:`lastTestHandledNonce` / `lastHandledNonce` 必须在**得出结果之后**写(提前写会让“未就绪”的重试被自己的持久值挡掉);启动瞬间凭据服务尚未就绪时测试/测速会以 `MISSING_CREDENTIAL` 失败(凭据其实已在 `.credentials.yaml` 里),应识别为「还没就绪」→ 释放认领 + 5s 延时重试(上限 24 次),**不要**写成渠道故障;测速还必须在整组候选都因未就绪失败时**不落盘、不冷却**,否则一次启动重放就把所有渠道误判成故障。
|
|
245
|
+
25. **domain 写入的并发丢失(0.3.1 review 挽救)**:`KvTable.put` 是整 record 覆盖,`get→filter→put` 的读-改-写在 put 的 IO 延迟窗口内并发调用会互相覆盖(后写盖先写,先记的账丢失)——恰好复刻了要消灭的丢账问题。**并发追加必须走原子链**:`update(key, fn)` 的 fn 在写链队列槽位看到当前值;桶不存在时 update 报 `missing-key`,先 put 初始化。另外在 promise 链里 `ctx.effect` 注册 disposer 前必须先验 fiber 活性——对 inactive fiber 注册会抛 `INACTIVE_EFFECT`,若被外层 catch 吞掉则 domain 永不 close,facility 名字被占 → HMR 重载后 `already-open` 静默降级。回归:`tests/health-domain-sync.test.cjs`。
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcaneorion/dsh-model-channel-manager",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test \"tests/*.test.cjs\""
|
|
8
|
+
},
|
|
6
9
|
"exports": {
|
|
7
10
|
".": "./src/index.js",
|
|
8
11
|
"./client": "./src/client.js",
|
|
@@ -33,6 +36,9 @@
|
|
|
33
36
|
"publishConfig": {
|
|
34
37
|
"access": "public"
|
|
35
38
|
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"yaml": "^2.9.0"
|
|
41
|
+
},
|
|
36
42
|
"peerDependencies": {
|
|
37
43
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
38
44
|
"@deepseek-ai/dsh-llm": "0.2.0-rc.1",
|
|
@@ -40,8 +46,10 @@
|
|
|
40
46
|
"@deepseek-ai/dsh-client-connection": "0.2.0-rc.1",
|
|
41
47
|
"@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.1",
|
|
42
48
|
"@deepseek-ai/dsh-api-remotes": "0.2.0-rc.1",
|
|
49
|
+
"@deepseek-ai/dsh-storage-domain": "0.2.0-rc.1",
|
|
43
50
|
"@deepseek-ai/schemastery": ">=3.18.4",
|
|
44
|
-
"react": "^18.3.1"
|
|
51
|
+
"react": "^18.3.1",
|
|
52
|
+
"zod": "^4.4.3"
|
|
45
53
|
},
|
|
46
54
|
"dsh": {
|
|
47
55
|
"bundle": {
|