@arcaneorion/dsh-model-channel-manager 0.1.0 → 0.2.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 CHANGED
@@ -3,7 +3,7 @@
3
3
  DSH 原生模型渠道管理。两半结构:
4
4
 
5
5
  - **host 半** `src/index.js`:轮询故障转移引擎(`llm.registerAdapter` 虚拟路由 `roundrobin/<组id>`)+ 7 天健康流水 + 测速排序 + 单模型真实请求测试通道。
6
- - **client 半** `src/client.js`:`conversation.view` 顶级页签「模型配置」,内含三个子页:**模型配置**(llm-pi-ai providers 全字段编辑、拉取上游、单模型 ⚡ 测试、供应商搜索过滤)、**轮询渠道**(groups 编辑 + ⚡测速 + 输入模态编辑;新建组默认呈现名 = 组 id、模态含图片——host 对当前会话模型做 resolveModelInfo,缺 image 时附加图片直接被拒)、**健康统计**(7 天聚合)。
6
+ - **client 半** `src/client.js`:`conversation.view` 顶级页签「模型配置」,内含三个子页:**模型配置**(llm-pi-ai providers 全字段编辑、拉取上游、单模型 ⚡ 测试、供应商搜索过滤)、**轮询渠道**(groups 编辑 + ⚡测速 + 输入模态编辑;**命名单一身份:显示名 = 组唯一 ID**,保存时归一化 `virtualModel.name = id`,改 ID 即改名,永不漂移)、**健康统计**(7 天聚合)。
7
7
  - **会话模型选择器已拆出**为独立 cordis client 插件 [`@arcaneorion/dsh-model-selector-search`](../model-selector-search/)(一个占座者一个插件单元,可独立启停/替换;座位遮蔽 + 搜索 + 近 7 天置顶 + 菜单向上展开都在该仓)。
8
8
 
9
9
  语义参考 pi 的 `pi-provider-manager`,但完全走 DSH 原生 seam(无独立 HTTP 服务/端口/token):
@@ -19,7 +19,14 @@ DSH 原生模型渠道管理。两半结构:
19
19
 
20
20
  ## 挂载
21
21
 
22
- `profiles/web/package.json`:
22
+ 从 npm 装(发布版):
23
+
24
+ ```bash
25
+ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
26
+ # 然后重启 dsh --profile web 并刷新页面
27
+ ```
28
+
29
+ 本地开发用 `link:`(改源码即时生效)——`profiles/web/package.json`:
23
30
  - `dependencies` 加 `"@arcaneorion/dsh-model-channel-manager": "link:/home/arcaneorion/AI/AI-DSH/plugin/model-channel-manager"`
24
31
  - `dsh.profile.bundles` 加 `"@arcaneorion/dsh-model-channel-manager"`
25
32
  - `pnpm install` 后重启 `dsh --profile web`
@@ -29,6 +36,22 @@ DSH 原生模型渠道管理。两半结构:
29
36
  - `llm.providers` 出现 `roundrobin/<组id>`
30
37
  - settings describe 含 `model-channels` / `model-channel-health` 命名空间
31
38
 
39
+ ## 兼容性(DSH 版本)
40
+
41
+ 本包在 **DSH `0.1.1-rc.2`**(`dsh --version`)上开发与实测,宿主侧依赖按该版本**精确钉住**:
42
+
43
+ | 宿主包 | 声明 | 用途 |
44
+ |---|---|---|
45
+ | `@deepseek-ai/dsh-llm` | `0.1.1-rc.2` | `llm.registerAdapter` / `llm.stream`(轮询引擎与健康采集) |
46
+ | `@deepseek-ai/dsh-settings` | `0.1.1-rc.2` | `model-channels` / `model-channel-health` 命名空间读写 |
47
+ | `@deepseek-ai/dsh-client-connection` | `0.1.1-rc.2` | client 半的 `connection.api` 调用 |
48
+ | `@deepseek-ai/dsh-client-ui-conversation` | `0.1.1-rc.2` | `conversation.view` 页签座位 |
49
+ | `@deepseek-ai/cordis` | `^4.0.2` | 插件生命周期 |
50
+ | `@deepseek-ai/schemastery` | `>=3.18.2` | 配置 schema |
51
+
52
+ **换 DSH 版本(例如 `0.1.2-rc.1`)必须先重新验证、再放宽 peer**:宿主服务与座位契约跨版本会变,
53
+ 精确钉住的 peer 会在安装时报冲突——这正是它存在的意义,好过装上去静默失效。
54
+
32
55
  ## 数据通道(全走公共 seam,无私有 RPC)
33
56
 
34
57
  - 读配置/健康/运行态 = `api.settings.describe()` 过滤命名空间
@@ -50,6 +73,14 @@ DSH 原生模型渠道管理。两半结构:
50
73
 
51
74
  **不要把 `result.value` 当 `result` 读**——曾因少解一层导致整个面板静默空数据(describe 返回 namespaces 但全面板 0 provider,无任何错误提示)。
52
75
 
