@arcaneorion/dsh-model-channel-manager 0.2.0 → 0.3.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 +15 -4
- package/package.json +9 -8
- package/src/client.js +101 -3
- package/src/index.js +202 -87
package/README.md
CHANGED
|
@@ -38,7 +38,11 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
|
|
|
38
38
|
|
|
39
39
|
## 兼容性(DSH 版本)
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
> **当前工作树已移植到 DSH `0.2.0-rc.1`**(peer 按 `0.2.0-rc.1` 声明;settings 寻址从「自建命名空间」改为「本行实例配置」——
|
|
42
|
+
> 原先的 `model-channels` + `model-channel-health` 两个命名空间合并为本行 Config 的
|
|
43
|
+
> `groups` / `providerOrder` / `effortMemory` / `health`)。下面这段 `0.1.1-rc.2` 的记录仅作历史基线参考。
|
|
44
|
+
|
|
45
|
+
本包原在 **DSH `0.1.1-rc.2`**(`dsh --version`)上开发与实测,宿主侧依赖按该版本**精确钉住**:
|
|
42
46
|
|
|
43
47
|
| 宿主包 | 声明 | 用途 |
|
|
44
48
|
|---|---|---|
|
|
@@ -59,13 +63,17 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
|
|
|
59
63
|
- 保存轮询组 = `api.settings.update({ns:'model-channels', patch:{groups}})`
|
|
60
64
|
- ⚡测速 = `api.settings.update({ns:'model-channel-health', patch:{speedRequest:{group,nonce}}})`(host watcher 消费)
|
|
61
65
|
- 单模型测试 = `settings.update({ns:'model-channel-health', patch:{testRequest:{nonce,provider,model,prompt,maxTokens}}})`;host 执行真实 `llm.stream` 后把结果写回 `testResults[nonce]`;client 轮询 describe 直到 ok/error
|
|
62
|
-
- `apiRef`
|
|
66
|
+
- `apiRef` 获取:**0.2 为 `ctx.remote`**(插件级 `inject` 声明 `remote` / `remote.settings` / `remote.credentials` / `remote.llm`,apply 时经 `ctx.inject(['remote'])` 捕获);0.1 的 `ctx.get('connection').api` 已不存在。
|
|
67
|
+
- 上述调用形状仍保留 0.1 的样子:client 半内建门面 `makeLegacyApi` 把 0.2 的**位置参数 + RemoteResult** 适配回旧的**对象入参 + `{result:{ok,value}}`**,并把 `model-channels` / `model-channel-health` 合成回旧命名空间视图(真实承载是本插件行 id `model-channel-manager` 的实例配置)。
|
|
63
68
|
|
|
64
|
-
>
|
|
69
|
+
> **宿主边界(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` 直接投影本行实例配置,没有该白名单环节;对应地,本插件的数据落在 `~/.dsh/profiles/web/cordis.patch.yml` 的 `model-channel-manager` 行 `config` 下。
|
|
65
71
|
|
|
66
72
|
## 响应信封(重要)
|
|
67
73
|
|
|
68
|
-
|
|
74
|
+
0.1 的 `connection.api.*` 返回 `{result: {ok, value}}` 包裹(`dsh-client-connection` 的 `callUnary` + zod 校验)。
|
|
75
|
+
**0.2 的原生远程调用直接返回 `RemoteResult`(`{ok, value} | {ok:false, error}`)**,不再有 `result` 外层;
|
|
76
|
+
本插件 client 的门面把它重新包回旧形状,因此下面这层解包逻辑在 0.2 上依然成立:
|
|
69
77
|
- 成功:`resp.result.value.{...}`
|
|
70
78
|
- 失败:`resp.result.ok === false`,错误在 `resp.result.error.message`
|
|
71
79
|
- `settings.describe` 的 value = `{writable, hasDocument, namespaces:[{ns, value, base, user, revision, ...}]}`
|
|
@@ -167,3 +175,6 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
|
|
|
167
175
|
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`。
|
|
168
176
|
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` 校验后压缩回写;杀进程前务必备份。
|
|
169
177
|
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
|
+
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. **整字段落盘 + 内存态被配置快照覆盖**:`settings.update` 是整字段替换;`reloadFromConfig()` 每次 volatile-update 都用配置快照整体覆盖 `state.records`,而健康 flush 有 2s 防抖 → **刚记下的一笔在落盘前就被内存覆盖**(症状:面板“测试”成功不入账,失败反被全局拦截器的 catch 记上)。修法:`pendingRecords` 缓冲,快照覆盖后把未落盘的补回,flush 成功后再清账。
|
|
180
|
+
24. **nonce 落盘时机与启动竞态**:`lastTestHandledNonce` / `lastHandledNonce` 必须在**得出结果之后**写(提前写会让“未就绪”的重试被自己的持久值挡掉);启动瞬间凭据服务尚未就绪时测试/测速会以 `MISSING_CREDENTIAL` 失败(凭据其实已在 `.credentials.yaml` 里),应识别为「还没就绪」→ 释放认领 + 5s 延时重试(上限 24 次),**不要**写成渠道故障;测速还必须在整组候选都因未就绪失败时**不落盘、不冷却**,否则一次启动重放就把所有渠道误判成故障。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcaneorion/dsh-model-channel-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -34,12 +34,13 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
38
|
-
"@deepseek-ai/dsh-llm": "0.
|
|
39
|
-
"@deepseek-ai/dsh-settings": "0.
|
|
40
|
-
"@deepseek-ai/dsh-client-connection": "0.
|
|
41
|
-
"@deepseek-ai/dsh-client-ui-conversation": "0.
|
|
42
|
-
"@deepseek-ai/
|
|
37
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
38
|
+
"@deepseek-ai/dsh-llm": "0.2.0-rc.1",
|
|
39
|
+
"@deepseek-ai/dsh-settings": "0.2.0-rc.1",
|
|
40
|
+
"@deepseek-ai/dsh-client-connection": "0.2.0-rc.1",
|
|
41
|
+
"@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.1",
|
|
42
|
+
"@deepseek-ai/dsh-api-remotes": "0.2.0-rc.1",
|
|
43
|
+
"@deepseek-ai/schemastery": ">=3.18.4",
|
|
43
44
|
"react": "^18.3.1"
|
|
44
45
|
},
|
|
45
46
|
"dsh": {
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
"patch": "./cordis.patch.yml"
|
|
48
49
|
},
|
|
49
50
|
"client": {
|
|
50
|
-
"inject": ["slots"
|
|
51
|
+
"inject": ["slots"],
|
|
51
52
|
"platform": "web"
|
|
52
53
|
}
|
|
53
54
|
}
|
package/src/client.js
CHANGED
|
@@ -12,6 +12,103 @@ window.__ModuleLoader__.load({
|
|
|
12
12
|
let apiRef = null
|
|
13
13
|
const savedKeys = {}
|
|
14
14
|
|
|
15
|
+
// dsh 0.2:客户端远程面由 connection.api 改为 ctx.remote;参数改为位置参数,结果统一为
|
|
16
|
+
// RemoteResult。本适配层把两者还原成 0.1 的调用形状(对象入参 + {result:{ok,value}})。
|
|
17
|
+
// 另:0.2 的 settings 命名空间恒等于「插件行 id」,本插件的两套旧命名空间
|
|
18
|
+
// (model-channels / model-channel-health)现由其实例配置的字段承载,在此合成回旧视图。
|
|
19
|
+
const NS_MCM = 'model-channel-manager'
|
|
20
|
+
const makeLegacyApi = (remote) => {
|
|
21
|
+
const wrap = (res) => ({ result: res || { ok: false } });
|
|
22
|
+
const readMcm = async () => {
|
|
23
|
+
const res = await remote.settings.describe()
|
|
24
|
+
if (!res || res.ok !== true) return null
|
|
25
|
+
return ((res.value && res.value.namespaces) || []).find((n) => n && n.ns === NS_MCM) || null
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
settings: {
|
|
29
|
+
describe: async () => {
|
|
30
|
+
const res = await remote.settings.describe()
|
|
31
|
+
if (!res || res.ok !== true) return wrap(res)
|
|
32
|
+
const namespaces = (res.value && res.value.namespaces) || []
|
|
33
|
+
const mcm = namespaces.find((n) => n && n.ns === NS_MCM)
|
|
34
|
+
const value = (mcm && mcm.value) || {}
|
|
35
|
+
const user = (mcm && mcm.user) || {}
|
|
36
|
+
return { result: { ok: true, value: { namespaces: [...namespaces,
|
|
37
|
+
{ ns: 'model-channels', value: { groups: value.groups || [], providerOrder: value.providerOrder || [], effortMemory: value.effortMemory || {} }, user: { groups: user.groups, providerOrder: user.providerOrder }, revision: (mcm && mcm.revision) || 0 },
|
|
38
|
+
{ ns: 'model-channel-health', value: value.health || {}, user: (user && user.health) || {}, revision: (mcm && mcm.revision) || 0 },
|
|
39
|
+
] } } }
|
|
40
|
+
},
|
|
41
|
+
// 旧签名:update({ ns, patch })
|
|
42
|
+
update: async (args) => {
|
|
43
|
+
const ns = (args && args.ns) || NS_MCM
|
|
44
|
+
const patch = (args && args.patch) || {}
|
|
45
|
+
if (ns === 'model-channels') return wrap(await remote.settings.update(NS_MCM, patch, undefined))
|
|
46
|
+
const row = await readMcm()
|
|
47
|
+
const health = (row && row.value && row.value.health) || {}
|
|
48
|
+
return wrap(await remote.settings.update(NS_MCM, { health: Object.assign({}, health, patch) }, undefined))
|
|
49
|
+
},
|
|
50
|
+
// 旧签名:mutate({ ns, ops })——0.2 只对真实行命名空间提供 path-ops;
|
|
51
|
+
// 本插件自身的健康子树用合并写实现等价语义。
|
|
52
|
+
mutate: async (args) => {
|
|
53
|
+
const ns = (args && args.ns) || NS_MCM
|
|
54
|
+
const ops = (args && args.ops) || []
|
|
55
|
+
if (ns !== 'model-channels' && ns !== 'model-channel-health' && ns !== NS_MCM) {
|
|
56
|
+
return wrap(await remote.settings.mutate(ns, ops, undefined))
|
|
57
|
+
}
|
|
58
|
+
const row = await readMcm()
|
|
59
|
+
const health = Object.assign({}, (row && row.value && row.value.health) || {})
|
|
60
|
+
const top = {
|
|
61
|
+
groups: (row && row.value && row.value.groups) || [],
|
|
62
|
+
providerOrder: (row && row.value && row.value.providerOrder) || [],
|
|
63
|
+
}
|
|
64
|
+
for (const op of ops) {
|
|
65
|
+
const path = (op && op.path) || []
|
|
66
|
+
const root = (ns === 'model-channels') ? (path[0] === 'groups' ? top : (path[0] === 'providerOrder' ? top : health)) : health
|
|
67
|
+
if (path.length === 0) continue
|
|
68
|
+
const key = path[0]
|
|
69
|
+
const rest = path.slice(1)
|
|
70
|
+
if (rest.length === 0) {
|
|
71
|
+
if (op.op === 'set') root[key] = op.value
|
|
72
|
+
else delete root[key]
|
|
73
|
+
} else if (rest.length === 1) {
|
|
74
|
+
const child = Object.assign({}, root[key] || {})
|
|
75
|
+
if (op.op === 'set') child[rest[0]] = op.value
|
|
76
|
+
else delete child[rest[0]]
|
|
77
|
+
root[key] = child
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (ns === 'model-channels') {
|
|
81
|
+
return wrap(await remote.settings.update(NS_MCM, { groups: top.groups, providerOrder: top.providerOrder }, undefined))
|
|
82
|
+
}
|
|
83
|
+
return wrap(await remote.settings.update(NS_MCM, { health }, undefined))
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
credentials: {
|
|
87
|
+
// 旧签名:set({ ref, value })
|
|
88
|
+
set: async (args) => wrap(await remote.credentials.set((args && args.ref) || '', (args && args.value) || '')),
|
|
89
|
+
// 旧签名:describe({ refs: [...] }) → value.credentials[ref]
|
|
90
|
+
describe: async (args) => {
|
|
91
|
+
const refs = (args && args.refs) || []
|
|
92
|
+
const res = await remote.credentials.describe(refs)
|
|
93
|
+
if (!res || res.ok !== true) return wrap(res)
|
|
94
|
+
return { result: { ok: true, value: { credentials: res.value || {} } } }
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
llm: {
|
|
98
|
+
// 旧签名:discoverModels({ settingsNs, ...request }) → value.models
|
|
99
|
+
discoverModels: async (args) => {
|
|
100
|
+
const settingsNs = (args && args.settingsNs) || 'llm-pi-ai'
|
|
101
|
+
const request = Object.assign({}, args)
|
|
102
|
+
delete request.settingsNs
|
|
103
|
+
const res = await remote.llm.discoverModels(settingsNs, request)
|
|
104
|
+
if (!res || res.ok !== true) return wrap(res)
|
|
105
|
+
const value = res.value
|
|
106
|
+
return { result: { ok: true, value: Array.isArray(value) ? { models: value } : value } }
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
15
112
|
const CSS = `
|
|
16
113
|
.mcm-root { display:flex; flex-direction:column; height:100%; overflow:hidden; font-size:13px; color:var(--dsw-alias-label-primary); background:var(--dsw-alias-bg-base); }
|
|
17
114
|
.mcm-header { display:flex; align-items:center; justify-content:space-between; padding:12px 24px; border-bottom:1px solid var(--dsw-alias-border-l1); background:var(--dsw-alias-bg-layer-1); flex-shrink:0; }
|
|
@@ -1428,8 +1525,9 @@ window.__ModuleLoader__.load({
|
|
|
1428
1525
|
|
|
1429
1526
|
|
|
1430
1527
|
function apply(ctx) {
|
|
1431
|
-
|
|
1432
|
-
|
|
1528
|
+
// dsh 0.2:远程调用面为 ctx.remote(connection.api 已移除)。保留 0.1 的调用形状,
|
|
1529
|
+
// 由下面的适配层把位置参数/RemoteResult 还原成旧的 {result:{ok,value}} 与对象入参。
|
|
1530
|
+
ctx.inject(['remote'], (scope) => { apiRef = makeLegacyApi(scope.remote) })
|
|
1433
1531
|
ctx.effect(() => {
|
|
1434
1532
|
const tag = document.createElement('style')
|
|
1435
1533
|
tag.dataset.mcmStyle = ''
|
|
@@ -1448,6 +1546,6 @@ window.__ModuleLoader__.load({
|
|
|
1448
1546
|
))
|
|
1449
1547
|
}
|
|
1450
1548
|
|
|
1451
|
-
return { name: 'model-channel-manager', inject: ['slots', '
|
|
1549
|
+
return { name: 'model-channel-manager', inject: ['slots', 'remote', 'remote.settings', 'remote.credentials', 'remote.llm'], apply }
|
|
1452
1550
|
},
|
|
1453
1551
|
})
|
package/src/index.js
CHANGED
|
@@ -5,15 +5,9 @@ const ROUTE_PREFIX = 'roundrobin/';
|
|
|
5
5
|
const GROUP_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
6
6
|
const NS_CONFIG = 'model-channels';
|
|
7
7
|
const NS_HEALTH = 'model-channel-health';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}).loose(true)).default([]),
|
|
12
|
-
// 面板供应商顺序的持久化载体:settings-file 的 patchNode 对 map 键序是盲的
|
|
13
|
-
// (纯重排 = 零 diff),但数组是 deepEqual 不等即整值 setIn(原位、保序)。
|
|
14
|
-
// 顺序存成数组数据而不是对象键序元数据,重启后从文件真实还原。引擎不消费它。
|
|
15
|
-
providerOrder: z.array(z.string()).default([]),
|
|
16
|
-
}).loose(true);
|
|
8
|
+
// dsh 0.2:settings 不再是可动态注册的命名空间总线,而是「插件行实例配置」的表单投影。
|
|
9
|
+
// 因此原先的 model-channels / model-channel-health 两个命名空间合并为本插件的 Config
|
|
10
|
+
// (字段 groups / providerOrder / effortMemory / health),命名空间 id 即本行 id。
|
|
17
11
|
const HEALTH_SCHEMA = z.object({
|
|
18
12
|
records: z.dict(z.array(z.any())).default({}),
|
|
19
13
|
speedResults: z.dict(z.array(z.any())).default({}),
|
|
@@ -25,7 +19,34 @@ const HEALTH_SCHEMA = z.object({
|
|
|
25
19
|
lastTestHandledNonce: z.number().default(0),
|
|
26
20
|
legacyMigrated: z.any(),
|
|
27
21
|
}).loose(true);
|
|
28
|
-
export
|
|
22
|
+
export const Config = z.object({
|
|
23
|
+
groups: z.array(z.any()).default([]).volatile(),
|
|
24
|
+
providerOrder: z.array(z.string()).default([]).volatile(),
|
|
25
|
+
effortMemory: z.dict(z.string()).default({}).volatile(),
|
|
26
|
+
health: HEALTH_SCHEMA.volatile(),
|
|
27
|
+
}).loose(true);
|
|
28
|
+
export function apply(ctx, config) {
|
|
29
|
+
// 本行在 profile 中的 entry id 就是 settings 命名空间;缺失时回落到包名。
|
|
30
|
+
const SELF_NS = (ctx.fiber && ctx.fiber.entry && ctx.fiber.entry.options && ctx.fiber.entry.options.id) || 'model-channel-manager';
|
|
31
|
+
const refOf = (key, fallback) => {
|
|
32
|
+
const field = config && config[key];
|
|
33
|
+
if (field && typeof field.get === 'function') {
|
|
34
|
+
try {
|
|
35
|
+
return field.get();
|
|
36
|
+
}
|
|
37
|
+
catch (_e) {
|
|
38
|
+
return fallback;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return fallback;
|
|
42
|
+
};
|
|
43
|
+
const healthOf = () => refOf('health', null) || {};
|
|
44
|
+
const cfgOf = () => ({
|
|
45
|
+
groups: refOf('groups', []) || [],
|
|
46
|
+
providerOrder: refOf('providerOrder', []) || [],
|
|
47
|
+
effortMemory: refOf('effortMemory', {}) || {},
|
|
48
|
+
});
|
|
49
|
+
|
|
29
50
|
const isContentChunk = (c) => {
|
|
30
51
|
if (!c) return false;
|
|
31
52
|
if (c.type === 'text-delta' || c.type === 'reasoning-delta') return c.text !== '';
|
|
@@ -282,18 +303,25 @@ export function apply(ctx) {
|
|
|
282
303
|
// 每键保留 300 条:健康页签 30m/24h 视图与选择器置顶绰绰有余,同时把
|
|
283
304
|
// settings.yaml 的体量压到可读(2000 条/键时该文件一度膨胀到 2 万行)
|
|
284
305
|
state.records[key] = list.filter((e) => e.ts >= cutoff).slice(-300);
|
|
306
|
+
pendingRecords.push({ key, rec });
|
|
285
307
|
persistHealth();
|
|
286
308
|
};
|
|
287
309
|
const persistSpeedResults = async (r) => {
|
|
288
310
|
if (bus === null) return;
|
|
289
|
-
await bus.
|
|
311
|
+
await bus.writeHealth({ speedResults: clone(r), runtime: await persistRuntime() });
|
|
290
312
|
};
|
|
291
313
|
// ---------- 健康持久化节流:合并 2s 窗口内的写入,避免每个请求全量重写 settings ----------
|
|
292
314
|
let healthFlushHandle = null;
|
|
293
315
|
let healthDirty = false;
|
|
316
|
+
// 尚未落盘的流水:reloadFromConfig 每次 volatile-update 都用配置快照整体覆盖 state.records,
|
|
317
|
+
// 而 flush 有 2s 防抖——不缓冲的话,刚记下的一笔会在落盘前被内存覆盖(实测「测试成功不入账」就是这个)。
|
|
318
|
+
let pendingRecords = [];
|
|
294
319
|
const flushHealthNow = async () => {
|
|
295
320
|
if (bus === null) return;
|
|
296
|
-
|
|
321
|
+
const flushed = pendingRecords.slice();
|
|
322
|
+
await bus.writeHealth({ records: clone(pullRecords()), speedResults: clone(pullSpeedResults()), runtime: await persistRuntime() });
|
|
323
|
+
// 写入成功后才清账;失败就留着,等下一次 flush 重试
|
|
324
|
+
pendingRecords = pendingRecords.filter((p) => !flushed.includes(p));
|
|
297
325
|
};
|
|
298
326
|
const persistHealth = () => {
|
|
299
327
|
healthDirty = true;
|
|
@@ -585,11 +613,15 @@ export function apply(ctx) {
|
|
|
585
613
|
await ctx.timeout(1500);
|
|
586
614
|
}
|
|
587
615
|
}
|
|
588
|
-
return { provider: cand.provider, model: cand.model, ok: false, ttft: null, latency: null, failure: (lastErr && lastErr.message) || 'speedtest failed' };
|
|
616
|
+
return { provider: cand.provider, model: cand.model, ok: false, ttft: null, latency: null, failure: (lastErr && lastErr.message) || 'speedtest failed', code: (lastErr && lastErr.code) || null };
|
|
589
617
|
}));
|
|
590
618
|
results.push(...measured);
|
|
591
619
|
}
|
|
592
620
|
const rows = results.map((r) => ({ provider: r.provider, model: r.model, ok: r.ok, ttft: r.ttft, latency: r.latency, at: Date.now(), failure: r.failure || null }));
|
|
621
|
+
// 整组候选都因「环境未就绪」(凭据服务尚未加载等)失败时,不落盘 speedResults、不冷却候选,
|
|
622
|
+
// 交给调用方延后重试——否则启动瞬间的一次重放会把所有渠道误判成故障并冷却。
|
|
623
|
+
if (rows.length > 0 && rows.every((r, i) => !r.ok && notReady({ code: results[i] && results[i].code, message: r.failure })))
|
|
624
|
+
return { ok: false, deferred: true };
|
|
593
625
|
state.speedResults[cfg.id] = rows;
|
|
594
626
|
await persistSpeedResults(state.speedResults);
|
|
595
627
|
const now = Date.now();
|
|
@@ -675,6 +707,7 @@ export function apply(ctx) {
|
|
|
675
707
|
let ttft = null;
|
|
676
708
|
let text = '';
|
|
677
709
|
let reasoning = '';
|
|
710
|
+
let lastUsage = null;
|
|
678
711
|
const closeInner = async () => { try {
|
|
679
712
|
const c = inner.return ? inner.return() : null;
|
|
680
713
|
if (c && c.then)
|
|
@@ -697,6 +730,8 @@ export function apply(ctx) {
|
|
|
697
730
|
if (ttft === null && isContentChunk(chunk)) {
|
|
698
731
|
ttft = Date.now() - start;
|
|
699
732
|
}
|
|
733
|
+
if (chunk && chunk.type === 'usage' && chunk.usage && typeof chunk.usage === 'object')
|
|
734
|
+
lastUsage = chunk.usage;
|
|
700
735
|
if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
701
736
|
text += chunk.text;
|
|
702
737
|
}
|
|
@@ -705,8 +740,12 @@ export function apply(ctx) {
|
|
|
705
740
|
}
|
|
706
741
|
if (isTerminalChunk(chunk)) {
|
|
707
742
|
const reason = chunk.reason || {};
|
|
708
|
-
if (isSuccessReason(reason))
|
|
743
|
+
if (isSuccessReason(reason)) {
|
|
744
|
+
// 命中 finish 就 return,内层流不会被全局拦截器完整排干,成功这一笔要自己补记
|
|
745
|
+
// (失败那笔由拦截器的 catch 分支记,所以此前只有失败会进健康流水)
|
|
746
|
+
recordHealth(provider, { provider, model }, { ok: true, ttftMs: ttft, latencyMs: Date.now() - start, code: null, usage: lastUsage });
|
|
709
747
|
return { ok: true, ttftMs: ttft, latencyMs: Date.now() - start, text: text.slice(0, 2000), reasoning: reasoning.slice(0, 2000) };
|
|
748
|
+
}
|
|
710
749
|
throw { code: (reason.failure && reason.failure.code) || 'STREAM_ERROR', message: (reason.failure && reason.failure.message) || 'model test failed' };
|
|
711
750
|
}
|
|
712
751
|
}
|
|
@@ -716,61 +755,56 @@ export function apply(ctx) {
|
|
|
716
755
|
throw err;
|
|
717
756
|
}
|
|
718
757
|
}
|
|
719
|
-
function handleTestRequest(
|
|
758
|
+
function handleTestRequest(next) {
|
|
720
759
|
const req = next.testRequest;
|
|
721
|
-
const last = next.lastTestHandledNonce || 0;
|
|
760
|
+
const last = Math.max(next.lastTestHandledNonce || 0, claimedTestNonce);
|
|
722
761
|
if (!req || typeof req.provider !== 'string' || typeof req.model !== 'string' || typeof req.nonce !== 'number' || req.nonce === last)
|
|
723
762
|
return;
|
|
724
|
-
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
763
|
+
claimedTestNonce = req.nonce;
|
|
764
|
+
// nonce 的落盘推迟到本次测试得出结论之后:只有「真跑过」才算 handled。
|
|
765
|
+
// 启动早于凭据服务就绪时会以 MISSING_CREDENTIAL 失败,那要释放认领重试;
|
|
766
|
+
// 若提前写了 nonce,重试会被自己的持久值挡掉。
|
|
767
|
+
const settle = (entry) => {
|
|
768
|
+
if (bus !== null)
|
|
769
|
+
bus.writeHealth({ lastTestHandledNonce: req.nonce }).catch((e) => console.error('[model-channel-manager] lastTestHandledNonce 写入失败:', e));
|
|
770
|
+
return setResult(entry);
|
|
771
|
+
};
|
|
772
|
+
// 0.2 的配置写入是「整字段落盘」,不再有 path-ops;结果集按当前值合并后整段写回。
|
|
730
773
|
const setResult = async (entry) => {
|
|
731
774
|
const key = String(req.nonce);
|
|
732
775
|
const value = Object.assign({}, entry, { nonce: req.nonce, provider: req.provider, model: req.model });
|
|
733
|
-
const cur = (
|
|
734
|
-
if (
|
|
735
|
-
|
|
736
|
-
await settings.mutate(NS_HEALTH, [{ op: 'set', path: ['testResults', key], value }]);
|
|
737
|
-
const landed = ((bus && bus.healthScope ? bus.healthScope.get().testResults : null) || {})[key];
|
|
738
|
-
if (landed) { mutateWorks = true; return; }
|
|
739
|
-
mutateWorks = false;
|
|
740
|
-
console.error('[model-channel-manager] testResults mutate 未生效,回退 update 合并写');
|
|
741
|
-
} catch (e) {
|
|
742
|
-
mutateWorks = false;
|
|
743
|
-
console.error('[model-channel-manager] testResults mutate 失败,回退 update 合并写:', e);
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
await settings.update(NS_HEALTH, { testResults: Object.assign({}, cur, { [key]: value }) }).catch((e) => console.error('[model-channel-manager] testResults update 兜底写入失败:', e));
|
|
776
|
+
const cur = healthOf().testResults || {};
|
|
777
|
+
if (bus !== null)
|
|
778
|
+
await bus.writeHealth({ testResults: Object.assign({}, cur, { [key]: value }) }).catch((e) => console.error('[model-channel-manager] testResults 写入失败:', e));
|
|
747
779
|
};
|
|
748
780
|
// 修剪低频执行:只在条目数超限时砍到 50(不在每次写入时整包重写)
|
|
749
781
|
const maybePrune = () => {
|
|
750
|
-
const all = (
|
|
782
|
+
const all = healthOf().testResults || {};
|
|
751
783
|
const entries = Object.entries(all);
|
|
752
784
|
if (entries.length <= 50) return;
|
|
753
|
-
const
|
|
754
|
-
|
|
755
|
-
if (
|
|
756
|
-
|
|
757
|
-
for (const [k, v] of entries.slice(0, 50)) kept[k] = v;
|
|
758
|
-
settings.update(NS_HEALTH, { testResults: kept }).catch((e) => console.error('[model-channel-manager] testResults 修剪 update 失败:', e));
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
761
|
-
settings.mutate(NS_HEALTH, drop).catch((e) => console.error('[model-channel-manager] testResults 修剪失败:', e));
|
|
785
|
+
const kept = {};
|
|
786
|
+
for (const [k, v] of entries.sort((a, b) => ((b[1] && b[1].finishedAt) || 0) - ((a[1] && a[1].finishedAt) || 0)).slice(0, 50)) kept[k] = v;
|
|
787
|
+
if (bus !== null)
|
|
788
|
+
bus.writeHealth({ testResults: kept }).catch((e) => console.error('[model-channel-manager] testResults 修剪失败:', e));
|
|
762
789
|
};
|
|
763
790
|
const done = async (r) => {
|
|
764
791
|
try {
|
|
765
|
-
await
|
|
792
|
+
await settle(Object.assign({}, r, { finishedAt: Date.now() }));
|
|
766
793
|
maybePrune();
|
|
767
794
|
}
|
|
768
795
|
catch (_e) { }
|
|
769
796
|
};
|
|
770
797
|
void setResult({ status: 'running', startedAt: Date.now() });
|
|
771
798
|
runModelTest(req.provider, req.model, req.prompt, req.maxTokens).then(async (r) => {
|
|
799
|
+
replayAttempts.delete('test');
|
|
772
800
|
await done(Object.assign({ status: 'ok' }, r));
|
|
773
801
|
}).catch(async (e) => {
|
|
802
|
+
if (notReady(e)) {
|
|
803
|
+
// 凭据服务尚未就绪(典型是启动瞬间的重放):不写成假 error,释放认领并延后重试
|
|
804
|
+
claimedTestNonce = 0;
|
|
805
|
+
scheduleReplay('test', () => reloadFromConfig(false));
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
774
808
|
await done({ status: 'error', code: (e && e.code) || 'STREAM_ERROR', error: (e && e.message) || String(e) });
|
|
775
809
|
});
|
|
776
810
|
}
|
|
@@ -791,44 +825,126 @@ export function apply(ctx) {
|
|
|
791
825
|
}
|
|
792
826
|
// ---------- settings 总线接入(响应式:settings 服务异步初始化,apply 时查询太早) ----------
|
|
793
827
|
let bus = null;
|
|
828
|
+
let runtimeRestored = false;
|
|
829
|
+
// 非持久去重:回写要等当前 HMR 事务结束才落盘,这中间 volatile-update 可能带着同一个 nonce 再来,
|
|
830
|
+
// 内存里先认领,避免同一请求被重复执行(并顺带消掉重复的回写风暴)。
|
|
831
|
+
let claimedTestNonce = 0;
|
|
832
|
+
let claimedSpeedNonce = 0;
|
|
833
|
+
// 启动早于凭据服务就绪:此时重放测试/测速会以 MISSING_CREDENTIAL 失败。
|
|
834
|
+
// 这类错误是「环境还没准备好」而不是「渠道故障」,必须释放认领、延后重试,不能写成假 error。
|
|
835
|
+
const notReady = (e) => {
|
|
836
|
+
const code = (e && e.code) || '';
|
|
837
|
+
const msg = String((e && (e.message || e.error)) || '');
|
|
838
|
+
return code === 'MISSING_CREDENTIAL' || code === 'INVALID_CREDENTIAL' || /no credential|credential .*not set|is not set — store/i.test(msg);
|
|
839
|
+
};
|
|
840
|
+
const replayAttempts = new Map();
|
|
841
|
+
const scheduleReplay = (key, fn, ms = 5000) => {
|
|
842
|
+
const n = (replayAttempts.get(key) || 0) + 1;
|
|
843
|
+
if (n > 24)
|
|
844
|
+
return;
|
|
845
|
+
replayAttempts.set(key, n);
|
|
846
|
+
ctx.timeout(() => { fn(); }, ms);
|
|
847
|
+
};
|
|
848
|
+
// 从实例配置读取全部状态;volatile 提交后由 loader/volatile-update 触发重载。
|
|
849
|
+
function reloadFromConfig(initial) {
|
|
850
|
+
const cfg = cfgOf();
|
|
851
|
+
state.config = clone(cfg) || { groups: [] };
|
|
852
|
+
const health = healthOf();
|
|
853
|
+
state.records = clone(health.records || {});
|
|
854
|
+
// 配置快照覆盖后,把尚未落盘的流水补回来(ts+provider+model 相同视为同一笔,不重复)
|
|
855
|
+
for (const p of pendingRecords) {
|
|
856
|
+
const list = state.records[p.key] || (state.records[p.key] = []);
|
|
857
|
+
if (!list.some((e) => e.ts === p.rec.ts && e.provider === p.rec.provider && e.model === p.rec.model))
|
|
858
|
+
list.push(p.rec);
|
|
859
|
+
}
|
|
860
|
+
state.speedResults = clone(health.speedResults || {});
|
|
861
|
+
state.testResults = clone(health.testResults || {});
|
|
862
|
+
if (initial && !runtimeRestored) {
|
|
863
|
+
restoreRuntime(health.runtime || {});
|
|
864
|
+
runtimeRestored = true;
|
|
865
|
+
}
|
|
866
|
+
const live = pullConfig().groups;
|
|
867
|
+
for (const g of live)
|
|
868
|
+
groupRuntime(g.id);
|
|
869
|
+
// 清理已删除组的运行时残留,避免 runtime 持久化无限累积
|
|
870
|
+
for (const id of Array.from(runtime.keys()))
|
|
871
|
+
if (!live.some((g) => g.id === id))
|
|
872
|
+
runtime.delete(id);
|
|
873
|
+
rewireRoutes();
|
|
874
|
+
const req = health.speedRequest;
|
|
875
|
+
const last = Math.max(health.lastHandledNonce || 0, claimedSpeedNonce);
|
|
876
|
+
if (req && typeof req.group === 'string' && typeof req.nonce === 'number' && req.nonce !== last) {
|
|
877
|
+
claimedSpeedNonce = req.nonce;
|
|
878
|
+
const cfgRow = pullConfig().groups.find((g) => g.id === req.group);
|
|
879
|
+
// nonce 落盘推迟到本次测速得出结论之后(同 handleTestRequest 的理由):
|
|
880
|
+
// 提前写会把「没就绪」的重试用自己的持久值挡掉。
|
|
881
|
+
const settleSpeed = () => {
|
|
882
|
+
if (bus !== null)
|
|
883
|
+
bus.writeHealth({ lastHandledNonce: req.nonce }).catch(() => { });
|
|
884
|
+
};
|
|
885
|
+
if (cfgRow)
|
|
886
|
+
runSpeedTest(cfgRow).then((r) => {
|
|
887
|
+
if (r && r.deferred) {
|
|
888
|
+
claimedSpeedNonce = 0;
|
|
889
|
+
scheduleReplay('speed', () => reloadFromConfig(false));
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
replayAttempts.delete('speed');
|
|
893
|
+
settleSpeed();
|
|
894
|
+
}).catch((e) => {
|
|
895
|
+
if (notReady(e)) {
|
|
896
|
+
claimedSpeedNonce = 0;
|
|
897
|
+
scheduleReplay('speed', () => reloadFromConfig(false));
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
settleSpeed();
|
|
901
|
+
console.error('[model-channel-manager] speedtest error:', e);
|
|
902
|
+
});
|
|
903
|
+
else
|
|
904
|
+
settleSpeed();
|
|
905
|
+
}
|
|
906
|
+
handleTestRequest(health);
|
|
907
|
+
}
|
|
794
908
|
ctx.inject(['settings'], (sctx) => {
|
|
795
909
|
const settings = sctx.settings;
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
910
|
+
// 0.2 只暴露「按行 id 定位的实例配置」;健康字段是同一 Config 里的 health 子树,
|
|
911
|
+
// 写入为整字段合并写(旧的 path-ops mutate 在 0.2 不存在)。
|
|
912
|
+
// 0.2 的配置写入走 configEditor.edit() → hmr.runExclusive(),而 runExclusive 一旦发现已在
|
|
913
|
+
// 事务内就直接拒绝("HMR transactions cannot be nested")。loader/volatile-update 回调本身
|
|
914
|
+
// 就运行在该事务里,所以回写必须先切出事务上下文:事务内创建的异步资源(AsyncResource、
|
|
915
|
+
// setTimeout)都会继承事务上下文,只有 AsyncLocalStorage.exit() 能干净地切出去,
|
|
916
|
+
// 写入再由 runExclusive 排进队列、在本次事务结束后执行。
|
|
917
|
+
const outsideTransaction = (fn) => {
|
|
918
|
+
const hmr = ctx.get('hmr');
|
|
919
|
+
const als = hmr && hmr.executing;
|
|
920
|
+
if (als && typeof als.exit === 'function' && als.getStore()) return als.exit(fn);
|
|
921
|
+
return fn();
|
|
922
|
+
};
|
|
923
|
+
// 0.2 是「整字段落盘」,并发调用会各自基于同一份旧快照做 read-modify-write,
|
|
924
|
+
// 后写的直接覆盖先写的(实测 lastTestHandledNonce 与 records 就这样丢过)。
|
|
925
|
+
// 所以写入串行化:排队后逐个重新读当前值再合并,不让两次写入互相覆写。
|
|
926
|
+
let healthWrites = Promise.resolve();
|
|
927
|
+
const writeHealth = (patch) => {
|
|
928
|
+
const run = async () => {
|
|
929
|
+
const cur = healthOf();
|
|
930
|
+
const next = Object.assign({}, cur, patch);
|
|
931
|
+
return outsideTransaction(() => settings.update(SELF_NS, { health: next }));
|
|
932
|
+
};
|
|
933
|
+
const queued = healthWrites.then(run, run);
|
|
934
|
+
healthWrites = queued.catch(() => { });
|
|
935
|
+
return queued;
|
|
936
|
+
};
|
|
937
|
+
bus = {
|
|
938
|
+
settings,
|
|
939
|
+
writeHealth,
|
|
940
|
+
cfgScope: { get: () => cfgOf() },
|
|
941
|
+
healthScope: { get: () => healthOf() },
|
|
942
|
+
};
|
|
943
|
+
reloadFromConfig(true);
|
|
944
|
+
ctx.on('loader/volatile-update', () => {
|
|
945
|
+
reloadFromConfig(false);
|
|
816
946
|
console.log('[model-channel-manager] config hot-reloaded, routes:', pullConfig().groups.map((g) => g.id).join(', ') || '(none)');
|
|
817
947
|
});
|
|
818
|
-
healthScope.watch((next) => {
|
|
819
|
-
state.records = clone(next.records || {});
|
|
820
|
-
state.speedResults = clone(next.speedResults || {});
|
|
821
|
-
state.testResults = clone(next.testResults || {});
|
|
822
|
-
const req = next.speedRequest;
|
|
823
|
-
const last = next.lastHandledNonce || 0;
|
|
824
|
-
if (req && typeof req.group === 'string' && typeof req.nonce === 'number' && req.nonce !== last) {
|
|
825
|
-
settings.update(NS_HEALTH, { lastHandledNonce: req.nonce }).catch(() => { });
|
|
826
|
-
const cfg = pullConfig().groups.find((g) => g.id === req.group);
|
|
827
|
-
if (cfg)
|
|
828
|
-
runSpeedTest(cfg).catch((e) => console.error('[model-channel-manager] speedtest error:', e));
|
|
829
|
-
}
|
|
830
|
-
handleTestRequest(settings, next);
|
|
831
|
-
});
|
|
832
948
|
boot();
|
|
833
949
|
});
|
|
834
950
|
// ---------- 全局 LLM 请求健康拦截 (涵盖所有非虚拟路由的真实渠道模型调用) ----------
|
|
@@ -913,14 +1029,13 @@ export function apply(ctx) {
|
|
|
913
1029
|
}
|
|
914
1030
|
console.log('[model-channel-manager] booted, groups:', pullConfig().groups.map((g) => g.id).join(', ') || '(none)');
|
|
915
1031
|
}
|
|
916
|
-
// 工作区 .channel-manager/config.json ->
|
|
1032
|
+
// 工作区 .channel-manager/config.json -> 本行实例配置(一次性)。
|
|
917
1033
|
// 完成后写 legacyMigrated 哨兵,防止「用户清空全部组 → 重启 → 旧配置复活」;
|
|
918
1034
|
// fs/sandboxPolicy 未就绪时短暂重试,而不是静默放弃直到下次重启。
|
|
919
1035
|
async function migrateLegacyConfig() {
|
|
920
|
-
|
|
921
|
-
if (settings === undefined || pullConfig().groups.length !== 0)
|
|
1036
|
+
if (bus === null || pullConfig().groups.length !== 0)
|
|
922
1037
|
return;
|
|
923
|
-
if ((
|
|
1038
|
+
if (healthOf().legacyMigrated === true)
|
|
924
1039
|
return;
|
|
925
1040
|
for (let attempt = 0; attempt < 6; attempt++) {
|
|
926
1041
|
const fsSvc = ctx.get('fs');
|
|
@@ -933,13 +1048,13 @@ export function apply(ctx) {
|
|
|
933
1048
|
const legacy = JSON.parse(text);
|
|
934
1049
|
const migrated = normalizeConfig(legacy);
|
|
935
1050
|
if (migrated.groups.length > 0) {
|
|
936
|
-
await settings.
|
|
1051
|
+
await bus.settings.update(SELF_NS, { groups: migrated.groups });
|
|
937
1052
|
state.config = { groups: migrated.groups };
|
|
938
1053
|
console.log('[model-channel-manager] migrated legacy config from workspace .channel-manager/config.json');
|
|
939
1054
|
}
|
|
940
1055
|
}
|
|
941
1056
|
catch (_e) { /* 无遗留配置,忽略 */ }
|
|
942
|
-
|
|
1057
|
+
bus.writeHealth({ legacyMigrated: true }).catch(() => { });
|
|
943
1058
|
return;
|
|
944
1059
|
}
|
|
945
1060
|
try {
|