dsh-model-pool 0.0.0-stage → 0.1.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.
@@ -0,0 +1,331 @@
1
+ # dsh-model-pool
2
+
3
+ [![CI](https://github.com/BruceZhang1993/dsh-model-pool/actions/workflows/ci.yml/badge.svg)](https://github.com/BruceZhang1993/dsh-model-pool/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/dsh-model-pool.svg)](https://www.npmjs.com/package/dsh-model-pool)
5
+ [![npm downloads](https://img.shields.io/npm/dm/dsh-model-pool.svg)](https://www.npmjs.com/package/dsh-model-pool)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/BruceZhang1993/dsh-model-pool/blob/master/LICENSE)
7
+ ![Node.js >= 20](https://img.shields.io/badge/node-%E2%89%A520-brightgreen.svg)
8
+ [![DeepSeek Harness plugin](https://img.shields.io/badge/DeepSeek_Harness-plugin-4d6bfe.svg)](https://github.com/deepseek-ai/deepseek-harness)
9
+
10
+ [English](README.md) · **简体中文**
11
+
12
+ 一个把**所有已注册提供方聚合成一个模型池**的 DeepSeek Harness 插件。
13
+
14
+ 你只需选择模型*一次* —— 由池按照你选定的策略决定每次调用由哪个具体提供方来服务。某个提供方不可用时,池会跳过它、转移到下一个候选,并在 UI 中把它标记为不可用,直到它恢复。
15
+
16
+ ```
17
+ composer picker model pool concrete providers
18
+ ───────────────── ────────── ──────────────────
19
+ Model Pool
20
+ DeepSeek V4 Pro ──────► strategy + health gate ──────► deepseek-account / deepseek-v4-pro
21
+ DeepSeek Flash (priority, round-robin, workbuddy / deepseek-v4-pro
22
+ GLM 5.3 Flash least-used, fastest, …) workbuddy-global / …
23
+ …
24
+ thinking depth: high ─────► mapped to each target's nearest tier
25
+ ```
26
+
27
+ ## 为什么
28
+
29
+ 提供方之间存在重叠。`deepseek-v4-pro` 可能同时由第一方账号*和*第三方路由提供;`deepseek-v4.1-flash` 也可能存在于多个提供方上。今天你必须为每个会话选定一条路由,并记住哪条是健康的。这个插件把*模型*作为选择单位,把*提供方*变成实现细节。
30
+
31
+ ## 安装
32
+
33
+ **环境要求:** DeepSeek Harness 桌面版或 CLI,Node.js **>= 20**。无需构建步骤,也没有运行时依赖 —— Host 侧是纯 ESM,浏览器侧是手写的 `window.__ModuleLoader__` 模块,只 `require('react')`。两个 peer 依赖(`@deepseek-ai/dsh-llm`、`@deepseek-ai/schemastery`)由 Host 提供,因此插件旁边不需要额外安装任何东西。
34
+
35
+ ### DSH 桌面版
36
+
37
+ 打开**插件管理器**,在注册表中搜索 `dsh-model-pool` 并安装。之后重启 DSH 桌面版。
38
+
39
+ ### CLI profile
40
+
41
+ ```sh
42
+ dsh plugin --profile web add dsh-model-pool # latest
43
+ dsh plugin --profile web add dsh-model-pool@0.1.0 # pin a version
44
+ ```
45
+
46
+ `dsh plugin` 在 profile 目录内委托给 pnpm,并把该 bundle 选入 `dsh.profile.bundles`。之后重启该 profile。
47
+
48
+ ### 本地检出
49
+
50
+ ```sh
51
+ dsh plugin --profile web add /absolute/path/to/dsh-model-pool
52
+ ```
53
+
54
+ 这是开发用的安装方式:pnpm 会链接本地检出而不是下载,所以下次重启就能加载你的改动。
55
+
56
+ ### 验证
57
+
58
+ - **设置 → 模型池**存在,并列出每个池内模型及其提供方数量、思考深度和各路由的状态标记。
59
+ - 输入区的模型选择器里有 **Model Pool** 条目;选中其中一个模型后,每次调用都会经过池路由。
60
+
61
+ 两半都在同一个包里:`dsh.bundle.patch` 是 Host 侧(`index.js` + `lib/`),`dsh.client` 是浏览器侧(`client.js`)。安装时不做任何构建。
62
+
63
+ 完整指南(含故障排查)见 [`docs/installation.md`](https://github.com/BruceZhang1993/dsh-model-pool/blob/master/docs/installation.zh-CN.md)。
64
+
65
+ ## 使用
66
+
67
+ 1. 打开**设置 → 模型池**。这里列出每个池内模型、有多少提供方为它服务、它的思考深度,以及逐路由的状态标记。
68
+ 2. 选择**策略**(见下文)和行为开关。你也可以在**峰谷时段(peak / idle)**卡片里为高峰和空闲时段分别设置提供方排序;该卡片还会显示当前时段、下一次切换的时间,以及插件自动保持同步的法定节假日日历 —— 无需手工填写任何内容。
69
+ 3. 在输入区的模型选择器中,选一个**模型池**模型和思考深度。
70
+ 4. 每次调用都会被路由;失败会冷却某条路由并转移到下一个候选。
71
+
72
+ ## 策略
73
+
74
+ | 策略 | 作用 | 适用场景 |
75
+ |---|---|---|
76
+ | `priority` | 按配置的优先级顺序依次尝试提供方。 | 有明确的首选(如第一方)并需要备份时。 |
77
+ | `round-robin` | 每次调用轮换起始提供方。 | 在等价路由之间均匀分摊负载。 |
78
+ | `weighted-random` | 按各提供方权重随机抽取。 | 希望流量主要留在偏好路由上,但不做硬绑定。 |
79
+ | `least-used` | 优先选择已服务调用最少的提供方。 | 无需严格轮换的均衡。 |
80
+ | `fastest` | 优先选择近期首块延迟最低的提供方。 | 对延迟敏感的工作;提供方变慢时会自适应。 |
81
+ | `cheapest` | 优先选择声明成本提示最低的提供方。 | 对成本敏感的批量工作。 |
82
+
83
+ `priority`、`least-used`、`fastest` 和 `cheapest` 是稳定的:key 相同时保持优先级顺序,因此路由永远不会变得不可预测。`fastest` 会从真实调用中学习(首块延迟的 EWMA),所以每条路由上的第一次调用相当于一次探测。
84
+
85
+ ### 让同一会话固定在同一条路由
86
+
87
+ `sticky`(默认开启)会把一个会话固定到服务其首次调用的那条路由:
88
+
89
+ | | 未开启 sticky | 开启 sticky |
90
+ |---|---|---|
91
+ | `round-robin`,一个会话四轮 | `A B C A` | `A A A A` |
92
+ | 第二个会话 | 继续轮换 | 自己选一条路由,然后保持 |
93
+
94
+ 重点在于提供方的 prompt 缓存:每轮都重新决策会丢弃已缓存的前缀,长会话于是比需要的更慢、更贵。开启 sticky 后,切换提供方变成每个会话一次,而不是每条消息一次。
95
+
96
+ 这是一种偏好,不是锁:
97
+
98
+ - **失败转移仍然有效。** 如果被固定的路由在产生输出前失败,调用会转移到下一个候选,而*存活下来的*那条成为新的固定路由。
99
+ - **不健康的路由会被跳过。** 处于冷却期内的固定路由不再匹配,策略会重新决策。
100
+ - **换一个池内模型会重新开始。** 用另一个模型重新选择池,不会继承前一个模型的提供方。
101
+ - **只有池调用参与。** 从未选择池的会话不受影响。
102
+
103
+ 在需要按调用分摊(在多个账号间共享配额)而不是复用缓存时,可在**设置 → 模型池 → 行为**里关闭它。
104
+
105
+ ### 设置提供方优先级
106
+
107
+ `priority` 策略按 `providerOrder` 对提供方排序 —— 一个从高到低的提供方 id 列表。**设置 → 模型池 → 提供方优先级**会按显示名称列出所有已注册提供方;用箭头按钮调整顺序并保存。你不需要在那里输入 id。列表只包含当前真的为池贡献模型的提供方 —— 没有模型的提供方无法被路由到,给它排序不会改变任何结果。同样的排序也可以写在 `cordis.patch.yml` 里:
108
+
109
+ ```yaml
110
+ providerOrder: [deepseek-account, workbuddy, our-free-model]
111
+ ```
112
+
113
+ 未配置任何内容时,列表显示的是原始注册顺序,这也正是路由器的回退行为。未列出的提供方按注册顺序排在已列出的提供方之后,因此部分列表是有效的 —— 你只需把首选提到前面,其余都留在它后面。保存未改动的注册顺序会存成空排序,而不是一份冗余的完整列表。已注册但当前没有模型可服务的提供方仍会显示,因为它已注册;已消失的提供方会被保留并标记出来,而不是被静默丢弃。
114
+
115
+ 同一份列表也决定 `least-used`、`fastest` 和 `cheapest` 的平局决胜顺序,以及失败转移后的回退顺序。
116
+
117
+ ### 峰谷时段(peak / idle)
118
+
119
+ 同一个提供方在一天中不同时刻的吸引力并不相同。第一方账号在工作时间之外可能又便宜又快,但在工作时间内会被限额;付费备份往往正好相反。因此池可以根据当前时刻处于**高峰**还是**空闲**,对提供方采用不同的排序。
120
+
121
+ 出厂默认是一套中国工作时段策略:
122
+
123
+ | 方面 | 默认值 |
124
+ |---|---|
125
+ | 时区 | `Asia/Shanghai`(北京时间) |
126
+ | 高峰日 | 周一至周五 |
127
+ | 高峰时段 | `09:00-12:00` 和 `14:00-18:00` |
128
+ | 高峰例外 | 官方调休上班日 —— 需要补班的周六算工作日 |
129
+ | 其他一切 | **空闲** —— 周末全天、法定节假日全天,再加上午休、傍晚和夜间 |
130
+
131
+ 北京时间的周五 19:00、任意普通周六,以及整个国庆周都属于空闲;而国务院安排补班的那个周六不是。
132
+
133
+ 有两份列表承载排序,它们遵循与 `providerOrder` 相同的规则:高者在前、允许部分列表、空列表表示*该时段没有意见* —— 它会继承 `providerOrder`,而不是清空排序。正因为如此,这个特性在你配置之前是完全惰性的:两个列表都为空时,路由与之前完全一致。
134
+
135
+ ```yaml
136
+ peakProviderOrder: [workbuddy] # ranking while peak; empty = inherit providerOrder
137
+ offPeakProviderOrder: [deepseek-account] # ranking while idle; empty = inherit providerOrder
138
+ ```
139
+
140
+ 可在**设置 → 模型池 → 峰谷时段(peak / idle)**中设置(提供与提供方优先级相同的箭头按钮编辑器),也可写在 `cordis.patch.yml` 中。由于这层覆盖在策略运行*之前*就重排了候选,`priority` 会直接跟随它,`least-used`、`fastest` 和 `cheapest` 把它作为平局决胜继承 —— 与 `providerOrder` 的工作方式相同。`round-robin` 按时段顺序轮换,`weighted-random` 仍按权重抽取,只用该顺序来决定权重相同和权重为零的路由。
141
+
142
+ 日期本身也可配置:
143
+
144
+ ```yaml
145
+ peakTimeZone: Asia/Shanghai
146
+ peakWindows: ['09:00-12:00', '14:00-18:00'] # 'HH:MM-HH:MM'; 24:00 is a valid end
147
+ peakWeekdays: [1, 2, 3, 4, 5] # ISO: 1 = Monday … 7 = Sunday
148
+ peakMakeupWorkdays: [] # extra 调休上班日 on top of the fetched ones
149
+ ```
150
+
151
+ 这里刻意**没有**节假日列表。法定节假日和调休上班日来自一个持续维护的数据集,并由插件自动保持更新,所以没有人需要输入日期,也不会因为国务院公布明年的安排而过期 —— 见下文。
152
+
153
+ #### 法定节假日自动同步
154
+
155
+ 一个已经排定的中国日历无法由规则推导:调休每年都会挪动日期。因此插件把日历当作数据来读取,来自 [`holiday-cn`](https://github.com/NateScarlet/holiday-cn)(NateScarlet,MIT)—— 每年一个 JSON 文档,由国务院通知生成,并在 `papers` 字段中带有该通知的 URL。这是一个值得了解的真实依赖:本插件不自带任何节假日表。
156
+
157
+ ```jsonc
158
+ { "year": 2026,
159
+ "papers": ["https://www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm"],
160
+ "days": [ { "name": "元旦", "date": "2026-01-01", "isOffDay": true }, // 法定节假日
161
+ { "name": "元旦", "date": "2026-01-04", "isOffDay": false } ] } // 调休上班日
162
+ ```
163
+
164
+ 该文档从 jsDelivr 获取,CDN 不可达时回退到 `raw.githubusercontent.com`,并缓存到 `$DSH_HOME/model-pool/calendar.json`(未设置 `DSH_HOME` 时位于 `~/.dsh` 下)。后台刷新每 `holidayUpdateDays`(7)运行一次,且只在缓存早于 `holidayCacheDays`(30)或策略可达的某一年缺失时才重新获取 —— 第二个条件正是让 11 月发布的通知无需重启即可生效的原因。年龄阈值在每次同步时读取;后台节奏在插件加载时固定,因此修改它会在下次重载时生效。数据集尚未发布的那一年 —— 写作时的 2027 —— 会返回空日期,那些天就按星期规则处理。这是保守的读法:凭空发明一个节假日会悄悄把提供方移出轮转。
165
+
166
+ 获取到的日历包含两部分安排:放假的日子是空闲,官方调休上班日是工作日,因此补班的周六在正常时段内属于高峰,而其他任何周六都是空闲。`peakMakeupWorkdays` 仍作为普通设置存在,只是给你自己额外加一天用的逃生口 —— 它会与获取到的补班日取并集。既是节假日又是补班工作日的日期按空闲处理:法定节假日优先。
167
+
168
+ **网络永远不能破坏路由。** 每个路由决策都读取内存中的日历;获取失败会保留缓存并记录错误,而既无缓存又无网络的首次运行会回退到单纯的星期规则,而不是让调用失败。设置页会显示这次失败,并提供**立即同步**按钮来马上重试。
169
+
170
+ 设置卡片中的节假日区块**只报告当前年份** —— 状态(已同步 / 已过期 / 未同步 / 失败)、该年的节假日与补班天数、上次更新时间、来源 id 以及官方通知 URL —— 另有一个保持自动更新的开关和一个**立即同步**按钮。其他已加载年份仍留在返回数据里,因为路由需要它们(高峰前瞻会跨年),但卡片只显示一年,这样这些数字回答的是“今天的日历对不对”,而不是“缓存了多少历史”。国务院尚未公布的那一年会显示**尚未发布 / Not published yet**,而不是“0 个节假日”。`holidayOverride` 是唯一可手工编辑的列表:它在获取到的日历之上追加额外的**空闲**日期,用于一次性更正,刷新永远不会覆盖它。
171
+
172
+ `holidaySync: false`(或 `holidaySource: 'off'`)会完全关闭日历:获取到的日期不再影响路由,只剩 `holidayOverride` 和手工的 `peakMakeupWorkdays` 列表,其他每一天都按星期规则处理。`holidaySource` 通常是一个来源 id(`holiday-cn`);给它一个完整的 `https://` URL,或把 `holidayUrlTemplate` 设为带 `{year}` 的模板,就能把同步指向你自己的镜像。
173
+
174
+ 它上方的时段区块显示实时读数 —— 当前时段、本地日期与时间、已配置的时段窗口,以及时段下一次翻转的时刻(周五傍晚会报告周一 09:00,而不是下一个午夜)—— 这样你能看到实际生效的是哪套排序。今天若是法定节假日,或是一个补班工作日而策略仍把它当作空闲,它也会给出提示。`peakEnabled: false` 会完全关闭这层覆盖。
175
+
176
+ #### 与 sticky 如何配合
177
+
178
+ 峰谷排序不会与 `sticky` 冲突,而是与它组合。开启 sticky(默认)时,时段决定会话**何时开始**,而不是它每一轮怎么做:一个在空闲排序下开始的会话,在高峰时段开启后仍会保持那个提供方,因为 sticky 固定的是服务其首次调用的路由,而它本身已经以同样的方式覆盖了 `providerOrder`。这正是 sticky 的意义所在 —— 每轮重新决策会丢弃提供方的 prompt 缓存。关闭 sticky 后,提供方会恰好在时段边界切换;无论哪种情况,新会话都会在首次调用时遵守当时的时段。
179
+
180
+ ## 思考深度
181
+
182
+ 池对外公布其各候选思考深度的**并集**。你可以选任意深度;路由器会把它映射到目标实际支持的**最近层级** —— 优先精确匹配,其次取层级距离最小者,距离相同时取更高层级。因此在一个只提供 `off/high` 路由的池上选 `max`,该次尝试会变成 `high`,而不是以 `UNSUPPORTED_REASONING_EFFORT` 失败。
183
+
184
+ 池还会对外公布各候选中的**最小**上下文窗口,因此能放进池的请求一定也能放进它可能转移到的每一条路由。
185
+
186
+ ## 图片与其他输入
187
+
188
+ 池对外公布其各候选输入模态的**并集**,然后把每次调用路由到真正能接收它的候选。选一个路由中包含具备图片能力提供方的池内模型,你就可以给它附截图,而不必知道或关心这一轮由哪个提供方服务。
189
+
190
+ 这是有意为之,而过去恰好相反。对外公布的能力集是 Host 在调用存在*之前*就检查的东西:当所选模型的 `inputModalities` 存在且不含 `image` 时,带图片的 prompt 会直接以 `MODEL_DOES_NOT_SUPPORT_IMAGES` 被拒绝。因此公布候选的**交集**会让一个多数路由都能读图的模型拒绝所有上传 —— 只要有一个纯文本候选(某个区域网关、某个免费额度)就足以让整个池看起来是瞎的。公布**并集**则允许你附上图片,随后路由器会兑现这个承诺:
191
+
192
+ - 纯文本调用保留所有候选,池因此仍可自由均衡与转移。
193
+ - 携带图片的调用会在策略、冷却与 sticky 层运行*之前*被收窄到接受图片的路由,截图因此不会悄悄变成占位符。
194
+ - 未声明任何能力的候选在图片调用中被视为纯文本。未知不等同于有能力,猜错的代价是整个调用。
195
+ - 如果唯一具备图片能力的路由在失败后正处于冷却,仍会优先于健康的纯文本路由被尝试:一条可能已恢复的路由,胜过一条从未见过图片的路由给出的回答。
196
+ - 如果过期的快照里没有任何候选具备能力,池仍会尝试而不是拒绝:运行时随后会把图片降级为占位符,这是可恢复的,而不是让调用失败。
197
+
198
+ 设置 → 模型池会显示哪些候选接受图片,因此一个能力来自单一路由的池是可见的,而不是隐含的。
199
+
200
+ ## 在会话中查看实际路由
201
+
202
+ 池内模型是一条虚拟路由,否则输入区只会显示“Model Pool · DeepSeek V4.1 Flash”,完全不提真实上游。插件在输入区停靠栏(内置的轮次与 token 统计旁边)增加一个条目,显示**当前会话**实际运行在什么之上:
203
+
204
+ ```
205
+ 实际路由 workbuddy deepseek-v4.1-flash max DeepSeek V4.1 Flash
206
+ ```
207
+
208
+ - **提供方** —— 实际产出内容的具体提供方路由。
209
+ - **模型** —— 该提供方自己的模型 id,通常不是池 id。
210
+ - **思考深度** —— 实际使用的 effort(当该路由声明了它时)。
211
+ - **池模型** —— 你选择的那一行,用于提供上下文。
212
+
213
+ 当会话从未经过池时,该条目不渲染任何内容,因此普通会话不受影响。它会跟随失败转移:如果第一个候选在产生输出前失败,标记显示的是真正作答的提供方,而不是最先尝试的那个。该值按会话记录,所以同时打开的两个会话各显示自己的路由。
214
+
215
+ 该标记描述的是**会话**的调用。复用同一条池内路由的辅助调用 —— 会话标题生成、压缩摘要 —— 会自行选择思考深度(通常是 `off`),永远不会定义该条目,因此一次标题调用不可能让一个以 `High` 运行的会话报告 `Low`。记录在答案的终止 chunk 产生的那一刻就确定,因此重写或重新包装答案流的中间件也无法留下过期值。
216
+
217
+ 输入区停靠栏每隔几秒轮询一次,因此该值会在一轮结束后不久稳定下来。它只存在于当前 Host 世代 —— 重启会清空它,条目会在下一次池调用时重建。
218
+
219
+ ## 可用性标记
220
+
221
+ 每条具体路由都带有一个状态:
222
+
223
+ | 状态 | 含义 |
224
+ |---|---|
225
+ | `ok` | 上一次尝试成功。 |
226
+ | `degraded` | 至少失败过一次;冷却已结束,因此重新可参与路由。 |
227
+ | `unavailable` | 失败且仍在冷却窗口内;在还有其他可用路由时被跳过。 |
228
+ | `unknown` | 当前 Host 世代中从未使用过。 |
229
+
230
+ 失败的路由会连同失败码(例如 `AUTH`、`RATE_LIMIT`、`QUOTA`)被记录,并在 `cooldownMs` 内退出轮转。如果**所有**候选都在冷却,池仍会按策略顺序尝试它们 —— 短暂的故障不应变成硬失败。**重置健康状态**会清除这些标记。
231
+
232
+ ## 失败转移规则
233
+
234
+ - 只有在**尚未有任何输出到达你**时,调用才会转移。一旦文本或工具调用已经流式送达,错误就按原样上报:部分答案永不被重放。
235
+ - `maxAttempts` 限制一次调用最多尝试多少个提供方。
236
+ - 设置 `fallback: false` 时,第一个候选就是唯一候选。
237
+
238
+ ## 配置
239
+
240
+ 设置 → 模型池涵盖了日常可调项。其余设置位于 `cordis.patch.yml` 中本插件自己的行里,因此升级后仍然保留:
241
+
242
+ ```yaml
243
+ - id: model-pool
244
+ name: dsh-model-pool
245
+ config:
246
+ strategy: least-used
247
+ fallback: true
248
+ skipUnavailable: true
249
+ cooldownMs: 60000
250
+ maxAttempts: 3
251
+ include: [] # provider allowlist; empty = all
252
+ exclude: [workbuddy] # provider denylist
253
+ excludeModels: [] # model ids or substrings
254
+ providerOrder: [deepseek-account, workbuddy] # priority ranking, highest first
255
+ peakEnabled: true
256
+ peakTimeZone: Asia/Shanghai
257
+ peakWindows: ['09:00-12:00', '14:00-18:00'] # peak windows; empty = never peak
258
+ peakWeekdays: [1, 2, 3, 4, 5] # ISO 1 = Mon … 7 = Sun; empty = never peak
259
+ peakMakeupWorkdays: [] # extra 调休上班日; the fetched ones are automatic
260
+ peakProviderOrder: [workbuddy] # ranking while peak; empty = inherit
261
+ offPeakProviderOrder: [deepseek-account] # ranking while idle; empty = inherit
262
+ # Statutory calendar: fetched, not configured. Defaults shown; rarely changed.
263
+ holidaySync: true # keep the calendar up to date automatically
264
+ holidaySource: holiday-cn # or a source id, or 'off'
265
+ holidayUrlTemplate: '' # '{year}' template; empty = source default
266
+ holidayCacheDays: 30 # refetch when the cache is older than this
267
+ holidayUpdateDays: 7 # background refresh cadence
268
+ holidayOverride: [] # one-off corrections, layered on the fetch
269
+ weights: { deepseek-account: 3, workbuddy: 1 }
270
+ costs: { deepseek-account: 0.4, workbuddy: 0.1 }
271
+ modelGroups:
272
+ - id: deepseek-v4.1-flash
273
+ name: DeepSeek V4.1 Flash
274
+ models: [deepseek-v4.1-flash*, deepseek-flash]
275
+ merge: true
276
+ - id: fast-coding
277
+ name: fast-coding
278
+ models: [deepseek-v4.1-flash, glm-5.3-flash]
279
+ merge: false
280
+ ```
281
+
282
+ ### 合并同一模型的多个提供方 id
283
+
284
+ 同一个模型在每个提供方上通常有不同的 id —— 一个账号叫它 `deepseek-flash`,另一个叫 `deepseek-v4.1-flash`,第三个叫 `opencode/deepseek-v4.1-flash-free`。不加处理的话,它们会变成三行独立的池条目,这违背了池的意义。
285
+
286
+ `modelGroups` 解决这个问题。一个组列出**认领**匹配路由并把它们呈现为单行的一组选择器:
287
+
288
+ | 选择器形式 | 匹配对象 |
289
+ |---|---|
290
+ | `deepseek-v4.1-flash` | 具体模型 id,或归一化后的池 id |
291
+ | `workbuddy/deepseek-v4.1-flash` | 一条精确的 `provider/model` 路由 |
292
+ | `deepseek-v4.1-flash*` | 上述任意一种的 glob(`*` = 任意串,`?` = 单个字符) |
293
+
294
+ `merge: true`(默认)时,被认领的路由会离开它们的单 id 行,池因此每个逻辑模型只显示一行。自然行若因此没有路由则消失。当你想要一个*额外*的策展池(“fast-coding”)并希望自动行保留时,设置 `merge: false`。
295
+
296
+ 选择器按先到先得匹配:如果两个组认领同一条路由,靠前的组获胜,靠后的组从它那里什么也得不到。
297
+
298
+ 默认自带两个组,因此开箱即用 —— `deepseek-v4.1-flash*` + `deepseek-flash` 合并为 **DeepSeek V4.1 Flash**,`glm-5.3-flash*` 合并为 **GLM 5.3 Flash**。可在**设置 → 模型池 → 合并组**中编辑它们,或替换 `cordis.patch.yml` 中的列表。改动即时生效:无需重启。
299
+
300
+ ## 工作原理
301
+
302
+ Host 的 `llm` 服务暴露提供方路由;输入区的模型选择器与设置页通过 `listProviders()` / `listModels()` / `resolveModelInfo()` 读取它们。本插件:
303
+
304
+ 1. 注册**一条额外的虚拟路由**(`model-pool`),它的模型是所有其他提供方模型的并集,按归一化模型名分组。这正是池能出现在普通选择器(含思考深度菜单)中的原因。每个池内模型公布其候选输入模态的并集(见[图片与其他输入](#图片与其他输入)),因此只要它的*任意*提供方能读图,池内模型就能接收截图。
305
+ 2. 监听 **`llm/stream` waterfall**。指定池路由的请求会在派发*之前*被拦截,并以策略选出的具体提供方重新发出。携带图片的请求只考虑接受图片的候选,因此公布的能力被兑现,而不仅仅是被声明。运行时随后为真实目标执行唯一一次正确的文件、图片与工具历史投影。
306
+ 3. 为每条具体路由维护一个**健康登记表**,它同时驱动失败转移闸门和设置页的标记。
307
+
308
+ 适配器自身的 `stream()` 永不执行;它存在只是为了让目录把它暴露出来。如果它真的被调用,会以清晰的错误失败,而不是静默返回空。
309
+
310
+ 已核验的 API 证据与完整模块契约见 [`docs/CONTRACT.md`](https://github.com/BruceZhang1993/dsh-model-pool/blob/master/docs/CONTRACT.md)。
311
+
312
+ ## 适用范围与限制
313
+
314
+ - 池状态(健康、轮询游标)按 Host 世代存在:重启会清空它。获取到的节假日日历是例外 —— 它缓存在磁盘上,因此重启时读取缓存而不是重新下载。首次同步需要网络访问;在成功之前,法定节假日只是未知,每个工作日都按星期规则处理。
315
+ - 池会在 `llm/adapters-updated` 时重读提供方拓扑,因此安装或移除提供方会无需重启地更新池。
316
+ - 修改 `providerId` / `providerName` 需要重启才能重新注册路由。
317
+ - 路由按调用而非按会话:同一轮中的两次调用可能使用不同提供方。这对均衡是有意的,但也确实意味着提供方侧的 prompt 缓存在均衡的池中无法共享。如果缓存复用比均衡更重要,请使用 `priority`。
318
+ - 池内模型的图片能力是其候选的并集,因此附过图片的会话在该轮失败转移时仍能继续 —— 但*具体*路由是按调用收窄的,这意味着 sticky 会话可能为图片轮被移到另一个提供方,之后再移回。sticky 是缓存复用的偏好,不是能力的保证。
319
+ - 池无法知道超出其提供方所声明的信息。未公布 `inputModalities` 的路由在图片调用中被视为纯文本;如果这样的提供方实际上是能读图的,池在它声明该能力之前不会用它处理图片。
320
+
321
+ ## 开发
322
+
323
+ ```
324
+ node --test test/*.test.mjs # pure logic: grouping, strategies, health, effort,
325
+ # capabilities, peak, calendar
326
+ npm run check
327
+ ```
328
+
329
+ ## 许可证
330
+
331
+ MIT —— 见 [`LICENSE`](https://github.com/BruceZhang1993/dsh-model-pool/blob/master/LICENSE)。