76
+ ## Token 用量统计(健康面板)
77
+
78
+ - 健康记录条目在既有字段(ts/provider/model/ok/ttftMs/latencyMs/code)上**增量附带**上游真实 token 用量:`inputTokens` / `outputTokens` / `cacheReadTokens` / `cacheWriteTokens` / `reasoningTokens`——来自适配器在 `finish` 前发出的 `usage` StreamChunk(rc.2 运行时 `StreamChunk` 契约,pi-ai `done`/`error` 事件都带);无 usage 则这些字段不写。
79
+ - **计费口径与 DSH `tokenMeter` 一致**:input + cacheRead + cacheWrite + output(互斥计数,`inputTokens` 不含缓存命中)。面板「总 Token 用量」卡按统计窗口求和,副行显示 输入/输出/缓存 拆分;每模型卡底部显示该模型窗口 Tokens。
80
+ - 采集点与健康记录**同址**(保证 token 与请求数同记录同窗口):全局 `llm/stream` 拦截器 + 轮询引擎 `streamAttempt`(成功/失败/超时路径都尽量携带;pi-ai 的 error 事件同样上报部分 usage)。
81
+ - 测速(⚡测速)/ 单模型测试(⚡测试)消耗的 token **不计入**——与「测速结果不入健康流水」的既有口径一致。
82
+ - 记录 schema 无需改动(`records` 为 `z.array(z.any())`),无新 RPC / settings 字段;client 5s 轮询自动刷新。
83
+
53
84
  ## 测试通道(模型可用性)
54
85
 
55
86
  - 模型行「⚡测试」→ 弹窗输入自定义问题 + maxTokens → 发送
@@ -79,6 +110,12 @@ DSH 原生模型渠道管理。两半结构:
79
110
  - 历史健康流水保留在原 ID 名下(历史存档不受影响)
80
111
  - 改名后仍需点击右上「保存全部变更」落盘
81
112
 
113
+ ## 密钥写入(凭据引用虚拟化)
114
+
115
+ - 面板主视图只出现「API Key」输入框:**粘贴或输入后失焦即自动写入** DSH 凭据存储(`~/.dsh/.credentials.yaml`,0600,write-only 读不回),无手动按钮;清空输入框不会删除已存 key。上游 llm-pi-ai 的供应商 profile 只有 `apiKeyEnv` 一个密钥字段(凭据引用名),不存在内联 key 的选项——secrets 不进 settings.yaml、不随 `settings.describe` 下发,是有意的安全设计。
116
+ - 「API Key 环境变量名」已收进供应商高级选项、更名「凭据引用名 (apiKeyEnv)」:新增供应商时自动生成(`normalizeCredentialRef`),并对 **ID + 引用双重去重**——改名供应商会保留旧引用(write-only 无法搬移),只按 ID 去重会复活 `provider-1` 并继承已被占用的 `PROVIDER_1_API_KEY`(两个供应商同引用 = 共用同一把 key,写入互相覆盖)。此坑已由双重去重修复,存量撞引用靠 ⚠ 警示提示手动处理(改其中一个引用 → 重新写入)。
117
+ - 环境优先级:启动 shell 同名变量(只读、优先)> 存储的 key > 项目 `.env` > 用户 `.env`(credentials-local 分层);想用环境注入直接在启动环境 export 即可。
118
+
82
119
  **曾踩坑**:`selected` 曾初始化为 `missing`(只含"可加"),而 configured 项 checkbox 显示 `checked:true` 却不在 selected 里——应用时 `kept = models.filter(m => cs.has(m.id))` 把已配置模型全部丢弃 → **已有模型消失**。修复 = selected 初始化为 `configured ∩ 端点`。
83
120
 
84
121
  ## host 半内部接口
@@ -86,6 +123,7 @@ DSH 原生模型渠道管理。两半结构:
86
123
  - settings 接入用 **`ctx.inject(['settings'], (sctx) => {...})`**(settings 服务异步初始化,apply 时 `ctx.get('settings')` 为 undefined——曾经整个引擎静默失效,命名空间从未注册)
87
124
  - 配置 schema(schemastery):`model-channels` 的虚模型/candidates/strategy/timeoutMs/cooldownMs/maxRetriesPerCandidate/speedTest;`model-channel-health` 的 records 7 天切片(单组 ≤2000 条)/speedResults/runtime/speedRequest+lastHandledNonce/testRequest+testResults+lastTestHandledNonce
88
125
  - 引擎:sticky/round-robin/primary 三策略;首响应超时 + 流中空闲超时(动态 = max(timeoutMs, min(120s, ttft×2)));单候选原地重试(指数退避)耗尽才换;全炸清冷却重试一轮;测速 ttft/latency/hybrid/smart 四键(smart = 0.5×ttft_norm + 0.3×(1−reliability) + 0.2×latency_norm,reliability 贝叶斯平滑 `(success+2.5)/(total+5)`);测速失败进冷却;请求隔离按组
126
+ - 虚拟模型元数据:`reasoning.efforts` 七档(off…max)、**defaultEffort=max**——原生 `/model` 弹窗对新模型的自动填档与展示跟随该声明;会话内显式档位的跨会话恢复由 selector 插件的档位记忆层负责(`modelDirectories` 拦截,存 `model-channels.effortMemory`)
89
127
  - 迁移:startup 时从工作区 `.channel-manager/config.json` 一次性迁入 `model-channels`(无遗留则忽略);完成后写 `legacyMigrated` 哨兵防止「清空组后重启复活」;fs 未就绪时 5s×6 重试
90
128
  - 遗留 `.channel-manager/` 目录不再使用
91
129
 
@@ -100,6 +138,7 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
100
138
 
