@arcaneorion/dsh-model-channel-manager 0.3.15 → 0.4.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/README.md +70 -343
- package/package.json +17 -4
- package/src/channel-state.js +196 -0
- package/src/client.js +126 -307
- package/src/health-store.js +11 -20
- package/src/index.js +170 -622
package/README.md
CHANGED
|
@@ -1,378 +1,105 @@
|
|
|
1
1
|
# @arcaneorion/dsh-model-channel-manager
|
|
2
2
|
|
|
3
|
-
DSH
|
|
3
|
+
DSH 的模型渠道管理插件:提供商与模型编辑、轮询故障转移、真实模型测试和健康统计。当前版本 **0.4.0**,针对 DSH **0.2.0-rc.1**。
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
- **client 半** `src/client.js`:`conversation.view` 顶级页签「模型配置」,内含三个子页:**模型配置**(llm-pi-ai providers 全字段编辑、拉取上游、单模型 ⚡ 测试、供应商搜索过滤)、**轮询渠道**(groups 编辑 + ⚡测速 + 输入模态编辑;**命名单一身份:显示名 = 组唯一 ID**,保存时归一化 `virtualModel.name = id`,改 ID 即改名,永不漂移)、**健康统计**(7 天聚合)。
|
|
7
|
-
- **会话模型选择器已拆出**为独立 cordis client 插件 [`@arcaneorion/dsh-model-selector-search`](../model-selector-search/)(一个占座者一个插件单元,可独立启停/替换;座位遮蔽 + 搜索 + 近 7 天置顶 + 菜单向上展开都在该仓)。
|
|
5
|
+
模型选择器是独立插件 `@arcaneorion/dsh-model-selector-search`;本包占用 `conversation.view` 的「模型配置」页签。
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
## 0.4.0 的变化
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
| --- | --- |
|
|
13
|
-
| 自建 127.0.0.1 HTTP 面板 + token | `conversation.view` 页签(client 半,静态 bundle) |
|
|
14
|
-
| `models.json` / `roundrobin/config.json` + 自写原子写/bak | `settings` 服务命名空间 `model-channels`(配置)/ `model-channel-health`(健康+运行态+测试结果) |
|
|
15
|
-
| 轮询 provider(自实现 HTTP 转发) | `ctx.llm.registerAdapter(['roundrobin/<组>'])`,引擎内嵌套 `ctx.llm.stream({provider:候选})` 转发 |
|
|
16
|
-
| 健康 JSONL | `model-channel-health.records`(settings 总线,跨会话共享) |
|
|
17
|
-
| 保存即热重载(自建事件) | settings watcher → 热重建虚拟路由(原生) |
|
|
18
|
-
| 面板模型测试(本地 HTTP 转发) | `model-channel-health.testRequest` 哨 → host 走**真实** `llm.stream` → `testResults[nonce]` 回写 |
|
|
9
|
+
健康统计、运行态、模型测试和测速任务全部迁出 settings。常规请求、健康刷新和测试不会再重写 `cordis.patch.yml`,也不会推动配置 revision。健康页保留原有四张指标卡、供应商分组、模型状态卡和可用率条,30 分钟 / 24 小时 / 7 天改为按事件时间精确筛选。
|
|
19
10
|
|
|
20
|
-
|
|
11
|
+
测试和测速现在有独立任务编号、完成状态和取消按钮。重复提交同一编号只执行一次;宿主重启不会重放未完成任务。保存前核对编辑基线,拒绝覆盖另一页面已经修改的字段。
|
|
21
12
|
|
|
22
|
-
|
|
13
|
+
## 安装与加载
|
|
23
14
|
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
|
|
15
|
+
```sh
|
|
16
|
+
npm install
|
|
17
|
+
npm test
|
|
27
18
|
```
|
|
28
19
|
|
|
29
|
-
|
|
30
|
-
- `dependencies` 加 `"@arcaneorion/dsh-model-channel-manager": "link:/home/arcaneorion/AI/AI-DSH/plugin/model-channel-manager"`
|
|
31
|
-
- `dsh.profile.bundles` 加 `"@arcaneorion/dsh-model-channel-manager"`
|
|
32
|
-
- `pnpm install` 后重启 `dsh --profile web`
|
|
20
|
+
宿主以本地 `link:` 安装时,Node 从插件真实路径解析依赖,因此此目录必须能解析 package.json 中列出的 peer dependencies。不要只依赖 profile 目录的符号链接。宿主提供 React、Settings、Gateway、Connection 和模型运行时,不启动额外 HTTP 端口。
|
|
33
21
|
|
|
34
|
-
|
|
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 正常解析)无此问题。
|
|
22
|
+
DSH profile 挂载本包的 `cordis.patch.yml`。默认实例 id 为 `model-channel-manager`,每个 profile 使用一个实例。升级 host 后刷新浏览器,避免旧页面继续使用已经移除的任务通道。
|
|
40
23
|
|
|
41
|
-
|
|
42
|
-
- host 日志出现 `[model-channel-manager] booted, groups: ...`
|
|
43
|
-
- `llm.providers` 出现 `roundrobin/<组id>`
|
|
44
|
-
- settings describe 含 `model-channels` / `model-channel-health` 命名空间
|
|
24
|
+
## 配置与数据归属
|
|
45
25
|
|
|
46
|
-
|
|
26
|
+
| 数据 | 所属位置 | 写入时机 |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| groups、providerOrder、effortMemory | 本插件的 Config / profile patch | 用户保存配置 |
|
|
29
|
+
| 提供商及模型配置 | `llm-pi-ai` 的 Settings | 用户保存配置 |
|
|
30
|
+
| API Key | DSH credentials | 用户输入并写入凭据 |
|
|
31
|
+
| 健康事件与测速结果 | `model_channel_health` storageDomain | 请求完成 / 测速完成 |
|
|
32
|
+
| 路由运行态与任务结果 | `model_channel_state` storageDomain | 运行态合并写、任务开始和结束 |
|
|
33
|
+
| 历史 health 原文 | `model_channel_state.legacy` | 首次成功迁移时归档 |
|
|
34
|
+
| UI 健康摘要 | 内存派生,通过 Gateway 读取 | 健康页在前台时每 5 秒读取 |
|
|
47
35
|
|
|
48
|
-
|
|
49
|
-
> 原先的 `model-channels` + `model-channel-health` 两个命名空间合并为本行 Config 的
|
|
50
|
-
> `groups` / `providerOrder` / `effortMemory` / `health`)。下面这段 `0.1.1-rc.2` 的记录仅作历史基线参考。
|
|
36
|
+
原始健康记录沿用既有 domain 和版本:每桶最多保留 300 条,最多 7 天。时间窗口统计精确作用于**保留的记录**,不代表无限历史全量统计。延迟均值只统计有有效延迟的成功记录。没有调用时成功率显示「—」,不推断模型在线。
|
|
51
37
|
|
|
52
|
-
|
|
38
|
+
模型测试计入健康统计;测速不计入正常请求统计。输入、输出和缓存 token 按上游 usage 分别累加,不重复计算 reasoning token。
|
|
53
39
|
|
|
54
|
-
|
|
55
|
-
|---|---|---|
|
|
56
|
-
| `@deepseek-ai/dsh-llm` | `0.1.1-rc.2` | `llm.registerAdapter` / `llm.stream`(轮询引擎与健康采集) |
|
|
57
|
-
| `@deepseek-ai/dsh-settings` | `0.1.1-rc.2` | `model-channels` / `model-channel-health` 命名空间读写 |
|
|
58
|
-
| `@deepseek-ai/dsh-client-connection` | `0.1.1-rc.2` | client 半的 `connection.api` 调用 |
|
|
59
|
-
| `@deepseek-ai/dsh-client-ui-conversation` | `0.1.1-rc.2` | `conversation.view` 页签座位 |
|
|
60
|
-
| `@deepseek-ai/cordis` | `^4.0.2` | 插件生命周期 |
|
|
61
|
-
| `@deepseek-ai/schemastery` | `>=3.18.2` | 配置 schema |
|
|
62
|
-
|
|
63
|
-
**换 DSH 版本(例如 `0.1.2-rc.1`)必须先重新验证、再放宽 peer**:宿主服务与座位契约跨版本会变,
|
|
64
|
-
精确钉住的 peer 会在安装时报冲突——这正是它存在的意义,好过装上去静默失效。
|
|
65
|
-
|
|
66
|
-
## 数据通道(全走公共 seam,无私有 RPC)
|
|
67
|
-
|
|
68
|
-
- 读配置/运行态 = `api.settings.describe()` 过滤命名空间
|
|
69
|
-
- 保存 provider = `api.settings.update({ns:'llm-pi-ai', patch:{providers}})`
|
|
70
|
-
- 保存轮询组 = `api.settings.update({ns:'model-channels', patch:{groups}})`
|
|
71
|
-
- ⚡测速 = `api.settings.update({ns:'model-channel-health', patch:{speedRequest:{group,nonce}}})`(host watcher 消费)
|
|
72
|
-
- 单模型测试 = `settings.update({ns:'model-channel-health', patch:{testRequest:{nonce,provider,model,prompt,maxTokens}}})`;host 执行真实 `llm.stream` 后把结果写回 `testResults[nonce]`;client 轮询 describe 直到 ok/error
|
|
73
|
-
- `apiRef` 获取:**0.2 为 `ctx.remote`**(插件级 `inject` 声明 `remote` / `remote.settings` / `remote.credentials` / `remote.llm`,apply 时经 `ctx.inject(['remote'])` 捕获);0.1 的 `ctx.get('connection').api` 已不存在。
|
|
74
|
-
- 上述调用形状仍保留 0.1 的样子:client 半内建门面 `makeLegacyApi` 把 0.2 的**位置参数 + RemoteResult** 适配回旧的**对象入参 + `{result:{ok,value}}`**,并把 `model-channels` / `model-channel-health` 合成回旧命名空间视图(真实承载是本插件行 id `model-channel-manager` 的实例配置)。
|
|
75
|
-
|
|
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` 补丁)。
|
|
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,后续增强。健康页默认 7d,避免近 30m 冷窗让用户
|
|
99
|
-
误以为历史数据丢失。
|
|
100
|
-
- **混部 nonce 兼容**:测试/测速 nonce 使用随机安全整数(host schema 为 number),
|
|
101
|
-
避免 UUID client 遇到未重启旧 host 时被旧版 `typeof nonce === 'number'` 静默忽略;
|
|
102
|
-
新 host 同时兼容旧数字与字符串 nonce。使用 `crypto.getRandomValues`,fallback 为
|
|
103
|
-
时间戳×1000 + 同毫秒计数。
|
|
104
|
-
|
|
105
|
-
## 响应信封(重要)
|
|
106
|
-
|
|
107
|
-
0.1 的 `connection.api.*` 返回 `{result: {ok, value}}` 包裹(`dsh-client-connection` 的 `callUnary` + zod 校验)。
|
|
108
|
-
**0.2 的原生远程调用直接返回 `RemoteResult`(`{ok, value} | {ok:false, error}`)**,不再有 `result` 外层;
|
|
109
|
-
本插件 client 的门面把它重新包回旧形状,因此下面这层解包逻辑在 0.2 上依然成立:
|
|
110
|
-
- 成功:`resp.result.value.{...}`
|
|
111
|
-
- 失败:`resp.result.ok === false`,错误在 `resp.result.error.message`
|
|
112
|
-
- `settings.describe` 的 value = `{writable, hasDocument, namespaces:[{ns, value, base, user, revision, ...}]}`
|
|
113
|
-
- `llm.discoverModels` 的 value = `{models:[{id, name?, contextWindow?, maxTokens?}]}`
|
|
114
|
-
|
|
115
|
-
**不要把 `result.value` 当 `result` 读**——曾因少解一层导致整个面板静默空数据(describe 返回 namespaces 但全面板 0 provider,无任何错误提示)。
|
|
116
|
-
|
|
117
|
-
## Token 用量统计(健康面板)
|
|
118
|
-
|
|
119
|
-
- 健康记录条目在既有字段(ts/provider/model/ok/ttftMs/latencyMs/code)上**增量附带**上游真实 token 用量:`inputTokens` / `outputTokens` / `cacheReadTokens` / `cacheWriteTokens` / `reasoningTokens`——来自适配器在 `finish` 前发出的 `usage` StreamChunk(rc.2 运行时 `StreamChunk` 契约,pi-ai `done`/`error` 事件都带);无 usage 则这些字段不写。
|
|
120
|
-
- **计费口径与 DSH `tokenMeter` 一致**:input + cacheRead + cacheWrite + output(互斥计数,`inputTokens` 不含缓存命中)。面板「总 Token 用量」卡按统计窗口求和,副行显示 输入/输出/缓存 拆分;每模型卡底部显示该模型窗口 Tokens。
|
|
121
|
-
- 采集点与健康记录**同址**(保证 token 与请求数同记录同窗口):全局 `llm/stream` 拦截器 + 轮询引擎 `streamAttempt`(成功/失败/超时路径都尽量携带;pi-ai 的 error 事件同样上报部分 usage)。
|
|
122
|
-
- 测速(⚡测速)/ 单模型测试(⚡测试)消耗的 token **不计入**——与「测速结果不入健康流水」的既有口径一致。
|
|
123
|
-
- 记录 schema 无需改动(`records` 为 `z.array(z.any())`),无新 RPC / settings 字段;client 5s 轮询自动刷新。
|
|
124
|
-
|
|
125
|
-
## 测试通道(模型可用性)
|
|
126
|
-
|
|
127
|
-
- 模型行「⚡测试」→ 弹窗输入自定义问题 + maxTokens → 发送
|
|
128
|
-
- prompt 存 localStorage(`mcm_test_prompt`,pi 同款,全局共用)
|
|
129
|
-
- host 用 `llm.stream({provider, model, messages, maxTokens})` 真实调用(与正式对话同链路);45s 总时限(0.3.13 由 60s 下调,给终态写入留余量)
|
|
130
|
-
- 结果:`status:'ok'`(ttftMs/latencyMs/text)或 `status:'error'`(code/error)
|
|
131
|
-
- 模型行内显示 ⏳→✓/✗ 状态标签(hover 见详情)
|
|
132
|
-
|
|
133
|
-
> 注意:此通道依赖 host 半新代码。**旧 host(未重启)无 testRequest 处理器**,测试会一直「请求中」——client 现在约 66s 后超时,并按现场区分提示:`POLL_TIMEOUT_RUNNING`(host 已收到、仍在执行:渠道慢或上游挂起)或 `POLL_TIMEOUT`(host 未写入结果:多为旧 host 未重启或写入失败)。
|
|
134
|
-
>
|
|
135
|
-
> 0.3.12 起终态判定同时接受 `finishedAt`:即使条目的 `status` 被并发写坏成 `running`,只要带 `finishedAt`/`code`/`error` 就按终态显示真实上游错误,不再误报「host 未处理」。
|
|
136
|
-
|
|
137
|
-
## 任务通道完整性(0.3.12 重构:去重与写入语义)
|
|
138
|
-
|
|
139
|
-
## 任务通道完整性(0.3.12 重构:去重与写入语义)
|
|
140
|
-
|
|
141
|
-
0.3.11 线上症状「等待测试结果超时:host 可能未处理该请求」的两个真实成因,均在 0.3.12 修掉:
|
|
142
|
-
|
|
143
|
-
- **去重不许做数值推断**:`handleTestRequest` / `reloadFromConfig`(测速)改用纯函数
|
|
144
|
-
`shouldConsumeNonce()`(导出,可在测试里直接断言判定表)——严格等值 + 已消费集合 +
|
|
145
|
-
已结算值 + 已有终态结果,四者任一命中即不消费。旧实现 `last = Math.max(lastTestHandledNonce,
|
|
146
|
-
claimedNonce)` 对随机 53-bit nonce 有 ≈50% 失效率(「上一轮 > 本轮」即失效),
|
|
147
|
-
于是同一请求在每次 `loader/volatile-update`(含宿主自己每 5s 的 digest 回写)都被重新消费:
|
|
148
|
-
重复上游请求(真实计费)+ 多次执行结果写进同一条目(线上物证:同 nonce 同时带
|
|
149
|
-
`code TIMEOUT / 60000ms` 与 `ok:true / text`,单次执行不可能)。
|
|
150
|
-
- **health 子树一律叶写**:`writeHealth` 由「读整树 → 合并 → 整树写回」改为对 patch 命中的
|
|
151
|
-
顶层键逐个 `set`(path-ops),单键结果用 `writeHealthLeaf(['testResults', nonce], value)`;
|
|
152
|
-
client 侧 shim 同样不再 `describe` 快照 + 整树回写,直接下推 path-ops。整树回写的 payload 是
|
|
153
|
-
「调用时刻的快照」,晚于并发的终态落盘时会把 `status` 改回 `running`,而 `update` 的深合并
|
|
154
|
-
保留后写入的 `finishedAt/code/error` → 僵尸条目 → 客户端永远等不到终态。
|
|
155
|
-
- client 终态判定加 `finishedAt`(写入后不可抹掉),并把 `ok` 归一成 `status` 显示真实错误。
|
|
156
|
-
- 降级 nonce 路径改用安全整数随机数(旧 `Date.now()*1000+counter ≈ 1.8e18` 超出
|
|
157
|
-
`MAX_SAFE_INTEGER`,同毫秒可撞值;strict consume-once 下撞值 = 测试被静默丢弃)。
|
|
40
|
+
任务数据按 profile 路径和实例 id 隔离。完成任务最多保留 100 条,进行中的任务不被裁剪;幂等保证适用于仍被保留的任务编号。不同参数复用同一编号会返回 `TASK_CONFLICT`。单个实例同时最多执行 4 项任务,同一个组最多一项显式测速。
|
|
158
41
|
|
|
159
|
-
|
|
42
|
+
没有 storageDomain 时仍能使用路由、测试和面板,页面会显示内存模式;新增数据不能跨重启保留。存储出错会显示错误,不会删除尚未成功迁移的旧配置。任务预留写入失败时不会调用模型;终态写入失败时保留当前进程内的实际结果并返回 `persistenceError`。
|
|
160
43
|
|
|
161
|
-
##
|
|
44
|
+
## 升级迁移
|
|
162
45
|
|
|
163
|
-
|
|
46
|
+
0.3.x 的 `config.health` 仅作为迁移输入,已从可编辑 Settings 表单隐藏:
|
|
164
47
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
- 勾选说明文案:「勾选=保留/添加,取消勾选=清理。已配置项默认勾选,取消勾选会被删除。」
|
|
48
|
+
1. 打开原健康存储和新的状态存储。
|
|
49
|
+
2. 归档旧 health 原文,合并历史事件和测速结果,保存运行态与测试结果。
|
|
50
|
+
3. 已完成结果保持终态;历史未完成任务标记 `ABORTED`,不重新调用上游。
|
|
51
|
+
4. 全部成功后,使用 ConfigEditor 一次性删除旧 health。若迁移期间有人改动旧 health,保留配置并显示提示。
|
|
170
52
|
|
|
171
|
-
|
|
53
|
+
这次删除可能触发一次插件重载;之后不再有健康数据的配置写入。迁移幂等,不因旧 `healthMigrated` 标记存在而跳过尚未导入的数据。旧原型 `.channel-manager/config.json` 不再被自动导入,避免用户清空组后旧配置复活。
|
|
172
54
|
|
|
173
|
-
|
|
55
|
+
## 运行时接口
|
|
174
56
|
|
|
175
|
-
|
|
176
|
-
- `apiKeyEnv` 凭据引用**保持不变**——凭据是 write-only 无法搬移,保持引用名原地不动即可让已存储 Key 继续生效
|
|
177
|
-
- 历史健康流水保留在原 ID 名下(历史存档不受影响)
|
|
178
|
-
- 改名后仍需点击右上「保存全部变更」落盘
|
|
57
|
+
宿主提供 `modelChannels` Source Remote,客户端通过现有 Connection / Gateway 调用:
|
|
179
58
|
|
|
180
|
-
|
|
59
|
+
```js
|
|
60
|
+
const result = await ctx.connection.rpc.call(
|
|
61
|
+
'/api', 'modelChannels/invoke',
|
|
62
|
+
{ args: { method: 'snapshot', payload: {} } }, signal,
|
|
63
|
+
)
|
|
64
|
+
```
|
|
181
65
|
|
|
182
|
-
|
|
66
|
+
所有请求经过 DSH 的认证与响应信封。返回 `{ ok: true, value }` 或 `{ ok: false, error }`;客户端必须检查 `ok`。
|
|
183
67
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
68
|
+
| method | payload | value |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| snapshot | `{}` | 三个时间窗口的摘要、测速结果、路由运行态、存储状态 |
|
|
71
|
+
| test | `{ nonce, provider, model, prompt?, maxTokens? }` | 已预留的任务 |
|
|
72
|
+
| speed | `{ nonce, group }` | 已预留的测速任务 |
|
|
73
|
+
| task | `{ id }` | 当前任务状态和结果 |
|
|
74
|
+
| cancel | `{ id }` | 当前任务,取消状态随后通过 task 确认 |
|
|
187
75
|
|
|
188
|
-
|
|
76
|
+
nonce 接受安全正整数或最多 128 字符的字母数字、下划线、点、连字符字符串。prompt 最多 16000 字符,maxTokens 为 1–8192。模型测试总时限 45 秒;单候选测速使用组里的 timeoutMs。用户取消会中止上游并结束任务,页面卸载会停止该页面的轮询。
|
|
189
77
|
|
|
190
|
-
##
|
|
78
|
+
## 轮询路由
|
|
191
79
|
|
|
192
|
-
|
|
80
|
+
每个组注册 `roundrobin/<组id>`,支持 sticky、round-robin 和 primary 策略。组 id 同时是虚拟模型的显示名;组内候选只能引用真实渠道。
|
|
193
81
|
|
|
194
|
-
|
|
82
|
+
轮询选择时原子推进指针;单候选重试耗尽后切换候选。首响应和流中空闲均有超时,组级 `totalBudgetMs` 默认 10 分钟。测速支持 ttft、latency、hybrid 和 smart 排序;启用自动测速且无历史结果时,首次真实使用触发一次后台测速。
|
|
195
83
|
|
|
196
|
-
|
|
197
|
-
raw = JSON.stringify([fiber.uid, schema.toJSON(), entry.options.config ?? {}])
|
|
198
|
-
revision = previous.revision + Number(previous.raw !== raw)
|
|
199
|
-
```
|
|
84
|
+
提供商重命名同步轮询组及其 presets 中的引用,保留凭据引用名。历史健康数据继续归属原供应商 id。
|
|
200
85
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
**修法**:
|
|
204
|
-
|
|
205
|
-
- client:写入前**重新读取 revision**(消除过期);仍冲突时比较远端值与我们加载时的基线
|
|
206
|
-
(`groups`+`providerOrder` / `providers`,用键序无关的规范 JSON 比较)——
|
|
207
|
-
**远端没变 = 自噪声 → 用新 revision 重试(最多 3 次);远端真的变了 → 才报冲突**。
|
|
208
|
-
既消除误报,也保留真正的多页面冲突保护(审计 F05 的语义)。
|
|
209
|
-
- host:`digest` 内容未变时不写(60s 心跳保底),从源头减少 revision 噪声。
|
|
210
|
-
|
|
211
|
-
回归:`tests/revision-conflict.test.cjs`。
|
|
212
|
-
|
|
213
|
-
> 真正的根治是把健康/任务通道搬出 settings 行(审计建议的 Remote + storageDomain 路线)——
|
|
214
|
-
> 那时本行 revision 只随用户配置变化,连重试都不需要。当前修法在不改通信架构的前提下
|
|
215
|
-
> 同时消除了症状与误报。
|
|
216
|
-
|
|
217
|
-
## 密钥写入(凭据引用虚拟化)
|
|
218
|
-
|
|
219
|
-
- 面板主视图只出现「API Key」输入框:**粘贴或输入后失焦即自动写入** DSH 凭据存储(`~/.dsh/.credentials.yaml`,0600,write-only 读不回),无手动按钮;清空输入框不会删除已存 key。上游 llm-pi-ai 的供应商 profile 只有 `apiKeyEnv` 一个密钥字段(凭据引用名),不存在内联 key 的选项——secrets 不进 settings.yaml、不随 `settings.describe` 下发,是有意的安全设计。
|
|
220
|
-
- 「API Key 环境变量名」已收进供应商高级选项、更名「凭据引用名 (apiKeyEnv)」:新增供应商时自动生成(`normalizeCredentialRef`),并对 **ID + 引用双重去重**——改名供应商会保留旧引用(write-only 无法搬移),只按 ID 去重会复活 `provider-1` 并继承已被占用的 `PROVIDER_1_API_KEY`(两个供应商同引用 = 共用同一把 key,写入互相覆盖)。此坑已由双重去重修复,存量撞引用靠 ⚠ 警示提示手动处理(改其中一个引用 → 重新写入)。
|
|
221
|
-
- 环境优先级:启动 shell 同名变量(只读、优先)> 存储的 key > 项目 `.env` > 用户 `.env`(credentials-local 分层);想用环境注入直接在启动环境 export 即可。
|
|
222
|
-
|
|
223
|
-
**曾踩坑**:`selected` 曾初始化为 `missing`(只含"可加"),而 configured 项 checkbox 显示 `checked:true` 却不在 selected 里——应用时 `kept = models.filter(m => cs.has(m.id))` 把已配置模型全部丢弃 → **已有模型消失**。修复 = selected 初始化为 `configured ∩ 端点`。
|
|
224
|
-
|
|
225
|
-
## host 半内部接口
|
|
226
|
-
|
|
227
|
-
- settings 接入用 **`ctx.inject(['settings'], (sctx) => {...})`**(settings 服务异步初始化,apply 时 `ctx.get('settings')` 为 undefined——曾经整个引擎静默失效,命名空间从未注册)
|
|
228
|
-
- 配置 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)
|
|
229
|
-
- 引擎: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)`);测速失败进冷却;请求隔离按组
|
|
230
|
-
- 自动测速(0.3.5,审计 F14/H07):`speedTest.enabled` 且组无测速结果时,**首次真实使用触发一次后台测速**(不阻塞请求);此前只有 boot 时的 `onFirstUse` 分支,常规路径无入口——开启开关后从未生效
|
|
231
|
-
- 虚拟模型元数据:`reasoning.efforts` 七档(off…max)、**defaultEffort=max**——原生 `/model` 弹窗对新模型的自动填档与展示跟随该声明;会话内显式档位的跨会话恢复由 selector 插件的档位记忆层负责(`modelDirectories` 拦截,存 `model-channels.effortMemory`)
|
|
232
|
-
- 迁移:startup 时从工作区 `.channel-manager/config.json` 一次性迁入 `model-channels`(无遗留则忽略);完成后写 `legacyMigrated` 哨兵防止「清空组后重启复活」;fs 未就绪时 5s×6 重试
|
|
233
|
-
- 遗留 `.channel-manager/` 目录不再使用
|
|
234
|
-
|
|
235
|
-
## 客户端装载协议
|
|
236
|
-
|
|
237
|
-
`window.__ModuleLoader__.load({ id: '@arcaneorion/dsh-model-channel-manager', factory: (require) => ({ name, inject:['slots','connection'], apply }) })`;
|
|
238
|
-
react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {inject:['slots','connection'], platform:'web'}`(与 client.js 返回的 inject 一致)+ `exports['./client']` 使 client-modules 自动扫描挂载。
|
|
239
|
-
|
|
240
|
-
**client bundle 按内容 hash 服务且 `no-cache`**:改 client.js 后**刷新浏览器即可生效**,无需重启 DSH。host 改动才需重启。
|
|
241
|
-
|
|
242
|
-
## 已知限制
|
|
243
|
-
|
|
244
|
-
- **虚拟模型能力声明与候选实际能力无联动(审计 F08,0.3.4 已做最小切片)**:请求含图
|
|
245
|
-
或带 effort 时,宿主 `resolveModelInfo` 明确声明不支持的候选会被过滤(60s TTL 缓存;
|
|
246
|
-
`inputModalities` 缺失=未知不过滤,保持 failover;全滤退回原列表报真实错误)——
|
|
247
|
-
防住 H14(图片静默替换后假成功)与 H06(effort 强塞被拒)。**仍未做**:虚拟模型声明
|
|
248
|
-
元数据(vision/efforts/窗口)与候选能力的联动聚合,属后续专项
|
|
249
|
-
- 动态超时实现了首响应 + 流中空闲;全炸后「清冷却重试一轮」回溯,未实现「等待最早冷却」的睡眠分支
|
|
250
|
-
- 测速结果不入健康流水(pi 记);smart 键只统计真实请求
|
|
251
|
-
- **Token 字段只在新记录上出现**:host 升级重启前的存量健康记录无 token 字段,7 天视图对重启前的调用会低估 token(请求数/可用率不受影响);数据自重启后开始累积
|
|
252
|
-
- 配置里 provider 必须非虚拟路由(防自引用)
|
|
253
|
-
- 轮询渠道/健康统计面板需要 host 新代码(重启后生效);健康流水的数据在**实际请求过轮询组**后才出现
|
|
254
|
-
- `reasoningEfforts` 缺失(undefined)的 model 正确渲染(`|| {}` 兜底)
|
|
255
|
-
- 会话模型选择器搜索版已拆出为独立插件 `@arcaneorion/dsh-model-selector-search`(原生座位遮蔽、搜索、向上展开菜单、effort 档位未实现等边界见该仓 README);本插件不再注册任何座位
|
|
256
|
-
- `makeLegacyApi` 0.1 兼容门面仍保留(94 行、11 调用点):拆除要动 6 个功能路径的双层
|
|
257
|
-
信封,待 0.3.2 真实环境验证后再决定
|
|
258
|
-
- 30m/24h 健康视图是近似口径(按最近活跃过滤,数值为 7 天累计,UI 已标注);精确分窗口
|
|
259
|
-
需 host 出多份 digest
|
|
260
|
-
|
|
261
|
-
### 0.3.13:2026-10-01 审计剩余项(已修)
|
|
262
|
-
|
|
263
|
-
同一份只读审计(13 项)里,0.3.12 修了最要命的两条(去重 guard、整树回写),0.3.13 清掉其余:
|
|
264
|
-
|
|
265
|
-
- **R3 remount/dispose 打断在飞测试**:`timed()` 守卫不再只在 dispose 时 `clearTimeout`——
|
|
266
|
-
那样 promise 永不 settle,`await Promise.race([inner.next(), guard.promise])` 直接挂死,
|
|
267
|
-
在飞测试既不结束也不写终态。现在 dispose 会主动 reject `ABORTED`,调用方走正常失败路径。
|
|
268
|
-
另外**启动时清扫**:新进程里任何 `status: running` 且无 `finishedAt` 的条目都是上次遗留的,
|
|
269
|
-
补写 `ABORTED` 终态;`running + finishedAt` 的僵尸条目按 `ok` 归一状态位
|
|
270
|
-
(纯函数 `selectStaleTestEntries` 导出,回归见 `tests/runtime-hardening.test.cjs`)。
|
|
271
|
-
- **R2 volatile 提交静默失败(放大器)**:请求存在但既未认领也未结算时,
|
|
272
|
-
① `handleTestRequest` 显式打日志(每个 nonce 一次,不再静默忽略);
|
|
273
|
-
② 新增 5s 兜底轮询 `sweepPendingTestRequest`,复用同一个消费判定,主动重试一次并留日志;
|
|
274
|
-
③ `reloadFromConfig` 检测「health 子树运行时从有到无」并大声告警——这是 0.3.11
|
|
275
|
-
schema 兜底事故的形态,以前完全无声。
|
|
276
|
-
- **#7 预算错配**:host 测试总时限 60s → 45s(client 轮询上限 ≈66s,旧值只留 6s 余量);
|
|
277
|
-
client 放弃时还会比对 `lastTestHandledNonce`,区分第三种情况
|
|
278
|
-
`POLL_TIMEOUT_SETTLED`(host 已结算但结果条目未写入)。
|
|
279
|
-
- **#6 notReady 退避**:释放认领后设 5s 退避窗口(测试/测速各自),期间任何
|
|
280
|
-
volatile-update 都不再消费——旧实现释放后会被立刻再消费,并发起多个 45s 上游请求。
|
|
281
|
-
- **#13 迁移直写**:`migrateLegacyConfig` 改走 `bus.writeConfig`(`outsideTransaction` 包裹),
|
|
282
|
-
不再在 HMR 事务内直写(旧写法会抛 "cannot be nested" 并被空 catch 吞掉,只剩哨兵)。
|
|
283
|
-
- **#11 digest 遮蔽**:`digest: []` 且 settings 仍有 `records` 时(domain 打开成功但迁移失败、
|
|
284
|
-
或 host 刚重启尚未投影)回落到 records 路径,页面显示旧流水而不是全 0。
|
|
285
|
-
- **#10 health-store**:事件去重键由 `ts|provider|model` 扩成含 ok/code/延迟/token 的指纹
|
|
286
|
-
(同毫秒同渠道的两笔真实请求不再被当重复合并);`putSpeedRows` 与 `migrateFrom` 的测速写入
|
|
287
|
-
并入同一条写链(不与事件追加交错);`close()` 先排干写链再关 domain(不丢排队中的最后一笔)。
|
|
288
|
-
|
|
289
|
-
**仍然未修(有意保留)**:health 子树写入不带 `expectedRevision`。叶写后危害已大幅降低
|
|
290
|
-
(不同叶子互不覆盖),同一叶子的并发写本身是「后写者胜」的语义,加 revision 需要调用方
|
|
291
|
-
持有并维护 revision,收益不抵复杂度;配置域(groups/providerOrder)仍然带 revision。
|
|
292
|
-
|
|
293
|
-
## 引擎超时与生命周期(0.3.3 重构:审计 F01/F02/F15 已修)
|
|
294
|
-
|
|
295
|
-
- **per-attempt AbortController**:每次候选尝试独立 signal,用户取消转发(`relayAbort`,
|
|
296
|
-
finally 移除防泄漏)+ 超时 abort(`attemptController.abort(raceErr)`)。pi-ai 适配器把
|
|
297
|
-
`options.signal` 经 `AbortSignal.any` 融进 watchdog 并传给上游 HTTP——abort 即真正
|
|
298
|
-
取消网络请求,不再有「return() 排在挂起的 next() 之后拖住 failover」(H02 复现的根因)
|
|
299
|
-
- **统一 finally 有界关闭**:streamAttempt / measureCandidate / runModelTest 三处流消费
|
|
300
|
-
路径,成功 return / 失败 / 消费者提前退出都走 `closeInner`(closed 防重入 + 3s 关闭
|
|
301
|
-
预算 race,预算超时补一发 abort)
|
|
302
|
-
- **组级总预算 `totalBudgetMs`**(默认 10 分钟,组配置可调):超预算不开新尝试,以
|
|
303
|
-
`CHANNEL_BUDGET_EXCEEDED` 终结——旧实现最坏 `2×N×(R+1)` 次尝试(默认 R=2 → 6N)
|
|
304
|
-
- **测速/测试改总时限**(F15):旧实现每 chunk 重置 guard(H12:timeoutMs=40 流每
|
|
305
|
-
20ms 输出,84ms 后仍成功),现在 `deadline` 固定总时限,超时 abort
|
|
306
|
-
- 回归:`tests/timeout-abort.test.cjs`(契约断言 + 挂起流行为级验证——3s 关闭预算内
|
|
307
|
-
完成,不等满 5s 挂起)
|
|
308
|
-
|
|
309
|
-
## 引擎集成测试(0.3.15:真正跑一遍 apply → adapter → 引擎)
|
|
310
|
-
|
|
311
|
-
此前所有引擎测试都是**源级断言或逻辑复刻**,抓不到「作用域/引用」类缺陷:0.3.5 的 F13
|
|
312
|
-
重构把 `let cursor = …` 改成两个分支内赋值却丢了声明,ESM 严格模式下每次走虚拟路由都抛
|
|
313
|
-
`ReferenceError: cursor is not defined`(用户看到的「本轮运行失败」),而当时 32 个测试全绿。
|
|
314
|
-
|
|
315
|
-
`tests/engine-integration.test.cjs` 用最小 Cordis ctx 替身(只实现插件用到的 8 个面,
|
|
316
|
-
`ctx.timeout` 按 `cordis-plugin-timer` 的真实双形态实现)真正执行:
|
|
86
|
+
## 配置保存
|
|
317
87
|
|
|
88
|
+
读取与写入使用实际实例 id;提供商写入直接发送给 `llm-pi-ai`。保存前比较远端值与当前编辑基线,只有未被别人改过才使用最新 revision 提交;并发发生在提交窗口时由 Settings 冲突检查兜底。
|
|
89
|
+
|
|
90
|
+
两个配置实例没有跨实例事务。部分保存会明确显示哪部分成功,哪部分失败。点击「重新加载配置」会用宿主当前值重新建立草稿,请先确认不再需要未保存的编辑。
|
|
91
|
+
|
|
92
|
+
## 验证与框架修复
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
npm test
|
|
96
|
+
node scripts/verify-framework.cjs /path/to/deepseek-harness/vendor/loader/src/index.ts
|
|
97
|
+
node scripts/apply-framework-fix.cjs # 仅检查适用性
|
|
98
|
+
node scripts/apply-framework-fix.cjs --apply # 显式本地修复并执行回归,失败自动还原
|
|
318
99
|
```
|
|
319
|
-
apply(ctx, Config({...})) → 捕获 llm.registerAdapter 的 adapter
|
|
320
|
-
→ adapter.stream({provider: 'roundrobin/<组>'}) → 断言真实 llm.stream 调用序列
|
|
321
|
-
```
|
|
322
100
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
找出所有「赋值给从未声明标识符」的目标(F18 的 `idx`、这次的 `cursor` 都是这一类)。
|
|
329
|
-
这是文件级 no-undef(比词法作用域宽松,宁可漏报不误报),恰好覆盖最危险的形态。
|
|
330
|
-
|
|
331
|
-
> 顺带观察(非缺陷):`speedTest.enabled` 默认开启,所以**组内第一次请求会后台触发一次
|
|
332
|
-
> 全候选测速**(每个候选一次真实请求)。UI 文案是「开启自动测速」,语义一致;不想付这份
|
|
333
|
-
> 额度就在组设置里取消勾选。
|
|
334
|
-
|
|
335
|
-
## 踩坑速记(本项目,按严重程度)
|
|
336
|
-
|
|
337
|
-
1. **settings 服务异步初始化**:host 插件 `ctx.get('settings')` 在 apply 时为 undefined → 整个引擎静默不生效(无报错、无命名空间)。必须 `ctx.inject(['settings'], ...)`。
|
|
338
|
-
2. **响应信封少解一层**:`{result:{ok,value}}` 只解到 `result` 找不到 `namespaces`/`models` → 面板静默空。解包函数校验 `ok === false` 抛错(否则失败也显示成功)。
|
|
339
|
-
3. **client bundle 缓存感知**:静态 client 修改后刷新页面即可;不要因为"面板没更新"而重启 DSH——先 F5。
|
|
340
|
-
4. **React.createElement 括号地狱**:大元素树用辅助函数 + 中间变量 + 数组 children;`node --check`/acorn 只能保证语法,**无法确保 return 在函数体内**——曾把 return 行整行删进函数体外(`cards is not defined`,页面白屏 "Failed to load plugins")。改完后用真实浏览器验证。
|
|
341
|
-
5. **`connection` 注入**:static client 必须 `inject:['connection']` 并在 apply 捕获 `ctx.get('connection').api`;在渲染组件里 `ctx.get('connection')` 拿不到(renderer 只收 standardProps)。动态插件 client 没有 `connection` 服务(动态 catalog 里没有)。
|
|
342
|
-
6. **动态 vs 静态重复注册**:动态 `chm-3` 与静态包都注册 `conversation.view` id `models` 会出两个同名页签;静态化后停掉动态插件。
|
|
343
|
-
7. **profile bundles 变更需 pnpm install**:改 `profiles/web/package.json` 的 dependencies/bundles 后必须 `pnpm install` + 重启(symlink 需重建)。
|
|
344
|
-
8. **主实例 vs 临时实例**:诊断 host 问题时用 `dsh --profile web --no-open --port 3081` 起临时实例读日志/settings;主实例 3080 是用户进程,改动 host 后**必须用户重启**。
|
|
345
|
-
9. **中流失败不可故障转移**:候选已向下游输出内容后失败(终止块报错/流中超时),继续切候选会「finish 后又有内容 + 双 finish」并拼接两个模型输出。正确做法:失败终止块不下发,标 `emitted` 上抛,组层以 `CHANNEL_MIDSTREAM_FAIL` 直接终结。
|
|
346
|
-
10. **testResults 读写走已提交值**:watcher 同步的 state 快照滞后于 settings 写队列,连续测试会互相覆盖结果 → client 无限轮询。读写统一 `healthScope.get()`,client 轮询加 55 次上限。
|
|
347
|
-
11. **apiproxy settings 暴露白名单**:新宿主只放行 LLM provider ns + 静态白名单,插件自建 ns 被 describe 过滤/写入 `settings-not-exposed`——升级宿主前先打 `PLUGIN_SETTINGS_NAMESPACES` 补丁(见「数据通道」)。
|
|
348
|
-
12. **新增项命名 N+1 撞键**:`Object.keys().length + 1` 在删除中间项后撞已有键(provider 覆盖草稿、group 被 host seen-set 静默去重消失)。用 `uniqueSuffixName` 取第一个未占用后缀。
|
|
349
|
-
13. **超时 guard 必须 finally dispose**:`ctx.effect` 注册条目只有显式 disposer 才移除;`Promise.race` 超时路径跳过后面的 `guard.dispose()` 会按超时次数泄漏。race 包 try/finally。
|
|
350
|
-
14. **引擎提前终止的内层流拦截器记不到账**:全局拦截器只有流被完整排水才写记录;引擎超时关闭/收到终止块即停的请求要在 `streamAttempt` 侧自行 `recordHealth`,否则轮询组流量几乎不进健康统计。
|
|
351
|
-
15. **拖拽顺序不能依赖 map 键序落盘**:`@deepseek-ai/dsh-settings-file` 落盘是注释保留型叶子 diff(`patchNode`),对 map 键序是盲的——纯重排(值不变)在文件层是零 diff,`setIn` 对已存在键原地替换不挪位,新键只 append。settings 服务的内存 user 层顺序确实变了(运行中一切正常),但文件永远是创建时序,重启即还原。修复:**顺序存成数组数据**——`model-channels` ns 里 `providerOrder: [...]` 字段(数组走 wholesale replace 真实落盘),client 加载时按它重排渲染,未列出的 provider append 在后。llm-pi-ai 的 mutate 照旧(当次会话内存序即刻生效)。注:原生 Models 页本无拖拽交互,其顺序由 directory 决定(catalog 内置序 + settings 键序拼接)恒定;要原生排序持久需上游修 patchNode。回归测试见 `tests/provider-order-persistence.test.cjs`。
|
|
352
|
-
16. **save() 白名单重建会真删面板外字段**:mutate 是 unset+set 真删不是 merge;从零构建只带面板认识的字段,手工配置的 thinkingBudgets/retryPolicy/modelOverrides/defaultInput 任何一次保存(含只改轮询组)都被整批静默删除。修复:pObj 基底 `{...pVal}` 浅拷贝再覆盖面板字段,空值靠覆盖后删键而非忽略。模型对象同理。
|
|
353
|
-
17. **Compat 字段面必须以安装运行时为准(rc.2 共 20 项全部生效)——「仅两项生效」的错误结论导致保存剥字段**:源码仓快照的 PiAiCompatProfile 只暴露 thinkingFormat + supportsReasoningEffort,照此写白名单净化后,用户配置的角色模板类字段(thinkingFormat:chat-template / qwen-chat-template、chatTemplateKwargs、requiresThinkingAsText、supportsDeveloperRole 等)每次保存被静默剥掉,上游报 400「角色信息不正确」(Ark code 1214)。rc.2 实际 offer 20 个字段(含 chat-template 两种格式、chatTemplateKwargs、maxTokensField/cacheControlFormat 枚举等),schema 全部接受。修复:cleanCompat 改全量透传(仅剔空串/null 与非法枚举),compatEditor 按协议渲染全部字段(布尔用三态 select 表达「未设置」)。教训与 #21 同源:对照安装运行时 d.ts,不要照抄源码仓快照。回归:tests/compat-passthrough.test.cjs。
|
|
354
|
-
18. **guard 不能 cap 到 30s**:streamAttempt 的超时 guard 曾用 `Math.min(remaining, 30000)`,timeoutMs>30s 与动态超时 min(120s, ttft×2) 在 >30s 区间全部退化为 30s 切候选。guard 必须覆盖全量 remaining。另:用户主动 abort 不进健康流水(isAbortLike 三形态 + 终止块 ABORTED 跳过),否则污染成功率与 smart 键 reliability。
|
|
355
|
-
19. **single slot 换占必须传负 priority**:`conversation.input.model` 是单占位 seat,cell = slot 本身;原生无 priority(= 0),插件同名注册同不传 → **exact-priority 撞格直接抛错**(「already has a registration at priority 0」→ apply 失败 → 整个插件含模型配置页签加载失败,面板全白)。规则:同 cell 多 entry 按 priority **升序、数值最小者渲染**,遮蔽原生传 `priority: -1`。注意 slot-catalog 的「Do NOT pass priority」只适用于**动态包**(guard 自动分配);静态 bundle 必须自己传。另:mock 验证 slots.register 不会暴露 occupancy 检查(mock 不抛)——验证座位替换必须复刻真实 SlotCore 撞格语义。选择器拆出后,回归测试随代码迁至 `../model-selector-search/tests/slot-priority.test.cjs`。
|
|
356
|
-
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` 校验后压缩回写;杀进程前务必备份。
|
|
357
|
-
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 方法集做契约同步)。
|
|
358
|
-
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 它**(队列串行,互等即死锁)。
|
|
359
|
-
23. **整字段落盘 + 内存态被配置快照覆盖**(0.3.1 已根治):`settings.update` 是整字段替换;旧实现 `reloadFromConfig()` 每次 volatile-update 都用配置快照整体覆盖 `state.records`,而健康 flush 有 2s 防抖 → **刚记下的一笔在落盘前就被内存覆盖**(症状:面板“测试”成功不入账,失败反被全局拦截器的 catch 记上)。当时的修法是 `pendingRecords` 缓冲补账;0.3.1 起权威数据搬入 storageDomain(见「健康数据存储」),settings 只存 digest 小投影,此竞态从数据模型层消除。
|
|
360
|
-
24. **nonce 落盘时机与启动竞态**:`lastTestHandledNonce` / `lastHandledNonce` 必须在**得出结果之后**写(提前写会让“未就绪”的重试被自己的持久值挡掉);启动瞬间凭据服务尚未就绪时测试/测速会以 `MISSING_CREDENTIAL` 失败(凭据其实已在 `.credentials.yaml` 里),应识别为「还没就绪」→ 释放认领 + 5s 延时重试(上限 24 次),**不要**写成渠道故障;测速还必须在整组候选都因未就绪失败时**不落盘、不冷却**,否则一次启动重放就把所有渠道误判成故障。
|
|
361
|
-
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`。
|
|
362
|
-
26. **`ctx.get(name)` 是宽松读,不代表可以访问服务(0.3.10 线上事故)**:Cordis 的服务属性访问经 Proxy 校验 inject——`ctx.get('storageDomain')` 直接查 store 返回裸服务(不校验 state/inject),但拿着这个未声明的 ctx 做 `ctx.storageDomain.open(...)` 会抛 `cannot get property "storageDomain" without inject`;若被 promise 链的 catch 吞掉,表现为「插件启动正常、domain 永远不打开、健康流水静默降级为内存」。**可选服务必须用响应式 inject**:`ctx.inject(['storageDomain'], (dctx) => { ... dctx.storageDomain ... })`——回调只在服务可用(state=2)时触发,缺席时插件照常工作;同时把插件主启动(`reloadFromConfig` + `boot`)放在 inject **之外**先跑,避免服务缺席时路由不注册。回归:`tests/domain-wiring.test.cjs`。
|
|
363
|
-
27. **`settings.update` 的深合并清不掉旧键(0.3.10 修)**:`update` 走 `mergeLayers`,`{records: {}}` 覆盖已有对象是**深合并**——旧键原样保留。0.3.8 的迁移「清空 settings 旧流水」实际没删,`records` 一直留在 profile 里,每次 `describe`(client 5s 轮询全量命名空间)都要带着它。要真删除必须走 path-ops:`settings.mutate(ns, [{op:'unset', path:['health','records']}, ...])`(`isVolatilePath` 对 volatile 子树的后代返回 true,允许操作)。回归:`tests/domain-wiring.test.cjs`。
|
|
364
|
-
28. **schema 类型漂移会静默清空整个 volatile 子树(0.3.11 线上事故,自己埋的)**:0.3.2 把 client nonce 改成 UUID 字符串、host 消费代码也兼容了字符串,**但没同步改 schema**——`lastTestHandledNonce: z.number()`。字符串一落盘,`HEALTH_SCHEMA` 解析时子字段抛错;而它是 `.loose(true)`,宽松兜底把**整个 health 子树换成默认空对象**(实测 `healthOf()` 返回 `{}`,不报错、不告警)。后果:`records`/`digest` 在运行时全部消失 → 存量迁移遍历空对象(什么都不导入)→ 清理逻辑以为「无残留」(旧键删不掉)→ 表现为「健康页没有历史数据」且旧 `records` 永久占据 profile。三条教训:① **写侧放宽类型时,schema 必须同步放宽**(用 `z.union([z.number(), z.string()])` 兼容历史值);② **`.loose(true)` 的兜底是静默的**,volatile 子树里一个字段失配 = 整树数据不可见;③ **迁移标记不可信**——`migrateFrom` 改为「桶已存在时合并去重」而非整桶跳过,且「有存量必须先导入再清理」,这样即使 marker 被误写也能把旧账救回来。回归:`tests/health-schema-tolerance.test.cjs`。
|
|
365
|
-
|
|
366
|
-
29. **去重不能依赖 nonce 的数值单调性(0.3.12)**:`last = Math.max(lastTestHandledNonce, claimedNonce)` 看似「取最新」,实则假设了 nonce 单调递增——而 0.3.2 起 nonce 是随机 53-bit,**「上一轮 > 本轮」时(≈50%)判等失效**,同一 testRequest 在每次 `loader/volatile-update`(包括宿主自己每 5s 的 digest 回写触发的那次)都被重新消费。症状是两级:① 上游被真实重复调用(计费);② 多次执行的结果写进同一条目(线上物证:同 nonce 一条记录同时带 `code TIMEOUT / 60000ms` 与 `ok:true / text/ttftMs`,单次执行不可能)。**规则:去重只做严格等值 + 已消费集合 + 已结算值 + 已有终态,任何「大小推断」都是错的**。释放认领(notReady 重放)时必须同时释放已消费集合,否则重放被自己的去重挡住。回归:`tests/task-dedup.test.cjs`(判定表直接调用生产导出 `shouldConsumeNonce`)。
|
|
367
|
-
30. **整树读-改-写会把并发终态「回灌」成旧值(0.3.12 线上事故)**:health 子树的每次写入都曾是 `cur = healthOf(); update({health:{...cur, patch}})`——payload 是调用时刻的**整树快照**。只要它晚于并发的终态写落盘,`status` 就被改回 `running`;而 `settings.update` 是深合并(只覆盖出现的键、不删键),后写入的 `finishedAt/code/error` 反而被保留 → 条目变成「running + 终态字段」的僵尸,客户端只认 `status==='ok'|'error'` 就永远等不到终态,66s 后误报「host 可能未处理该请求」。**规则:settings 里的共享子树一律叶写**(patch 命中的键逐个 `set`;单键结果用 `writeHealthLeaf(['testResults', nonce], value)`;修剪用 `unset` 而非整字典覆盖),client 侧同样不得「describe 快照 + 整树回写」。另:客户端终态判定要接受 `finishedAt`——状态位可能被写坏,但已经发生的终态字段不会消失。回归:`tests/testresult-fallback.test.cjs`(F4 直接复刻事故时序)。
|
|
368
|
-
|
|
369
|
-
31. **超时守卫 dispose 时必须 settle promise(0.3.13,审计 R3)**:`timed()` 的 guard 若在 fiber dispose 时只 `clearTimeout`,promise 永不 reject——`await Promise.race([inner.next(), guard.promise])` 就永久挂住,在飞测试既不结束也不写终态,客户端只能等到 66s 假超时(表现与「host 未处理」一模一样)。**规则:任何挂在 `ctx.effect` 上的定时器,dispose 时不仅要清定时器,还要让等待它的 promise settle**(这里 reject `ABORTED`),否则卸载路径会留下永久悬挂的 await。配套:新进程启动时清扫上次遗留的 `running` 条目(补 `ABORTED` 终态)——进程内在飞任务都有 fiber 生命周期,重启后见到的 running 一定是遗留的。回归:`tests/runtime-hardening.test.cjs`。
|
|
370
|
-
32. **静默失败要有出口(0.3.13,审计 R2)**:loader 的 `_commitVolatile` 在 `resolveConfig` 抛错时只 `logger.warn` 后返回 true——document 已落盘、fiber 引用不更新、**不发 `loader/volatile-update`**。而本插件的任务通道只在 volatile-update 里消费请求,于是请求被彻底忽略、客户端 66s 超时,宿主侧却「什么都没发生」。同理 `.loose(true)` 的 schema 兜底会把整棵 volatile 子树换成空对象而不报错(踩坑 28)。**规则:凡是「静默丢弃用户动作」的路径都要有可见出口**——① 未消费的请求打日志(每 nonce 一次);② 周期性兜底重试(5s,复用同一消费判定,每 nonce 一次);③ 关键子树从有到无时告警。回归:`tests/runtime-hardening.test.cjs`。
|
|
371
|
-
|
|
372
|
-
33. **settings 的 revision 是「raw config 的 JSON 指纹」,会被插件自己的健康写入推高(0.3.14)**:`describe()` 里 `revision += raw !== previous.raw`,`raw = JSON.stringify([fiber.uid, schema.toJSON(), entry.options.config])`。本插件的行同时承载健康投影(digest 每 5s、runtime、testResults),所以 client 手里的 revision 几秒内必然过期,保存被 `settings/conflict` 拒绝,提示却是「已被其他页面修改」——**其实「其他页面」就是插件自己**;providers 在另一个行(llm-pi-ai)没有自噪声,于是表现为「部分保存:提供商成功、轮询组失败,点多次才成功」。两条教训:① **带 revision 的写入必须「写入前重读」**,加载时记下的 revision 只在「该行没有其他写入者」时才有效;② 冲突要**分类**——比较远端值与加载基线(键序无关的规范 JSON),远端没变就是自噪声(重试),真的变了才是冲突(报错)。根治方向是把高频数据搬出该行(Remote/storageDomain)。回归:`tests/revision-conflict.test.cjs`。
|
|
373
|
-
|
|
374
|
-
34. **重构时把「声明 + 赋值」拆成分支内赋值,却丢了声明(0.3.15,用户看到的「本轮运行失败 cursor is not defined」)**:F13 把 `let cursor = strategy === 'primary' ? 0 : …` 改成 `if (round-robin) { cursor = …; } else { cursor = …; }` —— 两处赋值都在,**声明没了**。ESM 恒为严格模式,赋值未声明标识符直接抛 `ReferenceError`,于是**每一次走虚拟路由的请求都失败**;更糟的是这个错误只有真正跑到那一行才暴露:注册、目录、模型菜单、源级测试全部正常(32 项全绿)。教训:① **局部变量改写分支结构时,声明必须留在分支之外**(`let x;` + 分支内只赋值);② **引擎需要「真跑一遍」的集成测试**——源级断言/逻辑复刻挡不住这类缺陷,见 `tests/engine-integration.test.cjs`;③ 加一道静态守卫:acorn 扫「赋值给未声明标识符」(`tests/no-undeclared-assignment.test.cjs`),同一类缺陷的 F18(`idx`)也会被它抓住。
|
|
375
|
-
|
|
376
|
-
## 0.3.11 修复后的自愈路径(无需手工清库)
|
|
377
|
-
|
|
378
|
-
重启后:schema 接受历史字符串 nonce → `records` 重新可见 → `migrateFrom` 把旧流水**合并去重**进 domain(`my-opencode-go` 桶已有新事件,按 `ts|provider|model` 去重后并入)→ path-ops 真删除 settings 里的 `records` → digest 重算包含历史。日志会打印 `legacy health imported into domain and cleared from settings`。
|
|
101
|
+
框架修复针对已审计版本:Loader 拒绝非法 volatile 候选并恢复旧 raw/options;ConfigEditor 验证目标行确实接受了配置,避免 Group 吞掉错误后仍报告成功。备份在 `backups/framework-*/`,源码补丁在 `patches/`。重装宿主依赖可能覆盖这项本地修复,需重新检查;插件的新状态通道本身不依赖此补丁。
|
|
102
|
+
|
|
103
|
+
`tests/runtime-api.test.cjs` 经临时 profile 启动真实 Loader、Settings、ConfigEditor、Gateway、Connection、LLM 和 JSON storage,只替换外部模型适配器。覆盖认证、迁移、重启恢复、重复任务、取消、输入边界以及配置不变性。
|
|
104
|
+
|
|
105
|
+
浏览器验证使用 `tests/browser/preview.cjs` 与 `tests/browser/e2e.py`;图片与结果记录在 `docs/screenshots/`。纠正后的审计见 [docs/AUDIT-cordis-config-state-pollution.md](docs/AUDIT-cordis-config-state-pollution.md)。
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcaneorion/dsh-model-channel-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "node --test \"tests/*.test.cjs\""
|
|
7
|
+
"test": "node --expose-internals --test \"tests/*.test.cjs\"",
|
|
8
|
+
"test:framework": "node --test tests/framework/volatile-rollback.cjs",
|
|
9
|
+
"test:browser": "node scripts/test-browser.cjs"
|
|
8
10
|
},
|
|
9
11
|
"exports": {
|
|
10
12
|
".": "./src/index.js",
|
|
@@ -38,7 +40,16 @@
|
|
|
38
40
|
},
|
|
39
41
|
"devDependencies": {
|
|
40
42
|
"yaml": "^2.9.0",
|
|
41
|
-
"acorn": "^8.18.0"
|
|
43
|
+
"acorn": "^8.18.0",
|
|
44
|
+
"esbuild": "^0.28.1",
|
|
45
|
+
"react-dom": "^18.3.1",
|
|
46
|
+
"@deepseek-ai/dsh-app-boot": "0.2.0-rc.1",
|
|
47
|
+
"@deepseek-ai/dsh-config-editor": "0.2.0-rc.1",
|
|
48
|
+
"@deepseek-ai/dsh-hmr": "0.2.0-rc.1",
|
|
49
|
+
"@deepseek-ai/dsh-storage": "0.2.0-rc.1",
|
|
50
|
+
"@deepseek-ai/dsh-storage-json": "0.2.0-rc.1",
|
|
51
|
+
"@deepseek-ai/dsh-typert-registry": "0.2.0-rc.1",
|
|
52
|
+
"@deepseek-ai/cordis-plugin-timer": "^1.1.2"
|
|
42
53
|
},
|
|
43
54
|
"peerDependencies": {
|
|
44
55
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
@@ -50,7 +61,9 @@
|
|
|
50
61
|
"@deepseek-ai/dsh-storage-domain": "0.2.0-rc.1",
|
|
51
62
|
"@deepseek-ai/schemastery": ">=3.18.4",
|
|
52
63
|
"react": "^18.3.1",
|
|
53
|
-
"zod": "^4.4.3"
|
|
64
|
+
"zod": "^4.4.3",
|
|
65
|
+
"@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.1",
|
|
66
|
+
"@deepseek-ai/dsh-api-gateway": "0.2.0-rc.1"
|
|
54
67
|
},
|
|
55
68
|
"dsh": {
|
|
56
69
|
"bundle": {
|