@apinggithub/mydsh-auto-model-router 0.0.0-stage → 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.zh.md ADDED
@@ -0,0 +1,413 @@
1
+ # mydsh-auto-model-router
2
+
3
+ 一个适配 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)的混合式自动模型路由插件。通过 **L0–L3 四个能力层级**为每个 agent 请求选择模型——日常问答、代码测试、编写审查、复合多步任务——支持用户可配置的成本策略、可选的 LLM 任务分类器、以及失败降级链。
4
+
5
+ > 设计思路参考 [open-world-project/model-router](https://github.com/open-world-project/model-router)(Hermes Agent)与 [opencode-model-router](https://github.com/marco-jardim/opencode-model-router)(fast/medium/heavy 分层):层级化路由 + 成本模式决定模糊时的偏移方向。
6
+
7
+ ## 致谢
8
+
9
+ 本插件的思路来自以下开源项目:
10
+
11
+ - [open-world-project/model-router](https://github.com/open-world-project/model-router) — Hermes Agent 的自动模型路由器,含关键词评分和成本感知分层选择
12
+ - [opencode-model-router](https://github.com/marco-jardim/opencode-model-router) — OpenCode 的 fast/medium/heavy 分层委托插件
13
+ - [openclaw-routing-yaml](https://github.com/glasshousehq-os/openclaw-routing-yaml) — OpenClaw 的声明式 YAML 按任务路由
14
+ - [openmarkai/openclaw-router](https://github.com/openmarkai/openclaw-router) — 基于基准测试驱动的模型路由
15
+ - [MoeAisaka/openclaw-model-policy-router](https://github.com/MoeAisaka/openclaw-model-policy-router) — OpenClaw 的策略驱动 fail-closed 路由
16
+ - [NanmiCoder/dsh-auto-mode](https://github.com/NanmiCoder/dsh-auto-mode) — DSH 的自动权限模式;客户端设置页实现参考
17
+ - [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — 本插件所适配的宿主平台
18
+
19
+ - [English](README.md)
20
+ - 中文(本文档)
21
+
22
+ ## 工作原理
23
+
24
+ ```
25
+ 用户消息
26
+ │
27
+ ▼
28
+ ① agent/pre-step 任务文本 + token 记账
29
+ │
30
+ ▼
31
+ ② system-prompt/assemble 让 {{model}} 与路由结果保持一致
32
+ │
33
+ ▼
34
+ ③ agent/request ★ 决策点:
35
+ │
36
+ ├─ Tier 1 规则: 子串 / 正则匹配 → 层级(L0…L3)——永远优先
37
+ ├─ Tier 2 成本: 预算耗尽 → 钉到最便宜层级;
38
+ │ 未命中规则 → 模式决定默认层级
39
+ ├─ Tier 3 分类器: 可选便宜 LLM → 层级;层级缺模型 → 按模式漂移
40
+ └─ Tier 4 降级: 请求失败 → 沿链切换模型并重试
41
+ │
42
+ ▼
43
+ LLM 分发
44
+ ```
45
+
46
+ ### 四个层级
47
+
48
+ | 层级 | 典型任务 | 模型建议 |
49
+ | --- | --- | --- |
50
+ | `L0` | 日常问答、快速回答 | 最便宜/最快 |
51
+ | `L1` | 小改动、简单测试 | 普通 |
52
+ | `L2` | 写代码、审查、重构 | 较强 |
53
+ | `L3` | 复杂 BUG 修复、多步任务、汇报 | 最强 |
54
+
55
+ 每个层级是一个用户自行指定的 provider/model 路由,**必须**附带
56
+ `reasoningEffort`(省略时默认 `off`):`off` = 无思考模式,`high`/`max` =
57
+ 思考强度。层级可以留空不配置;路由会落到最近的已配置层级(方向由成本模式决定)。
58
+
59
+ ## 模型选择框中的 Auto 模式
60
+
61
+ 插件注册了一个虚拟的 `auto` provider,模型选择框会出现 **Auto Router** 分组,
62
+ 内含一个 **Auto** 模型:
63
+
64
+ ```
65
+ Auto Router
66
+ └─ Auto
67
+ deepseek-official
68
+ ├─ deepseek-v4-flash
69
+ └─ deepseek-v4-flash
70
+ ```
71
+
72
+ - 选 **Auto** → 插件接管,按 L0–L3 层级自动路由。
73
+ - 选**具体模型** → 插件原样放行,你手选的模型优先,路由不干预。
74
+
75
+ ### 设置界面页签(浏览器端)
76
+
77
+ 浏览器端会在 DSH 设置面板(齿轮图标 → 侧栏)注册一个 **Auto Router**
78
+ 页签,展示当前路由策略——即 host 在会话开始时注入的
79
+ `<mydsh-auto-model-router-status>` 状态报告——并附提示文案:如需修改配置,
80
+ 直接在对话中告诉 AI(例如"把 L2 改成 deepseek-v4-flash 且 max 思考"或
81
+ "成本模式切换为 cost-first"),AI 会帮你编辑 `cordis.patch.yml`,
82
+ 重启 DSH 后生效。
83
+
84
+ 在模型选择框选 **Auto** 立即生效,不再有确认弹窗。
85
+
86
+ 无需额外构建步骤:`client.js` 是本仓库维护的自包含 ModuleLoader bundle
87
+ (`pnpm run build:client` 可从 `client/` 源码重新生成)。
88
+
89
+ ## 安装
90
+
91
+ ```bash
92
+ dsh plugin --profile web add ./mydsh-auto-model-router
93
+ ```
94
+
95
+ 或通过已发布的 npm 包安装:
96
+
97
+ ```bash
98
+ npm install @apinggithub/mydsh-auto-model-router
99
+ dsh plugin --profile web add @apinggithub/mydsh-auto-model-router
100
+ ```
101
+
102
+ ## 配置
103
+
104
+ 在 profile 的 `cordis.patch.yml` 中添加:
105
+
106
+ ```yaml
107
+ - insert:
108
+ - id: mydsh-auto-model-router
109
+ name: '@deepseek-ai/cordis-plugin-group'
110
+ group: true
111
+ isolate:
112
+ modelRouter: true
113
+ config:
114
+ - id: mydsh-auto-model-router-runtime
115
+ name: '@apinggithub/mydsh-auto-model-router'
116
+ config:
117
+ # ── 四个层级(出厂默认)────────────────────────────────────
118
+ # reasoningEffort:off = 无思考,medium/high/max = 思考强度
119
+ levels:
120
+ L0: # 日常问答
121
+ provider: deepseek-official
122
+ model: deepseek-v4-flash
123
+ reasoningEffort: off
124
+ L1: # 代码与测试
125
+ provider: deepseek-official
126
+ model: deepseek-v4-flash
127
+ reasoningEffort: medium
128
+ L2: # 编写与审查
129
+ provider: deepseek-official
130
+ model: deepseek-v4-flash
131
+ reasoningEffort: high
132
+ L3: # 复合多步任务
133
+ provider: deepseek-official
134
+ model: deepseek-v4-flash
135
+ reasoningEffort: max
136
+
137
+ # 关键词规则仅在全自动模式生效,默认为空
138
+ rules: []
139
+
140
+ # ── 模式A:字符统计锁(零延迟)────────────────────────────
141
+ heuristic:
142
+ windowSize: 3 # 统计最近 N 次用户输入
143
+ counting: cjk2 # 中文计2字符/其他计1;可选 tokens
144
+ thresholds: # 宽度 → 层级(用户可改)
145
+ - maxChars: 10
146
+ level: L0
147
+ - maxChars: 100
148
+ level: L1 # 锁顶;超过 100 弹窗询问用户
149
+
150
+ # ── 模式C:关键词积分(全自动)────────────────────────────
151
+ scoring:
152
+ weights: # 每次命中积分(按层级)
153
+ L1: 1
154
+ L2: 2
155
+ L3: 3
156
+ bands: # 积分 → 层级(用户可改)
157
+ - maxScore: 0
158
+ level: L0
159
+ - maxScore: 5
160
+ level: L1
161
+ - maxScore: 15
162
+ level: L2
163
+ - maxScore: null
164
+ level: L3
165
+
166
+ # ── Tier 2:成本控制(用户可配置)─────────────────────────
167
+ costControl:
168
+ enabled: true
169
+ mode: balanced # cost-first | quality-first | balanced
170
+ defaultLevel: L1 # 未命中规则时使用的层级
171
+ tokenBudgetPerSession: 0 # 0 = 不设预算(默认);
172
+ # 设置正值即按会话封顶
173
+ # 会话预算耗尽时的行为:
174
+ # silent — 静默切到最便宜层级(不告知用户)
175
+ # notify — 注入用户可见消息说明降级原因
176
+ # ask — 弹窗询问;用户可选"继续用当前模型"
177
+ # (本会话豁免预算)
178
+ downgradeBehavior: notify
179
+
180
+ # ── LLM 分类器(全自动模式兜底;默认开启)───────────────────
181
+ llmClassifier:
182
+ enabled: true
183
+ model:
184
+ provider: deepseek-official
185
+ model: deepseek-v4-flash
186
+ requestTimeoutMs: 10000
187
+
188
+ # ── Tier 3:免费额度优先(可选)──────────────────────────
189
+ # 声明哪些路由是「免费额度」:优先走它们,额度用尽/报错自动换线。
190
+ freeQuota:
191
+ enabled: true # 没有任何免费声明时自动失效
192
+ routes: # 不在 levels 里的额外免费路由
193
+ - provider: glm
194
+ model: glm-4.7-flash
195
+ maxLevel: L1 # 这一档及以下的活可以交给它(含),默认 L1
196
+ reasoningEffort: off
197
+ demoteOnError: true # 该路由一报错即在本会话内降级到付费路由
198
+ notify: true # 降级时插入一次可见提示
199
+
200
+ # 固定层级模式每 N 次用户输入重新弹窗询问
201
+ askEveryInputs: 3
202
+
203
+ # ── Tier 4:失败降级链(模型级)───────────────────────────
204
+ fallbackChain:
205
+ - provider: deepseek-official
206
+ model: deepseek-v4-flash
207
+
208
+ maxRetries: 1
209
+ ```
210
+
211
+ ### costControl 三种模式
212
+
213
+ | 模式 | 默认层级(未命中规则) | 模糊时偏移方向 |
214
+ | --- | --- | --- |
215
+ | `cost-first` | 最低已配置层级 | **向下**(更便宜) |
216
+ | `quality-first` | 最高已配置层级 | **向上**(更强) |
217
+ | `balanced` | `costControl.defaultLevel` | 不动 |
218
+
219
+ 规则命中永远优先于成本模式:用户显式匹配到的层级不会被成本层降级。
220
+
221
+ ### 预算降级可见
222
+
223
+ `tokenBudgetPerSession` **默认为 0(不设预算)**——成本层不会因预算耗尽而强制
224
+ 降级,`downgradeBehavior` 此时不生效。设置一个正值即可开启按会话封顶:
225
+
226
+ - 例如 `tokenBudgetPerSession: 300000`:会话用满 30 万 token 后降级。
227
+ - 降级行为由 `downgradeBehavior` 控制(默认 `notify`):
228
+ - `notify`:注入一条用户可见消息,说明预算耗尽、剩余请求使用哪个模型、
229
+ 如何调高上限。
230
+ - `ask`:弹窗询问"切到便宜模型还是继续用当前模型";选"继续"则本会话
231
+ 豁免预算。
232
+ - `silent`:静默切换(不推荐——用户会疑惑回答质量为何变化)。
233
+
234
+ ### 免费额度优先(freeQuota)
235
+
236
+ 很多 provider 会送免费额度(例如智谱的 `glm-4.7-flash` 长期免费),但免费通道
237
+ 同时意味着**限流、额度耗尽、参数上限更严**。这一层让「免费」变成可声明的路由属性,
238
+ 并在它不可用时自动换线,而不是把整轮对话做废。
239
+
240
+ 声明方式有两种,可以混用:
241
+
242
+ ```yaml
243
+ levels:
244
+ L0: { provider: glm, model: glm-4.7-flash, reasoningEffort: off, freeQuota: true }
245
+ L1: { provider: glm, model: glm-4.7-flash, reasoningEffort: low, freeQuota: true }
246
+ L2: { provider: deepseek-account, model: deepseek-flash, reasoningEffort: high }
247
+ L3: { provider: deepseek-account, model: deepseek-flash, reasoningEffort: max }
248
+
249
+ # 或者:层级仍指向付费模型,另外声明免费路由来「抢」这些档位的活
250
+ freeQuota:
251
+ routes:
252
+ - { provider: glm, model: glm-4.7-flash, maxLevel: L1, reasoningEffort: off }
253
+ ```
254
+
255
+ 行为:
256
+
257
+ 1. **优先**——决策落在 `maxLevel` 及以下的档位时,走声明的免费路由(免费路由
258
+ 总是带着显式 `reasoningEffort`,默认 `off`,不会掉进适配器的默认思考档)。
259
+ 2. **失败即降级**——免费路由上任何一次请求失败(额度耗尽、429 限流、鉴权被拒、
260
+ 参数被拒……)都会把该路由在**本会话内**标记为已耗尽,请求立刻重试到下一跳
261
+ 可用路由(通常就是该档位原本的付费路由)。
262
+ 3. **不再重试坏路由**——降级后,fallbackChain 的游走会跳过所有已耗尽的免费路由,
263
+ 同一个会话不会反复撞同一堵墙。
264
+ 4. **可见**——`notify: true` 时插入一条说明「哪个免费路由、为什么、现在改用什么」
265
+ 的消息,每个会话最多一条。
266
+ 5. **会话级**——降级只在当前会话内生效,下个会话重新尝试免费路由;`freeQuota`
267
+ 完全未声明时整层失效,老配置行为不变。
268
+
269
+ > 注意:免费的**视觉**模型通常有更小的输出上限(例如 `glm-4v-flash` 只允许
270
+ > `max_tokens ≤ 1024`)。这类限制要在 provider 的 `models` 条目上写 `maxTokens`
271
+ > 修正,路由器无法替你猜——遇到 `INVALID_REQUEST: max_tokens参数非法` 时按提示
272
+ > 给该模型加 `maxTokens` 即可。
273
+
274
+ ### 加载时询问(无需客户端界面)
275
+
276
+ 安装或热插拔插件后的**第一个会话**,DSH 会:
277
+
278
+ 1. 注入一条状态汇报消息(当前层级→模型映射、成本模式、预算、降级链)。
279
+ 语言跟随 `settings.yaml` 的 `locale.preference`(`zh`/`en`,默认 `en`)。
280
+ 2. 弹窗询问三个问题(每轮插件加载只问一次):
281
+
282
+ | 问题 | 选项 |
283
+ | --- | --- |
284
+ | Q1 匹配规则 (T1) | 保持现有规则 / **查看现有规则**(注入规则清单) |
285
+ | Q2 成本模式 (T2) | 保持当前 / cost-first / balanced / quality-first(选择立即生效) |
286
+ | Q3 其他配置 | 跳过 / **检查某项配置**(输入 budget、fallbackChain、classifier 等,注入当前值) |
287
+
288
+ Q1/Q3 注入的清单与当前值让用户**决定**是否调整;持久修改仍落在
289
+ `cordis.patch.yml`(消息中会说明)。headless 或无可询问通道时自动降级为
290
+ 只汇报、不询问,不会阻塞。
291
+
292
+ ## 规则语法
293
+
294
+ - 纯文本:`match: 'refactor'` —— 大小写不敏感的子串匹配。
295
+ - 正则字面量:`match: '/\\bdebug\\b/'` —— 首尾斜杠界定正则,末尾可加标志(如 `/.../i`)。
296
+ - 规则**按顺序**求值,第一个命中生效。
297
+ - `match` 可匹配用户消息文本、工具调用结果文本,或会话 `cwd`(以 `cwd:<path>` 匹配)。
298
+
299
+ ## 挂载点
300
+
301
+ | DSH 事件 | 作用 |
302
+ | --- | --- |
303
+ | `agent/session-start` | 重置每会话路由状态 |
304
+ | `agent/pre-step` | token 记账(预算跟踪) |
305
+ | `system-prompt/assemble` | 保持 `{{model}}` 提示词变量一致 |
306
+ | `agent/request` | **路由决策点** —— 替换 provider/model |
307
+ | `agent/request-error` | 降级链 → `{ kind: 'retry' }` |
308
+ | `session/event` | 从 assistant 消息统计 token 消耗 |
309
+
310
+ ## 测试
311
+
312
+ ```bash
313
+ npm test # node --test *.test.mjs
314
+ ```
315
+
316
+ 无需外部服务——所有层级均以 fixture 做单元测试。
317
+
318
+ ## 已知问题
319
+
320
+ - **全自动模式延迟**:开启 LLM 分类器后,每个新用户输入的第一步会增加一次分类器调用(LLM 请求)。虽然同一输入内的后续步骤已缓存、无额外开销,但多步任务的整体响应时间仍比直接选择模型慢。欢迎贡献优化方案——例如跨会话缓存分类结果,或用轻量启发式作为 LLM 分类的前置过滤。
321
+
322
+ ## 配套插件:按档位拆账
323
+
324
+ `tier-cost/` 里是本仓库附带的第二个插件 **`@apinggithub/dsh-tier-cost`**:
325
+ 它把会话日志按路由档位折叠成 L0–L3 的 token 与花费(金额向计费插件
326
+ `@kenz1117/dsh-ui-usage-billing` 借 per-turn 数据,本身不维护价目表),
327
+ 在设置页多出一栏「档位花费」——因为四个档位常常共用同一个模型,计费面板
328
+ 按模型分账时看不出是哪一档花的钱。
329
+
330
+ ```bash
331
+ dsh plugin --profile desktop add ./tier-cost
332
+ ```
333
+
334
+ 细节见 [tier-cost/README.zh.md](tier-cost/README.zh.md)。
335
+
336
+ ## License
337
+
338
+ MIT
339
+
340
+ ### 上下文窗口守卫(contextGuard)
341
+
342
+ 档位可声明其模型的 `contextWindow`。会话实际上下文(最近一次请求的输入侧
343
+ token,含缓存读写)超过 `window × headroom`(默认 0.85)时,**该档位被跳过**,
344
+ 路由向上漂到最近的装得下的档位;没有任何声明窗口装得下时保持原档(fail-open),
345
+ 让 provider 用真实错误说话、交给降级链处理。跳过每 (档位, 会话) 记录一次,
346
+ `/status` 的 `contextGuard` 字段可见。未声明任何窗口时整层不生效。
347
+
348
+ ```yaml
349
+ levels:
350
+ L0: { provider: bailian, model: qwen-max, contextWindow: 32768 } # 小窗口模型的保护
351
+ contextGuard: { enabled: true, headroom: 0.85 }
352
+ ```
353
+
354
+ ## 部署到其它电脑
355
+
356
+ 一台机器 = 两条命令(本仓库含两个插件):
357
+
358
+ ```powershell
359
+ git clone https://github.com/apinggithub/mydsh-auto-model-router
360
+ cd mydsh-auto-model-router; cd ..
361
+ # 宿主 0.2.x 会因插件 peer 钉死旧版本而拒装,先授予精确版本豁免(每个新版本各授一次):
362
+ dsh plugin --profile desktop allow-version '@apinggithub/mydsh-auto-model-router@0.1.1' --dsh-version 0.2.0-rc.2 --accept-risk
363
+ dsh plugin --profile desktop add ./mydsh-auto-model-router
364
+ dsh plugin --profile desktop add ./mydsh-auto-model-router/tier-cost # 可选:按档位拆账
365
+ ```
366
+
367
+ 然后在该机器的 `profiles/<profile>/cordis.patch.yml` 里写完整档位块,**provider/model 必须
368
+ 是那条机器自己能用的路由,别照抄 id**:
369
+
370
+ ```yaml
371
+ - id: mydsh-auto-model-router-runtime
372
+ name: '@apinggithub/mydsh-auto-model-router'
373
+ config:
374
+ levels:
375
+ L0: { provider: ..., model: ..., reasoningEffort: off, contextWindow: ..., freeQuota: true }
376
+ L1: { provider: ..., model: ..., reasoningEffort: low, contextWindow: ..., freeQuota: true }
377
+ L2: { provider: ..., model: ..., reasoningEffort: high, contextWindow: ..., freeQuota: true }
378
+ L3: { provider: ..., model: ..., reasoningEffort: max, contextWindow: ... }
379
+ contextGuard: { enabled: true, headroom: 0.85 }
380
+ freeQuota: { enabled: true, demoteOnError: true, notify: true }
381
+ ```
382
+
383
+ ⚠️ 内置默认 levels 是历史值(`deepseek-v4-flash` + `medium`),在 0.2.x 宿主上**两者都不是
384
+ 合法值**——新机器必须自带档位块,否则选 Auto 会直接失败。
385
+ `tier-cost` 对宿主其余部分零依赖:缺路由插件时全部归 manual/unknown,缺计费插件时只统计
386
+ token、费用记 0,均会在面板 notes 里说明。
387
+
388
+ ### 目录新鲜度守卫(modelsGuard)
389
+
390
+ 模型版本升级后,provider 不再公布的 id 会变成「过期路由」。守卫在激活时与每
391
+ `refreshMs`(默认 10 分钟)用 `ctx.llm.listModels(provider)` 读取各 provider
392
+ 实际公布的模型:过期档位的请求**自动漂移到最近的鲜活档位**(绝不静默打死),
393
+ 状态报告列出过期档位与**同族候选 id**,改档位仍是一行编辑的事。目录读不到或
394
+ 为空时一律 fail-open(不判过期)。未注册 listModels 的环境整层惰性。
395
+
396
+ ```yaml
397
+ modelsGuard: { enabled: true, refreshMs: 600000 }
398
+ ```
399
+
400
+ > 静态列表本身仍是配置(`cordis.patch.yml`)——这是刻意的:插件不会替你决定
401
+ > 换成哪个模型(窗口/单价/能力都变了),它只保证**坏档位不再被选中**并告诉你该换什么。
402
+
403
+ ## 发布到 npm
404
+
405
+ 两个插件同一仓库、各自独立发布(root 与本目录的 package.json 都可直接 publish):
406
+
407
+ ```bash
408
+ npm publish # @apinggithub/mydsh-auto-model-router(root)
409
+ cd tier-cost && npm publish # @apinggithub/dsh-tier-cost
410
+ ```
411
+
412
+ 0.2.0 起 peerDependencies 已放宽为宿主自适应范围,新机器安装**不再需要**
413
+ allow-version 豁免(豁免仅 0.1.1 及更早需要)。发布前各自 `npm login --scope=apinggithub`。
@@ -0,0 +1,104 @@
1
+ // Virtual "auto" provider adapter. Registers a provider route named `auto`
2
+ // so the model selector shows an "Auto Router" group with one model:
3
+ //
4
+ // Auto Router
5
+ // └─ Auto (auto routing)
6
+ //
7
+ // Selecting it marks the session's provider as `auto`; the router plugin then
8
+ // takes over in `agent/request` and routes through the configured L0–L3
9
+ // levels. Selecting any real provider/model leaves the user's choice
10
+ // untouched (the plugin passes through).
11
+ //
12
+ // If a request still reaches provider `auto` (e.g. the router did not
13
+ // intercept this particular call — a titled step, compaction, or a listener
14
+ // ordering gap), `stream()` forwards to the router's current per-session
15
+ // decision instead of throwing, so routing is never a hard failure.
16
+
17
+ import { routesEqual } from './router.mjs'
18
+
19
+ export const AUTO_PROVIDER = 'auto'
20
+ export const AUTO_MODEL = 'auto'
21
+
22
+ /** Provider route metadata shown in the selector. */
23
+ export function autoProviderInfo(provider) {
24
+ return { id: provider, name: 'Auto Router' }
25
+ }
26
+
27
+ /** One advertised model: the single "Auto" choice. */
28
+ export function autoListModels() {
29
+ return [{ provider: AUTO_PROVIDER, id: AUTO_MODEL, name: 'Auto' }]
30
+ }
31
+
32
+ /** Exact-model metadata for the advertised model. */
33
+ export function autoResolveModel(provider, model) {
34
+ return { provider, id: model, name: model }
35
+ }
36
+
37
+ /**
38
+ * The virtual adapter object. `stream()` forwards to the router's decision
39
+ * when it is reachable, so the auto provider never hard-fails routing.
40
+ */
41
+ export const AutoAdapter = {
42
+ providerInfo: autoProviderInfo,
43
+ providerRetryPolicy() {
44
+ return undefined
45
+ },
46
+ listModels: async () => autoListModels(),
47
+ resolveModel: async (provider, model) => autoResolveModel(provider, model),
48
+ /**
49
+ * The newer adapter contract (host 0.2.x): the runtime validates a request
50
+ * against the model's capabilities and binds it to one concrete adapter
51
+ * generation through `prepareCall(provider, model, signal)`, then dispatches
52
+ * through the returned `stream`. The virtual `auto` provider owns no
53
+ * generation of its own, so it returns the metadata it already advertises and
54
+ * routes the stream through the same forwarding generator below.
55
+ *
56
+ * Without this, every call the router does not intercept — a title step,
57
+ * compaction, a listener ordering gap — died with
58
+ * "registration.adapter.prepareCall is not a function" on hosts past 0.1.x.
59
+ * (`normalizeModelInfo` requires provider === the registration id, id === the
60
+ * requested model, and a non-empty name; everything else is optional.)
61
+ */
62
+ async prepareCall(provider, model) {
63
+ return {
64
+ model: await AutoAdapter.resolveModel(provider, model),
65
+ stream: (options) => AutoAdapter.stream(options),
66
+ }
67
+ },
68
+ async *stream(options) {
69
+ const router = globalThis.__dshAutoModelRouter
70
+ if (router !== undefined) {
71
+ const sessionId = options?.sessionId ?? ''
72
+ const route = router.current(sessionId)
73
+ if (route && !routesEqual(route, { provider: AUTO_PROVIDER, model: AUTO_MODEL })) {
74
+ // Forward this request to the routed model through the same llm
75
+ // runtime; the adapter contract requires we stream chunks back.
76
+ const llm = globalThis.__dshLl
77
+ if (llm?.stream !== undefined) {
78
+ // ctx.llm.stream returns the AsyncIterable directly (not { stream }).
79
+ const stream = llm.stream({
80
+ ...options,
81
+ provider: route.provider,
82
+ model: route.model,
83
+ ...route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort },
84
+ })
85
+ for await (const chunk of stream) yield chunk
86
+ return
87
+ }
88
+ }
89
+ }
90
+ throw new Error(
91
+ 'mydsh-auto-model-router: request reached the virtual "auto" provider with no router '
92
+ + 'decision available. Check that the plugin is mounted.',
93
+ )
94
+ },
95
+ }
96
+
97
+ /**
98
+ * Whether a proposed request config selects the Auto provider.
99
+ * @param {object} proposed - { provider, model } from the agent/request waterfall.
100
+ * @returns {boolean}
101
+ */
102
+ export function isAutoSelection(proposed) {
103
+ return proposed?.provider === AUTO_PROVIDER
104
+ }
package/classifier.mjs ADDED
@@ -0,0 +1,86 @@
1
+ // Optional LLM classifier (MODE C fallback): when keyword scoring yields no
2
+ // hits, ask a cheap model to classify the task into a level (L0…L3). Off by
3
+ // default (latency); enable via llmClassifier.enabled + llmClassifier.model
4
+ // in the patch config.
5
+ //
6
+ // The classifier judges the WINDOW text — 本次输入 + 上次输入 + 上次回复 —
7
+ // through ctx.llm.stream with a tiny standalone prompt, and is called once
8
+ // per user input (never cached; each new input re-judges).
9
+
10
+ import { LEVEL_IDS } from './config.mjs'
11
+
12
+ const CLASSIFIER_SYSTEM = `You are a task classifier for a coding agent's model router.
13
+ Given the recent conversation window (the user's latest input, their previous
14
+ input, and the assistant's last reply), decide which capability level should
15
+ handle the LATEST user input.
16
+ Reply with ONLY a JSON object: {"level": "L0" | "L1" | "L2" | "L3"}.
17
+
18
+ Level definitions:
19
+ - L0: everyday chat, quick factual answers, trivial questions. Cheapest model.
20
+ - L1: small code edits, simple tests, basic explanations. Regular model.
21
+ - L2: writing code, reviewing code, refactoring, debugging sessions. Strong model.
22
+ - L3: fixing complex bugs, multi-step composite tasks, test suites, reporting
23
+ on large changes, architecture decisions. Most powerful model.
24
+
25
+ When unsure between two levels, pick the LOWER level (the router's cost policy
26
+ will adjust it). Never reply with anything besides that JSON object.`
27
+
28
+ export class LlmClassifier {
29
+ /**
30
+ * @param {object} config - normalized llmClassifier config.
31
+ * @param {object} llm - ctx.llm service (used only when enabled).
32
+ */
33
+ constructor(config, llm) {
34
+ this.config = config
35
+ this.llm = llm
36
+ }
37
+
38
+ enabled() {
39
+ return this.config.enabled
40
+ }
41
+
42
+ /**
43
+ * Classify the WINDOW text into a level id.
44
+ * @param {string} windowText - 本次输入 + 上次输入 + 上次回复.
45
+ * @returns {Promise<string | null>} a LEVEL_IDS entry or null.
46
+ */
47
+ async classify(windowText) {
48
+ if (!this.enabled() || !this.llm || !this.config.model) return null
49
+ const trimmed = String(windowText ?? '').trim()
50
+ if (trimmed.length === 0) return null
51
+
52
+ const model = this.config.model
53
+ const controller = new AbortController()
54
+ const timer = setTimeout(() => controller.abort(), this.config.requestTimeoutMs)
55
+ try {
56
+ // ctx.llm.stream returns the AsyncIterable directly (not { stream }).
57
+ const stream = this.llm.stream({
58
+ provider: model.provider,
59
+ model: model.model,
60
+ messages: [
61
+ { role: 'system', content: [{ type: 'text', text: CLASSIFIER_SYSTEM }] },
62
+ { role: 'user', content: [{ type: 'text', text: trimmed }] },
63
+ ],
64
+ signal: controller.signal,
65
+ })
66
+ let buffer = ''
67
+ for await (const chunk of stream) {
68
+ const delta = chunk?.delta
69
+ if (typeof delta === 'string') buffer += delta
70
+ }
71
+ return parseLevel(buffer)
72
+ } catch {
73
+ // Classifier failure is never fatal — the router falls through.
74
+ return null
75
+ } finally {
76
+ clearTimeout(timer)
77
+ }
78
+ }
79
+ }
80
+
81
+ export function parseLevel(text) {
82
+ const match = String(text ?? '').match(/"level"\s*:\s*"(L[0-3])"/)
83
+ if (!match) return null
84
+ const level = match[1]
85
+ return LEVEL_IDS.includes(level) ? level : null
86
+ }