101
139
  - 动态超时实现了首响应 + 流中空闲;全炸后「清冷却重试一轮」回溯,未实现「等待最早冷却」的睡眠分支
102
140
  - 测速结果不入健康流水(pi 记);smart 键只统计真实请求
141
+ - **Token 字段只在新记录上出现**:host 升级重启前的存量健康记录无 token 字段,7 天视图对重启前的调用会低估 token(请求数/可用率不受影响);数据自重启后开始累积
103
142
  - 配置里 provider 必须非虚拟路由(防自引用)
104
143
  - 轮询渠道/健康统计面板需要 host 新代码(重启后生效);健康流水的数据在**实际请求过轮询组**后才出现
105
144
  - `reasoningEfforts` 缺失(undefined)的 model 正确渲染(`|| {}` 兜底)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcaneorion/dsh-model-channel-manager",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -13,7 +13,7 @@
13
13
  "author": "arcanexis",
14
14
  "repository": {
15
15
  "type": "git",
16
- "url": "git+https://github.com/arcanexis/dsh-model-channel-manager.git"
16
+ "url": "git+https://github.com/ArcaneOrion/dsh-model-channel-manager.git"
17
17
  },
18
18
  "keywords": [
19
19
  "deepseek",
@@ -34,7 +34,13 @@
34
34
  "access": "public"
35
35
  },
36
36
  "peerDependencies": {
37
- "@deepseek-ai/schemastery": ">=3.18.2"
37
+ "@deepseek-ai/cordis": "^4.0.2",
38
+ "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
39
+ "@deepseek-ai/dsh-settings": "0.1.1-rc.2",
40
+ "@deepseek-ai/dsh-client-connection": "0.1.1-rc.2",
41
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.1-rc.2",
42
+ "@deepseek-ai/schemastery": ">=3.18.2",
43
+ "react": "^18.3.1"
38
44
  },
