@apinggithub/mydsh-auto-model-router 0.3.2 → 0.3.4

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
@@ -63,10 +63,12 @@ LLM dispatch
63
63
  | `L2` | writing code, review, refactoring | strong |
64
64
  | `L3` | complex bug fixes, multi-step tasks, reports | most powerful |
65
65
 
66
- Each level is a user-assigned provider/model route with a **required**
67
- `reasoningEffort` (defaults to `off` when omitted): `off` = no thinking mode,
68
- `high`/`max` = thinking intensity. Levels may be left unconfigured; the router
69
- then falls to the nearest configured level (direction depends on the cost
66
+ Each level is a user-assigned provider/model route with an **optional**
67
+ `reasoningEffort`: omitting it (or writing `null`) sends no effort at all —
68
+ required for models without a thinking mode (GLM/Qwen flash), which the host
69
+ otherwise rejects; `off` explicitly disables thinking; `high`/`max` set its
70
+ intensity. Levels may be left unconfigured; the router then falls to the nearest
71
+ configured level (direction depends on the cost
70
72
  mode).
71
73
 
72
74
  ## Auto mode in the model selector
package/README.zh.md CHANGED
@@ -52,9 +52,10 @@ LLM 分发
52
52
  | `L2` | 写代码、审查、重构 | 较强 |
53
53
  | `L3` | 复杂 BUG 修复、多步任务、汇报 | 最强 |
54
54
 
55
- 每个层级是一个用户自行指定的 provider/model 路由,**必须**附带
56
- `reasoningEffort`(省略时默认 `off`):`off` = 无思考模式,`high`/`max` =
57
- 思考强度。层级可以留空不配置;路由会落到最近的已配置层级(方向由成本模式决定)。
55
+ 每个层级是一个用户自行指定的 provider/model 路由,**可选**附带
56
+ `reasoningEffort`:不写(或写 `null`)= 不发送该字段——GLM/Qwen flash 这类没有
57
+ 思考模式的模型**必须**这样,否则宿主会拒绝请求;`off` = 显式关闭思考;`high`/`max`
58
+ = 思考强度。层级可以留空不配置;路由会落到最近的已配置层级(方向由成本模式决定)。
58
59
 
59
60
  ## 模型选择框中的 Auto 模式
60
61
 
@@ -389,14 +390,39 @@ contextGuard: { enabled: true, headroom: 0.85 }
389
390
 
390
391
  ```powershell
391
392
  # 1. 从 npm 装(推荐;peer 范围已放宽为宿主自适应,不需要 allow-version 豁免)
392
- dsh plugin --profile desktop add '@apinggithub/mydsh-auto-model-router@0.3.1'
393
+ dsh plugin --profile desktop add '@apinggithub/mydsh-auto-model-router@0.3.3'
393
394
 
394
395
  # 2. 可选:按档位拆账
395
396
  dsh plugin --profile desktop add '@apinggithub/dsh-tier-cost@0.1.0'
396
397
 
397
- # 3. 重启 DSH(bundle 只在启动时组合;配置改动用热加载即可)
398
+ # 3. 改完 cordis.patch.yml 后先校验(见下),再重启
399
+ node scripts/check-patch.mjs --profile desktop
400
+
401
+ # 4. 重启 DSH(bundle 只在启动时组合;配置改动用热加载即可)
398
402
  ```
399
403
 
404
+ ### 改配置前必读:先校验,再重启
405
+
406
+ `cordis.patch.yml` 里一个少的换行就能让宿主**启动失败**:
407
+
408
+ ```
409
+ DesktopHostFatalError: failed to parse overlay … YAMLException: bad indentation (454:44)
410
+ ```
411
+
412
+ 更麻烦的是崩溃对话框里有 **"Disable third-party plugins"** 按钮——点它会把所有第三方
413
+ bundle 从 profile 里摘掉,插件(含 Auto 入口)随之消失,需要手工恢复。
414
+
415
+ 所以每次改完都跑一次(会用宿主自己的解析器校验,并清理临时 profile):
416
+
417
+ ```powershell
418
+ node scripts/check-patch.mjs --profile desktop
419
+ # PASS profile "desktop" composes (194 top-level entries)
420
+ # FAIL … 会打印宿主的原始错误
421
+ ```
422
+
423
+ 参数:`--home <DSH_HOME>`、`--profile <name>`、`--file <patch>`(只做静态检查)、`--keep`(保留临时 profile 便于排查)。
424
+
425
+
400
426
  也可以从源码装:`git clone …` 后 `dsh plugin --profile desktop add ./mydsh-auto-model-router`。
401
427
 
402
428
  ### 每台机器必须自带档位块
package/config.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  // fallbackChain: [route, ...] (model-level, appended with the default)
23
23
  // maxRetries: per-route retry budget on request-error
24
24
 
25
- export const PLUGIN_VERSION = '0.3.2'
25
+ export const PLUGIN_VERSION = '0.3.4'
26
26
 
27
27
  export const LEVEL_IDS = Object.freeze(['L0', 'L1', 'L2', 'L3'])
28
28
  export const COST_MODES = Object.freeze(['cost-first', 'quality-first', 'balanced'])
@@ -382,13 +382,14 @@ function normalizeLevels(levels) {
382
382
  }
383
383
 
384
384
  function normalizeLevelRoute(route, where) {
385
- // `reasoningEffort: null` means "this route has no thinking mode at all":
386
- // the field is omitted so a provider whose model declares no reasoning
387
- // metadata (Zhipu/Bailian flash models) is not rejected with
388
- // UNSUPPORTED_REASONING_EFFORT.
389
- const omitEffort = route.reasoningEffort === null
385
+ // A level states a reasoning effort only when the config states one.
386
+ //
387
+ // This used to default to 'off', which broke twice over: providers whose
388
+ // models have no thinking mode (GLM/Qwen flash) reject *any* effort with
389
+ // UNSUPPORTED_REASONING_EFFORT, and `apply()` normalises the config a second
390
+ // time, so an omitted field came back as 'off' even when the config had said
391
+ // `null`. Absent now stays absent, which makes normalisation idempotent.
390
392
  const normalized = normalizeRoute(route, where)
391
- // Every other level carries an explicit effort; absent → 'off' (no thinking).
392
393
  const contextWindow = route.contextWindow === undefined || route.contextWindow === null
393
394
  ? undefined
394
395
  : Number(route.contextWindow)
@@ -397,7 +398,6 @@ function normalizeLevelRoute(route, where) {
397
398
  }
398
399
  return {
399
400
  ...normalized,
400
- ...omitEffort ? {} : { reasoningEffort: normalized.reasoningEffort ?? 'off' },
401
401
  // A level may declare that its route is a free-quota route. The flag rides
402
402
  // along with the route so the router can prefer/demote it.
403
403
  ...route.freeQuota === true ? { freeQuota: true } : {},
@@ -26,7 +26,12 @@ dsh plugin --profile desktop add '@apinggithub/mydsh-auto-model-router@0.3.1'
26
26
  # 2. append the levels block to the profile patch
27
27
  notepad $env:DSH_HOME\profiles\desktop\cordis.patch.yml
28
28
 
29
- # 3. restart DSH once (bundles compose at boot; later edits hot-reload)
29
+ # 3. ALWAYS validate it before restarting — a YAML typo here stops DSH from
30
+ # starting, and the crash dialog's "disable third-party plugins" button then
31
+ # strips every bundle (the router disappears from the model picker)
32
+ node scripts/check-patch.mjs --profile desktop
33
+
34
+ # 4. restart DSH once (bundles compose at boot; later edits hot-reload)
30
35
  ```
31
36
 
32
37
  Check what the plugin sees at any time:
package/index.mjs CHANGED
@@ -294,8 +294,29 @@ export function apply(ctx, input = {}) {
294
294
  }
295
295
  })
296
296
 
297
- // ── ★ agent/request: reuse the per-input decision ──────────────────────
298
- // Only a NEW user input triggers a fresh decision (and only when the
297
+ /**
298
+ * Rewrite a host proposal onto one routed model.
299
+ *
300
+ * The incoming proposal still carries the *session's* reasoning effort — the
301
+ * host's model-selection listener re-applies it on every step — and that
302
+ * value belongs to the virtual `auto` model, not to the model we route to.
303
+ * Spreading it through sends a thinking parameter to a model that has no
304
+ * thinking mode, and the host rejects the request with
305
+ * `provider "zhipu" model "glm-4.7-flash" does not support reasoning effort "off"`.
306
+ * So the inherited effort is dropped unless the level itself declares one.
307
+ *
308
+ * @param {object} proposed - config from the host's own waterfall
309
+ * @param {object} route - the level's route
310
+ * @returns {object} the rewritten config
311
+ */
312
+ const withRoute = (proposed, route) => {
313
+ const next = { ...proposed, provider: route.provider, model: route.model }
314
+ delete next.reasoningEffort
315
+ if (route.reasoningEffort !== undefined) next.reasoningEffort = route.reasoningEffort
316
+ return next
317
+ }
318
+
319
+ // ── ★ agent/request: reuse the per-input decision ────────────────────── // Only a NEW user input triggers a fresh decision (and only when the
299
320
  // session picked the virtual auto provider). Steps within the same input
300
321
  // reuse state.current — no per-step re-judging, no per-step classifier.
301
322
  ctx.on('agent/request', async (payload, next) => {
@@ -333,15 +354,13 @@ export function apply(ctx, input = {}) {
333
354
  }
334
355
  // fallback: user dismissed → stay in heuristic mode.
335
356
  const route = router.current(sessionId)
336
- return { ...proposed, provider: route.provider, model: route.model,
337
- ...route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort } }
357
+ return withRoute(proposed, route)
338
358
  }
339
359
  if (decision.needsAsk === 'fixed-reelect') {
340
360
  const picked = await askFixedLevel(ctx, agentOf(payload), router, sessionId)
341
361
  if (picked) router.setMode(sessionId, 'fixed', { fixedLevel: picked })
342
362
  const route = router.current(sessionId)
343
- return { ...proposed, provider: route.provider, model: route.model,
344
- ...route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort } }
363
+ return withRoute(proposed, route)
345
364
  }
346
365
  }
347
366
 
@@ -363,15 +382,7 @@ export function apply(ctx, input = {}) {
363
382
  if (demotion && config.freeQuota.notify) {
364
383
  await notifyFreeQuotaFallback(agentOf(payload), sessionId, demotion, resolveLocale(ctx))
365
384
  }
366
- return {
367
- ...proposed,
368
- provider: route.provider,
369
- model: route.model,
370
- // A level that omits `reasoningEffort` (configured as `null`) has no
371
- // thinking mode: sending an effort to a model without reasoning
372
- // metadata would be rejected with UNSUPPORTED_REASONING_EFFORT.
373
- ...route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort },
374
- }
385
+ return withRoute(proposed, route)
375
386
  } catch (error) {
376
387
  // The router must never be able to fail a user request: hand the host's
377
388
  // own proposal back untouched and surface the reason once.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apinggithub/mydsh-auto-model-router",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Hybrid model router for DeepSeek Harness: declarative rules, user-configurable cost control (cost-first / quality-first / balanced), optional LLM task classifier, and failure fallback chains.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -26,6 +26,7 @@
26
26
  "client.js",
27
27
  "cordis.patch.yml",
28
28
  "examples",
29
+ "scripts",
29
30
  "README.md",
30
31
  "README.zh.md"
31
32
  ],
@@ -41,6 +42,7 @@
41
42
  "scripts": {
42
43
  "test": "node --test *.test.mjs",
43
44
  "build:client": "node build-client.mjs",
45
+ "check:patch": "node scripts/check-patch.mjs",
44
46
  "publish:all": "npm publish --access public && cd tier-cost && npm publish --access public"
45
47
  },
46
48
  "peerDependencies": {
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ // Validate a profile's cordis.patch.yml with the host's own parser.
3
+ //
4
+ // This exists because a single missing newline in the patch (a `- { … }` entry
5
+ // glued to the next key) makes DSH fail at startup with
6
+ // DesktopHostFatalError: failed to parse overlay … YAMLException
7
+ // and the desktop app offers to "disable third-party plugins", which strips
8
+ // every bundle. Catching it here costs two seconds.
9
+ //
10
+ // The check is authoritative rather than re-implemented: it copies the profile
11
+ // into a throw-away sibling profile and runs `dsh --dump-config`, so the real
12
+ // loader parses the real overlay with the real merge rules.
13
+ //
14
+ // Usage:
15
+ // node scripts/check-patch.mjs # profile "desktop", $DSH_HOME
16
+ // node scripts/check-patch.mjs --profile web
17
+ // node scripts/check-patch.mjs --home E:\dsh --profile desktop
18
+ // node scripts/check-patch.mjs --file path\to\cordis.patch.yml # syntax only
19
+ //
20
+ // Exit code 0 = the host parsed and composed the profile; 1 = it would not boot.
21
+ import { execFileSync } from 'node:child_process'
22
+ import {
23
+ copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, unlinkSync,
24
+ } from 'node:fs'
25
+ import { homedir } from 'node:os'
26
+ import { join, resolve } from 'node:path'
27
+
28
+ function parseArgs(argv) {
29
+ const out = { profile: 'desktop', home: undefined, file: undefined, keep: false }
30
+ for (let i = 0; i < argv.length; i += 1) {
31
+ const arg = argv[i]
32
+ if (arg === '--profile') out.profile = argv[++i]
33
+ else if (arg === '--home') out.home = argv[++i]
34
+ else if (arg === '--file') out.file = argv[++i]
35
+ else if (arg === '--keep') out.keep = true
36
+ else if (arg === '--help' || arg === '-h') out.help = true
37
+ else throw new Error(`unknown argument: ${arg}`)
38
+ }
39
+ return out
40
+ }
41
+
42
+ /** Cheap structural checks that catch the mistakes people actually make. */
43
+ function staticChecks(text) {
44
+ const problems = []
45
+ if (text.trim() === '') {
46
+ problems.push('the file is empty (an empty overlay must still be `[]`)')
47
+ return problems
48
+ }
49
+ if (text.includes('\t')) problems.push('contains a TAB character; YAML forbids tabs for indentation')
50
+ if (text.charCodeAt(0) === 0xfeff) problems.push('starts with a UTF-8 BOM; write it without one')
51
+
52
+ const lines = text.split(/\r?\n/)
53
+ lines.forEach((line, index) => {
54
+ // A `}` followed by more content on the same line is the classic
55
+ // "missing newline between a flow mapping and the next key" breakage.
56
+ if (/}[ \t]+[A-Za-z_][\w-]*:/.test(line)) {
57
+ problems.push(`line ${index + 1}: a flow mapping is glued to the next key — missing newline`)
58
+ }
59
+ })
60
+ return problems
61
+ }
62
+
63
+ /** Windows needs a junction for node_modules; POSIX can use a symlink. */
64
+ function linkModules(target, linkPath) {
65
+ if (process.platform === 'win32') {
66
+ execFileSync('cmd', ['/c', 'mklink', '/J', linkPath, target], { stdio: 'pipe' })
67
+ } else {
68
+ execFileSync('ln', ['-s', target, linkPath], { stdio: 'pipe' })
69
+ }
70
+ }
71
+
72
+ function unlinkModules(linkPath) {
73
+ if (!existsSync(linkPath)) return
74
+ const stat = lstatSync(linkPath)
75
+ if (process.platform === 'win32' && stat.isSymbolicLink()) {
76
+ execFileSync('cmd', ['/c', 'rmdir', linkPath], { stdio: 'pipe' })
77
+ } else if (stat.isSymbolicLink()) {
78
+ unlinkSync(linkPath)
79
+ } else {
80
+ rmSync(linkPath, { recursive: true, force: true })
81
+ }
82
+ }
83
+
84
+ function main() {
85
+ const args = parseArgs(process.argv.slice(2))
86
+ if (args.help) {
87
+ console.log(readFileSync(new URL(import.meta.url), 'utf8').split('\n').slice(1, 22).join('\n').replace(/^\/\/ ?/gm, ''))
88
+ return 0
89
+ }
90
+
91
+ const home = args.home ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')
92
+
93
+ if (args.file !== undefined) {
94
+ const patchPath = resolve(args.file)
95
+ if (!existsSync(patchPath)) {
96
+ console.error(`FAIL ${patchPath} does not exist`)
97
+ return 1
98
+ }
99
+ const problems = staticChecks(readFileSync(patchPath, 'utf8'))
100
+ if (problems.length > 0) {
101
+ console.error(`FAIL ${patchPath}`)
102
+ for (const problem of problems) console.error(` - ${problem}`)
103
+ return 1
104
+ }
105
+ console.log(`PASS ${patchPath} (static checks only; run without --file to compose the profile)`)
106
+ return 0
107
+ }
108
+
109
+ const profileDir = join(home, 'profiles', args.profile)
110
+ const patchPath = join(profileDir, 'cordis.patch.yml')
111
+ if (!existsSync(patchPath)) {
112
+ console.error(`FAIL no profile patch at ${patchPath}`)
113
+ return 1
114
+ }
115
+
116
+ const problems = staticChecks(readFileSync(patchPath, 'utf8'))
117
+ if (problems.length > 0) {
118
+ console.error(`FAIL static checks on ${patchPath}`)
119
+ for (const problem of problems) console.error(` - ${problem}`)
120
+ return 1
121
+ }
122
+
123
+ const scratchName = `${args.profile}-check-${process.pid}`
124
+ const scratchDir = join(home, 'profiles', scratchName)
125
+ const modulesLink = join(scratchDir, 'node_modules')
126
+ const realModules = join(profileDir, 'node_modules')
127
+
128
+ const cleanup = () => {
129
+ if (args.keep) {
130
+ console.log(`kept ${scratchDir}`)
131
+ return
132
+ }
133
+ try { unlinkModules(modulesLink) } catch { /* already gone */ }
134
+ try { rmSync(scratchDir, { recursive: true, force: true }) } catch { /* best effort */ }
135
+ }
136
+
137
+ try {
138
+ mkdirSync(scratchDir, { recursive: true })
139
+ for (const name of ['package.json', 'cordis.patch.yml', 'cordis.yml']) {
140
+ const from = join(profileDir, name)
141
+ if (existsSync(from)) copyFileSync(from, join(scratchDir, name))
142
+ }
143
+ if (existsSync(realModules)) linkModules(realModules, modulesLink)
144
+
145
+ let output = ''
146
+ let ok = true
147
+ try {
148
+ // `dsh` is a .cmd shim on Windows, which execFileSync cannot spawn
149
+ // directly: go through cmd /c there.
150
+ const command = process.platform === 'win32' ? 'cmd' : 'dsh'
151
+ const argv = process.platform === 'win32'
152
+ ? ['/c', 'dsh', '--profile', scratchName, '--dump-config']
153
+ : ['--profile', scratchName, '--dump-config']
154
+ output = execFileSync(command, argv, {
155
+ encoding: 'utf8',
156
+ stdio: ['ignore', 'pipe', 'pipe'],
157
+ env: { ...process.env, DSH_HOME: home },
158
+ timeout: 180_000,
159
+ })
160
+ } catch (error) {
161
+ ok = false
162
+ output = `${error.stdout ?? ''}${error.stderr ?? ''}` || String(error.message ?? error)
163
+ }
164
+
165
+ if (!ok) {
166
+ console.error(`FAIL the host cannot compose profile "${args.profile}"`)
167
+ const detail = output.split('\n').filter((line) => line.trim() !== '').slice(-25).join('\n')
168
+ console.error(detail)
169
+ return 1
170
+ }
171
+
172
+ const entries = (output.match(/^- id: /gm) ?? []).length
173
+ console.log(`PASS profile "${args.profile}" composes (${entries} top-level entries)`)
174
+ console.log(` ${patchPath}`)
175
+ return 0
176
+ } finally {
177
+ cleanup()
178
+ }
179
+ }
180
+
181
+ try {
182
+ process.exitCode = main()
183
+ } catch (error) {
184
+ console.error(`FAIL ${error?.message ?? error}`)
185
+ process.exitCode = 1
186
+ }