39
45
  "dsh": {
40
46
  "bundle": {
package/src/client.js CHANGED
@@ -549,7 +549,8 @@ window.__ModuleLoader__.load({
549
549
  const saveKey = (name) => {
550
550
  const ref = (providers[name] || {}).apiKeyEnv || ''
551
551
  const val = (keyInput[name] || '').trim()
552
- if (!ref || !val || !apiRef) return
552
+ if (!val || !apiRef) return
553
+ if (!ref) { setLive((l) => Object.assign({}, l, { [name + '_key']: 'err: 缺少凭据引用名(见高级选项)' })); return }
553
554
  savedKeys[name] = val
554
555
  apiRef.credentials.set({ ref, value: val }).then((resp) => {
555
556
  const r = resp && resp.result ? resp.result : resp
@@ -570,6 +571,13 @@ window.__ModuleLoader__.load({
570
571
  if (p.displayName && String(p.displayName).toLowerCase().includes(pqLower)) return true
571
572
  return (p.models || []).some((m) => m && ((m.id || '').toLowerCase().includes(pqLower) || (m.name || '').toLowerCase().includes(pqLower)))
572
573
  })
574
+ // 凭据引用占用计数:同一 apiKeyEnv 被多个供应商引用 = 共用同一把 key(任一处
575
+ // 「写入存储」覆盖共享引用,全部共引供应商随之同 key)——用于卡片 ⚠ 警示
576
+ const refUsage = new Map()
577
+ for (const p of Object.values(providers || {})) {
578
+ const ref = p && typeof p.apiKeyEnv === 'string' ? p.apiKeyEnv.trim() : ''
579
+ if (ref) refUsage.set(ref, (refUsage.get(ref) || 0) + 1)
580
+ }
573
581
  const cards = visibleEntries.map(({ name, p, realIdx }) => {
574
582
  const isOpen = pqLower ? true : !!exp[name]
575
583
  const models = p.models || []
@@ -619,21 +627,23 @@ window.__ModuleLoader__.load({
619
627
  sel('协议架构 (API)', '当前支持的请求格式', p.api || 'openai-completions', APIS, (v) => updateP(name, { api: v }))
620
628
  ),
621
629
  el('div', { className: 'mcm-row' },
622
- tf('Base URL', 'API 端点基础地址', p.baseURL || '', (v) => updateP(name, { baseURL: v }), true),
623
- tf('API Key 环境变量名', '凭据存储引用名', p.apiKeyEnv || '', (v) => updateP(name, { apiKeyEnv: v }), true)
630
+ tf('Base URL', 'API 端点基础地址', p.baseURL || '', (v) => updateP(name, { baseURL: v }), true)
624
631
  ),
625
- field('快速写入密钥', '将 API Key 安全存入 DSH 凭据存储', false,
632
+ field('API Key', '粘贴或输入后失焦即自动写入 DSH 凭据存储(~/.dsh/.credentials.yaml,0600,write-only 读不回);清空输入框不会删除已存 key;启动环境若有同名变量则其只读优先', false,
626
633
  el('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
627
- el('input', { type: 'password', className: 'mcm-in mono', style: { flex: 1 }, placeholder: '输入并覆盖 API Key (sk-...)', value: keyInput[name] || '', onChange: (e) => setKeyInput((k) => Object.assign({}, k, { [name]: e.target.value })) }),
628
- btn('写入存储', () => saveKey(name)),
629
- live[name + '_key'] === 'saved' ? el('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 12 } }, '✓ 已保存') : null
634
+ el('input', { type: 'password', className: 'mcm-in mono', style: { flex: 1 }, placeholder: '粘贴 API Key (sk-...),失焦自动保存', value: keyInput[name] || '', onChange: (e) => setKeyInput((k) => Object.assign({}, k, { [name]: e.target.value })), onBlur: () => { const v = (keyInput[name] || '').trim(); if (v && v !== savedKeys[name]) saveKey(name) } }),
635
+ live[name + '_key'] === 'saved' ? el('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 12 } }, '✓ 已保存') : (live[name + '_key'] && String(live[name + '_key']).indexOf('err') === 0 ? el('span', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12 } }, live[name + '_key']) : null)
630
636
  )
631
637
  ),
638
+ (p.apiKeyEnv && (refUsage.get(p.apiKeyEnv) || 0) > 1) ? el('div', { style: { color: 'var(--dsw-alias-state-warn-primary)', fontSize: 11, lineHeight: 1.6 } }, '⚠ 该引用被 ' + refUsage.get(p.apiKeyEnv) + ' 个供应商共用 = 共用同一把 Key,任一处写入会同时覆盖所有供应商;若非有意,请在高级选项中修改其中一个「凭据引用名」并重新写入') : null,
632
639
  el('div', { style: { borderTop: '1px dashed var(--dsw-alias-border-l2)', paddingTop: 10 } },
633
640
  el('div', { style: { cursor: 'pointer', fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary)', display: 'flex', alignItems: 'center', gap: 6 }, onClick: () => setSa((s) => Object.assign({}, s, { [name]: !s[name] })) },
634
641
  el('span', null, sa[name] ? '▼' : '▶'), '供应商高级选项 (Headers, 传输, 超时, 图片预算, 重试策略, Compat)'
635
642
  ),
636
643
  sa[name] ? el('div', { style: { display: 'flex', flexDirection: 'column', gap: 12, marginTop: 10 } },
644
+ el('div', { className: 'mcm-row' },
645
+ tf('凭据引用名 (apiKeyEnv)', 'key 在凭据存储(~/.dsh/.credentials.yaml)中的引用名,write-only 读不回已存值;启动环境同名变量只读优先;修改后需重新写入 key', p.apiKeyEnv || '', (v) => updateP(name, { apiKeyEnv: v }), true)
646
+ ),
637
647
  el('div', { className: 'mcm-row' },
638
648
  nf('默认 Context Window', '未单独配置模型时的默认窗口', p.defaultContextWindow || 0, (v) => updateP(name, { defaultContextWindow: v })),
639
649
  nf('默认 Max Tokens', '未单独配置模型时的最大输出', p.defaultMaxTokens || 0, (v) => updateP(name, { defaultMaxTokens: v }))
@@ -768,7 +778,14 @@ window.__ModuleLoader__.load({
768
778
  ...cards,
769
779
  visibleEntries.length === 0 && pqLower ? el('div', { className: 'mcm-empty' }, '无匹配「' + pq.trim() + '」的供应商或模型') : null,
770
780
  btn('+新增提供商 (Provider)', () => {
771
- const nm = uniqueSuffixName('provider-', (n) => Object.prototype.hasOwnProperty.call(providers, n))
781
+ // 双重去重:provider ID 与凭据引用都不得与现有草稿冲突。改名供应商会保留旧
782
+ // 引用(write-only 无法搬移),只按 ID 去重会复活 provider-1 并继承已被占用
783
+ // 的 PROVIDER_1_API_KEY——两个供应商同引用 = 共用同一把 key(写入互相覆盖)
784
+ const idsTaken = new Set(Object.keys(providers || {}))
785
+ const refsTaken = new Set(Object.values(providers || {}).map((p) => p && p.apiKeyEnv).filter(Boolean))
786
+ let i = 1
787
+ while (idsTaken.has('provider-' + i) || refsTaken.has(normalizeCredentialRef('provider-' + i + '_api_key'))) i++
788
+ const nm = 'provider-' + i
772
789
  const keyRef = normalizeCredentialRef(nm + '_api_key')
773
790
  setDraft((d) => Object.assign({}, d || {}, { [nm]: { api: 'openai-completions', baseURL: '', apiKeyEnv: keyRef, displayName: nm, models: [], headers: Object.assign({}, DEFAULT_HEADERS) } }))
774
791
  setExp((e) => Object.assign({}, e, { [nm]: true }))
@@ -823,10 +840,9 @@ window.__ModuleLoader__.load({
823
840
  const id = uniqueSuffixName('group-', (n) => base.some((g) => g && g.id === n))
824
841
  return base.concat([{
825
842
  id,
826
- // 默认呈现名 = 组 id:多个轮询组在目录/选择器里靠名字区分(固定
827
- // 'RoundRobin' 会全部撞名,实测反馈),需要别的名字在「虚拟模型呈现名」改;
828
- // 默认模态含 image:host 对当前会话模型做 resolveModelInfo,缺 image 时
829
- // 附加图片直接被拒(MODEL_DOES_NOT_SUPPORT_IMAGES),按直觉默认放开
843
+ // 单一身份:显示名 = 组唯一 ID(save 时统一归一化 virtualModel.name = id,
844
+ // 不会漂移)。默认模态含 image:host 对当前会话模型做 resolveModelInfo,
845
+ // 缺 image 时附加图片直接被拒(MODEL_DOES_NOT_SUPPORT_IMAGES),按直觉默认放开
830
846
  virtualModel: { name: id, reasoning: true, input: ['text', 'image'], contextWindow: 1048576, maxTokens: 131072 },
831
847
  candidates: [],
832
848
  strategy: 'sticky',
@@ -871,7 +887,7 @@ window.__ModuleLoader__.load({
871
887
  return el('div', { className: 'mcm-card', key: 'rr_card_' + i },
872
888
  el('div', { className: 'mcm-card-h', onClick: () => setExpanded((e) => Object.assign({}, e, { [i]: !e[i] })) },
873
889
  el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, isOpen ? '▼' : '▶'),
874
- el('div', { className: 'mcm-card-title' }, el('span', null, vm.name || g.id), el('span', { className: 'mcm-badge brand' }, 'roundrobin/' + g.id)),
890
+ el('div', { className: 'mcm-card-title' }, el('span', null, g.id), el('span', { className: 'mcm-badge brand' }, 'roundrobin/' + g.id)),
875
891
  el('span', { className: 'mcm-badge' }, (g.candidates || []).length + ' 候选'),
876
892
  el('span', { className: 'mcm-badge' }, g.strategy || 'sticky'),
877
893
  el('div', { style: { marginLeft: 'auto', display: 'flex', gap: 6 }, onClick: (e) => e.stopPropagation() },
@@ -882,8 +898,7 @@ window.__ModuleLoader__.load({
882
898
  ),
883
899
  isOpen ? el('div', { className: 'mcm-editor' },
884
900
  el('div', { className: 'mcm-row' },
885
- tf('组唯一 ID', '对应虚拟路由 roundrobin/<id>', g.id || '', (v) => patchGroup(i, { id: v }), true),
886
- tf('虚拟模型呈现名', '对话侧栏显示的名字', vm.name || '', (v) => patchGroupPath(i, ['virtualModel', 'name'], v))
901
+ tf('组唯一 ID', '对应虚拟路由 roundrobin/<id>,也是模型选择器里显示的名字(改这里即改名)', g.id || '', (v) => patchGroup(i, { id: v }), true)
887
902
  ),
888
903
  field('输入模态', '声明虚拟模型接受的输入;不含图片时附加图片会被 host 直接拒绝(MODEL_DOES_NOT_SUPPORT_IMAGES)', false,
889
904
  el('div', { style: { display: 'flex', gap: 16, alignItems: 'center', height: 32 } },
@@ -979,12 +994,18 @@ window.__ModuleLoader__.load({
979
994
  lastTs: 0,
980
995
  lastOk: null,
981
996
  lastCode: null,
982
- recentErrors: 0
997
+ recentErrors: 0,
998
+ tokSum: 0
983
999
  })
984
1000
  }
985
1001
  }
986
1002
  }
987
1003
 
1004
+ // 窗口级 token 聚合(计费口径 = input + cacheRead + cacheWrite + output,与 dsh-token-meter 一致)
1005
+ let totalTokIn = 0
1006
+ let totalTokOut = 0
1007
+ let totalTokCache = 0
1008
+
988
1009
  // 累加时间窗口内的真实流水记录
989
1010
  for (const e of allEvents) {
990
1011
  if (!e || !e.provider || !e.model) continue
@@ -1004,11 +1025,19 @@ window.__ModuleLoader__.load({
1004
1025
  lastTs: 0,
1005
1026
  lastOk: null,
1006
1027
  lastCode: null,
1007
- recentErrors: 0
1028
+ recentErrors: 0,
1029
+ tokSum: 0
1008
1030
  }
1009
1031
  pMap.set(e.model, a)
1010
1032
  }
1011
1033
  a.total++
1034
+ const tokIn = (e && e.inputTokens) || 0
1035
+ const tokOut = (e && e.outputTokens) || 0
1036
+ const tokCache = ((e && e.cacheReadTokens) || 0) + ((e && e.cacheWriteTokens) || 0)
1037
+ totalTokIn += tokIn
1038
+ totalTokOut += tokOut
1039
+ totalTokCache += tokCache
1040
+ a.tokSum += tokIn + tokOut + tokCache
1012
1041
  if (e.ok) {
1013
1042
  a.success++
1014
1043
  if (e.ttftMs != null && e.ttftMs >= 0) a.ttftSum += e.ttftMs
@@ -1088,6 +1117,14 @@ window.__ModuleLoader__.load({
1088
1117
  el('span', { className: 'mcm-metric-value' }, totalRequests),
1089
1118
  el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, '真实上游交互捕获')
1090
1119
  ),
1120
+ el('div', { className: 'mcm-metric-card' },
1121
+ el('span', { className: 'mcm-metric-label' }, '总 Token 用量'),
1122
+ el('span', { className: 'mcm-metric-value' }, (totalTokIn + totalTokOut + totalTokCache).toLocaleString('zh-CN')),
1123
+ el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } },
1124
+ '输入 ' + totalTokIn.toLocaleString('zh-CN') + ' · 输出 ' + totalTokOut.toLocaleString('zh-CN') +
1125
+ (totalTokCache > 0 ? ' · 缓存 ' + totalTokCache.toLocaleString('zh-CN') : '')
1126
+ )
1127
+ ),
1091
1128
  el('div', { className: 'mcm-metric-card' },
1092
1129
  el('span', { className: 'mcm-metric-label' }, '窗口可用率'),
1093
1130
  el('span', { className: 'mcm-metric-value', style: { color: totalSuccess === totalRequests ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-primary)' } }, globalRate),
@@ -1135,7 +1172,7 @@ window.__ModuleLoader__.load({
1135
1172
  )
1136
1173
  ),
1137
1174
  el('div', { style: { display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', borderTop: '1px solid var(--dsw-alias-border-l1)', paddingTop: 8 } },
1138
- el('span', null, 'TTFT: ', el('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, m.avgTtft)),
1175
+ el('span', null, 'TTFT: ', el('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, m.avgTtft), ' · Tokens: ', el('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, (m.tokSum || 0).toLocaleString('zh-CN'))),
1139
1176
  el('span', null, '最近: ', el('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, m.lastTimeStr))
1140
1177
  )
1141
1178
  ))
@@ -1350,7 +1387,9 @@ window.__ModuleLoader__.load({
1350
1387
  const orderPatch = draft ? { providerOrder: Object.keys(cleanProviders) } : {}
1351
1388
  const p2 = apiRef.settings.update({
1352
1389
  ns: 'model-channels',
1353
- patch: Object.assign({ groups: channelsDraft || [] }, orderPatch)
1390
+ // 命名单一身份:写入时统一 virtualModel.name = 组 id——旧的独立呈现名
1391
+ // (如遗留的 group-1)在下一次保存时自动归一,无需迁移
1392
+ patch: Object.assign({ groups: (channelsDraft || []).map((g) => Object.assign({}, g, { virtualModel: Object.assign({}, g.virtualModel, { name: g.id }) })) }, orderPatch)
1354
1393
  }).then((resp) => {
1355
1394
  const r = resp && resp.result ? resp.result : resp
1356
1395
  if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'model-channels save failed')
package/src/index.js CHANGED
@@ -266,9 +266,22 @@ export function apply(ctx) {
266
266
  const now = Date.now();
267
267
  const key = gid || 'global';
268
268
  const list = state.records[key] || (state.records[key] = []);
269
- list.push({ ts: now, provider: cand.provider, model: cand.model, ok: entry.ok, ttftMs: entry.ttftMs, latencyMs: entry.latencyMs, code: entry.code || null });
269
+ const rec = { ts: now, provider: cand.provider, model: cand.model, ok: entry.ok, ttftMs: entry.ttftMs, latencyMs: entry.latencyMs, code: entry.code || null };
270
+ // 上游真实 token 用量(来自 usage StreamChunk,计费口径与 dsh-token-meter 一致):
271
+ // 展平写入记录条目;缺失/非法字段不写,旧记录按 0 处理(向后兼容)。
272
+ const u = entry.usage;
273
+ if (u && typeof u === 'object') {
274
+ if (Number.isFinite(u.inputTokens)) rec.inputTokens = u.inputTokens;
275
+ if (Number.isFinite(u.outputTokens)) rec.outputTokens = u.outputTokens;
276
+ if (Number.isFinite(u.cacheReadTokens)) rec.cacheReadTokens = u.cacheReadTokens;
277
+ if (Number.isFinite(u.cacheWriteTokens)) rec.cacheWriteTokens = u.cacheWriteTokens;
278
+ if (Number.isFinite(u.reasoningTokens)) rec.reasoningTokens = u.reasoningTokens;
279
+ }
280
+ list.push(rec);
270
281
  const cutoff = now - 7 * 24 * 3600 * 1000;
271
- state.records[key] = list.filter((e) => e.ts >= cutoff).slice(-2000);
282
+ // 每键保留 300 条:健康页签 30m/24h 视图与选择器置顶绰绰有余,同时把
283
+ // settings.yaml 的体量压到可读(2000 条/键时该文件一度膨胀到 2 万行)
284
+ state.records[key] = list.filter((e) => e.ts >= cutoff).slice(-300);
272
285
  persistHealth();
273
286
  };
274
287
  const persistSpeedResults = async (r) => {
@@ -315,6 +328,7 @@ export function apply(ctx) {
315
328
  let started = false;
316
329
  let ttftMs = null;
317
330
  let lastAt = Date.now();
331
+ let lastUsage = null;
318
332
  const closeInner = async () => { try {
319
333
  const c = inner.return ? inner.return() : null;
320
334
  if (c && c.then)
@@ -341,6 +355,10 @@ export function apply(ctx) {
341
355
  }
342
356
  lastAt = Date.now();
343
357
  const chunk = next.value;
358
+ // 适配器在 finish 前发 usage 块(pi-ai done/error 都带);捕获后随记录写入,
359
+ // 与请求/健康计数保持同一记录、同一窗口口径
360
+ if (chunk && chunk.type === 'usage' && chunk.usage && typeof chunk.usage === 'object')
361
+ lastUsage = chunk.usage;
344
362
  if (chunk === undefined)
345
363
  throw { code: 'STREAM_CLOSED', message: 'channel stream ended without a terminal chunk' };
346
364
  if (!started) {
@@ -355,11 +373,11 @@ export function apply(ctx) {
355
373
  const reason = chunk.reason || {};
356
374
  // 引擎提前终止的内层流不会被全局拦截器完整排水记账,这里自行记录
357
375
  if (isSuccessReason(reason)) {
358
- recordHealth(cand.provider, cand, { ok: false, ttftMs: null, latencyMs: null, code: 'EMPTY_RESPONSE' });
376
+ recordHealth(cand.provider, cand, { ok: false, ttftMs: null, latencyMs: null, code: 'EMPTY_RESPONSE', usage: lastUsage });
359
377
  throw { code: 'EMPTY_RESPONSE', message: 'model returned a completed response with no content' };
360
378
  }
361
379
  const preCode = (reason.failure && reason.failure.code) || 'STREAM_ERROR';
362
- recordHealth(cand.provider, cand, { ok: false, ttftMs: null, latencyMs: null, code: preCode });
380
+ recordHealth(cand.provider, cand, { ok: false, ttftMs: null, latencyMs: null, code: preCode, usage: lastUsage });
363
381
  throw { code: preCode, message: (reason.failure && reason.failure.message) || 'candidate stream failed' };
364
382
  }
365
383
  else {
@@ -370,14 +388,14 @@ export function apply(ctx) {
370
388
  if (isTerminalChunk(chunk)) {
371
389
  const reason = chunk.reason || {};
372
390
  if (isSuccessReason(reason)) {
373
- recordHealth(cand.provider, cand, { ok: true, ttftMs, latencyMs: Date.now() - attemptStart, code: null });
391
+ recordHealth(cand.provider, cand, { ok: true, ttftMs, latencyMs: Date.now() - attemptStart, code: null, usage: lastUsage });
374
392
  yield chunk;
375
393
  return;
376
394
  }
377
395
  // 内容已向下游输出:绝不能再下发该失败终止块(否则出现 finish 后继续输出/双 finish),
378
396
  // 改为抛错并标记 emitted,由组层直接终结本次请求
379
397
  const midCode = (reason.failure && reason.failure.code) || 'STREAM_ERROR';
380
- recordHealth(cand.provider, cand, { ok: false, ttftMs, latencyMs: null, code: midCode });
398
+ recordHealth(cand.provider, cand, { ok: false, ttftMs, latencyMs: null, code: midCode, usage: lastUsage });
381
399
  throw { code: midCode, message: (reason.failure && reason.failure.message) || 'candidate stream failed mid-stream' };
382
400
  }
383
401
  yield chunk;
@@ -389,7 +407,7 @@ export function apply(ctx) {
389
407
  if (started && err && typeof err === 'object')
390
408
  err.emitted = true;
391
409
  if (err && err.code === 'TIMEOUT')
392
- recordHealth(cand.provider, cand, { ok: false, ttftMs, latencyMs: null, code: 'TIMEOUT' });
410
+ recordHealth(cand.provider, cand, { ok: false, ttftMs, latencyMs: null, code: 'TIMEOUT', usage: lastUsage });
393
411
  throw err;
394
412
  }
395
413
  }
@@ -503,7 +521,7 @@ export function apply(ctx) {
503
521
  // ---------- 测速 ----------
504
522
  async function measureCandidate(cfg, cand, st) {
505
523
  const llm = ctx.llm;
506
- const inner = llm.stream({ provider: cand.provider, model: cand.model, messages: [{ role: 'user', content: [{ type: 'text', text: st.prompt }] }], maxTokens: st.maxTokens });
524
+ const inner = llm.stream({ provider: cand.provider, model: cand.model, messages: [{ role: 'user', content: [{ type: 'text', text: st.prompt }] }], maxTokens: st.maxTokens, sessionId: 'mcm-speedtest-' + (st.nonce ?? Date.now()) });
507
525
  const start = Date.now();
508
526
  let ttft = null;
509
527
  const closeInner = async () => { try {
@@ -601,7 +619,10 @@ export function apply(ctx) {
601
619
  context: { contextWindow: cfg.virtualModel.contextWindow },
602
620
  defaultMaxTokens: cfg.virtualModel.maxTokens,
603
621
  inputModalities: cfg.virtualModel.input.slice(),
604
- reasoning: levels.length > 0 ? { efforts: levels.map((l) => ({ id: l, name: l })), defaultEffort: 'medium' } : undefined,
622
+ // defaultEffort=max:原生 /model 弹窗对新模型自动填与列表展示都跟随该声明
623
+ // (此前 medium 导致新会话选组即 medium);会话内显式档位由 selector 插件的
624
+ // 档位记忆层(modelDirectories 拦截)恢复
625
+ reasoning: levels.length > 0 ? { efforts: levels.map((l) => ({ id: l, name: l })), defaultEffort: 'max' } : undefined,
605
626
  };
606
627
  }
607
628
  const adapter = {
@@ -649,7 +670,7 @@ export function apply(ctx) {
649
670
  // ---------- 单模型真实请求测试(client 经 settings 总线下发,走 DSH 真实 llm.stream 链路) ----------
650
671
  async function runModelTest(provider, model, prompt, maxTokens) {
651
672
  const llm = ctx.llm;
652
- const inner = llm.stream({ provider, model, messages: [{ role: 'user', content: [{ type: 'text', text: prompt || '你好' }] }], maxTokens: maxTokens || 512 });
673
+ const inner = llm.stream({ provider, model, messages: [{ role: 'user', content: [{ type: 'text', text: prompt || '你好' }] }], maxTokens: maxTokens || 512, sessionId: 'mcm-modeltest-' + Date.now() });
653
674
  const start = Date.now();
654
675
  let ttft = null;
655
676
  let text = '';
@@ -700,12 +721,29 @@ export function apply(ctx) {
700
721
  const last = next.lastTestHandledNonce || 0;
701
722
  if (!req || typeof req.provider !== 'string' || typeof req.model !== 'string' || typeof req.nonce !== 'number' || req.nonce === last)
702
723
  return;
703
- settings.update(NS_HEALTH, { lastTestHandledNonce: req.nonce }).catch(() => { });
704
- // 结果写入走 mutate path-ops 原子设置单 nonce:read-merge-write 整包 update 在
705
- // 两个测试并发完成时会互相覆盖(读旧包→写入丢掉对方 nonce);path-ops 对
706
- // settings 队列中的 section 现势作用,不再依赖调用方快照
707
- const setResult = (entry) => {
708
- settings.mutate(NS_HEALTH, [{ op: 'set', path: ['testResults', String(req.nonce)], value: Object.assign({}, entry, { nonce: req.nonce, provider: req.provider, model: req.model }) }]).catch(() => { });
724
+ settings.update(NS_HEALTH, { lastTestHandledNonce: req.nonce }).catch((e) => console.error('[model-channel-manager] lastTestHandledNonce 写入失败:', e));
725
+ // 结果写入:首选 mutate path-ops 原子设置单 nonce(并发测试互不覆盖)。
726
+ // 实测踩坑(#21 同源):部分运行时(rc.2 file provider)会静默拒绝 mutate——
727
+ // promise 正常 resolve 但条目未落盘。故写后回读校验,未落上则回退 update
728
+ // 合并写(现势整段 + 本条目),保证结果必然可被 client 轮询读到。
729
+ let mutateWorks = null;
730
+ const setResult = async (entry) => {
731
+ const key = String(req.nonce);
732
+ const value = Object.assign({}, entry, { nonce: req.nonce, provider: req.provider, model: req.model });
733
+ const cur = (bus && bus.healthScope ? bus.healthScope.get().testResults : null) || {};
734
+ if (mutateWorks !== false) {
735
+ try {
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));
709
747
  };
710
748
  // 修剪低频执行:只在条目数超限时砍到 50(不在每次写入时整包重写)
711
749
  const maybePrune = () => {
@@ -713,17 +751,23 @@ export function apply(ctx) {
713
751
  const entries = Object.entries(all);
714
752
  if (entries.length <= 50) return;
715
753
  const drop = entries.sort((a, b) => ((b[1] && b[1].finishedAt) || 0) - ((a[1] && a[1].finishedAt) || 0)).slice(50).map(([k]) => ({ op: 'unset', path: ['testResults', String(k)] }));
716
- if (drop.length > 0)
717
- settings.mutate(NS_HEALTH, drop).catch(() => { });
754
+ if (drop.length === 0) return;
755
+ if (mutateWorks === false) {
756
+ const kept = {};
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));
718
762
  };
719
763
  const done = async (r) => {
720
764
  try {
721
- setResult(Object.assign({}, r, { finishedAt: Date.now() }));
765
+ await setResult(Object.assign({}, r, { finishedAt: Date.now() }));
722
766
  maybePrune();
723
767
  }
724
768
  catch (_e) { }
725
769
  };
726
- setResult({ status: 'running', startedAt: Date.now() });
770
+ void setResult({ status: 'running', startedAt: Date.now() });
727
771
  runModelTest(req.provider, req.model, req.prompt, req.maxTokens).then(async (r) => {
728
772
  await done(Object.assign({ status: 'ok' }, r));
729
773
  }).catch(async (e) => {
@@ -800,6 +844,7 @@ export function apply(ctx) {
800
844
  let ttft = null;
801
845
  let lastError = null;
802
846
  let isSuccess = false;
847
+ let usage = null;
803
848
  // 用户主动中止不是渠道故障:AbortError / code ABORTED / signal 已 aborted
804
849
  // 三种形态都不进健康流水,否则污染成功率与 smart 键的 reliability 权重
805
850
  const isAbortLike = (err) => {
@@ -813,6 +858,9 @@ export function apply(ctx) {
813
858
  };
814
859
  try {
815
860
  for await (const chunk of next()) {
861
+ // 适配器在 finish 前发 usage 块(done/error 都带);捕获后随记录写入
862
+ if (chunk && chunk.type === 'usage' && chunk.usage && typeof chunk.usage === 'object')
863
+ usage = chunk.usage;
816
864
  if (ttft === null && isContentChunk(chunk)) {
817
865
  ttft = Date.now() - startTs;
818
866
  }
@@ -835,6 +883,7 @@ export function apply(ctx) {
835
883
  ttftMs: isSuccess ? ttft : null,
836
884
  latencyMs: isSuccess ? latency : null,
837
885
  code: isSuccess ? null : (lastError || 'UNKNOWN_TERMINAL'),
886
+ usage,
838
887
  });
839
888
  }
840
889
  } catch (err) {
@@ -843,6 +892,7 @@ export function apply(ctx) {
843
892
  recordHealth(p, { provider: p, model: options.model }, {
844
893
  ok: false,
845
894
  code: (err && err.code) || (err && err.message) || 'EXCEPTION',
895
+ usage,
846
896
  });
847
897
  }
848
898
  throw err;