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

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
@@ -432,39 +432,148 @@ contextGuard: { enabled: true, headroom: 0.85 }
432
432
 
433
433
  ## Deploying to another machine
434
434
 
435
- One machine = two commands (this repository ships both plugins):
435
+ One machine = three commands (this repository ships both plugins):
436
436
 
437
437
  ```powershell
438
- git clone https://github.com/apinggithub/mydsh-auto-model-router
439
- cd mydsh-auto-model-router; cd ..
440
- # On 0.2.x hosts the plugin's stale pinned peers make the installer refuse; grant the
441
- # exact-version exemption first (per new plugin version):
442
- dsh plugin --profile desktop allow-version '@apinggithub/mydsh-auto-model-router@0.1.1' --dsh-version 0.2.0-rc.2 --accept-risk
443
- dsh plugin --profile desktop add ./mydsh-auto-model-router
444
- dsh plugin --profile desktop add ./mydsh-auto-model-router/tier-cost # optional: per-tier cost panel
438
+ # 1. Install from npm (peer ranges are host-adaptive; no allow-version exemption needed)
439
+ dsh plugin --profile desktop add '@apinggithub/mydsh-auto-model-router@0.3.1'
440
+
441
+ # 2. Optional: per-tier cost panel
442
+ dsh plugin --profile desktop add '@apinggithub/dsh-tier-cost@0.1.0'
443
+
444
+ # 3. Restart DSH once (bundles are composed at boot; config edits hot-reload)
445
445
  ```
446
446
 
447
- Then write a full levels block into that machine's `profiles/<profile>/cordis.patch.yml` —
448
- provider/model must be routes **that machine** can actually use (do not copy ids across machines):
447
+ From source instead: `git clone …` then `dsh plugin --profile desktop add ./mydsh-auto-model-router`.
448
+
449
+ ### Every machine ships its own levels block
450
+
451
+ The factory defaults are legacy routes that may not exist on that machine. Write your own
452
+ block at the end of `profiles/<profile>/cordis.patch.yml` — provider/model must be routes
453
+ **that machine** can actually use (do not copy ids across machines):
449
454
 
450
455
  ```yaml
451
456
  - id: mydsh-auto-model-router-runtime
452
457
  name: '@apinggithub/mydsh-auto-model-router'
453
458
  config:
454
459
  levels:
455
- L0: { provider: ..., model: ..., reasoningEffort: off, contextWindow: ..., freeQuota: true }
456
- L1: { provider: ..., model: ..., reasoningEffort: low, contextWindow: ..., freeQuota: true }
457
- L2: { provider: ..., model: ..., reasoningEffort: high, contextWindow: ..., freeQuota: true }
458
- L3: { provider: ..., model: ..., reasoningEffort: max, contextWindow: ... }
459
- contextGuard: { enabled: true, headroom: 0.85 }
460
+ # reasoningEffort: null = the model has no thinking mode (Qwen/GLM flash have none).
461
+ # Must be null, otherwise the host rejects the request with UNSUPPORTED_REASONING_EFFORT.
462
+ L0: { provider: zhipu, model: glm-4.7-flash, reasoningEffort: null, freeQuota: true }
463
+ L1: { provider: zhipu, model: glm-4.5-flash, reasoningEffort: null, freeQuota: true }
464
+ L2: { provider: zhipu, model: glm-4-flash, reasoningEffort: null }
465
+ L3: { provider: deepseek-official, model: deepseek-flash, reasoningEffort: max }
460
466
  freeQuota: { enabled: true, demoteOnError: true, notify: true }
467
+ contextGuard: { enabled: true, headroom: 0.85 }
468
+ quotaWatch: { enabled: true, provider: bailian-dell-vostro, balanceFloorCny: 10 }
469
+ ```
470
+
471
+ The providers themselves must already be configured in `cordis.patch.yml` (`llm-pi-ai`'s
472
+ `providers:` section), with keys in the DSH credential store or an `apiKeyEnv`.
473
+
474
+ `tier-cost` has zero hard dependencies beyond the host: without the router every turn folds
475
+ into manual/unknown, without the billing plugin tokens are counted and cost stays 0 — both
476
+ cases self-report in the panel notes.
477
+
478
+ **Multi-machine, same account**: quotas and balances live on the provider's side, so nothing
479
+ is accumulated locally — `quotaWatch` reads the cloud bill. But each machine configures its own
480
+ credentials (the store does not sync).
481
+
482
+ ## Quota watchdog (quotaWatch)
483
+
484
+ ### What it fixes
485
+
486
+ An exhausted free tier does not always produce an error: the provider may silently switch it to
487
+ pay-as-you-go. `demoteOnError` has nothing to catch then, and the route keeps spending (see
488
+ [the silent-billing trap](#the-silent-billing-trap-of-free-quota-real-incident--rules)). The
489
+ watchdog reads the **bill** instead and retires a "free" model within one poll.
490
+
491
+ ### What it can see
492
+
493
+ | Source | Gives | Limit |
494
+ | --- | --- | --- |
495
+ | `QueryAccountBalance` | available balance (CNY) | near real-time |
496
+ | `DescribeInstanceBill` | spend per model for the cycle | **T+1** |
497
+ | `QueryResourcePackageInstances` | resource-package remainder | empty when none exist |
498
+ | DeepSeek `GET /user/balance` | platform balance | near real-time |
499
+
500
+ There is **no** public "remaining free quota" API — that progress bar only exists in the
501
+ provider console. So the semantics are *detect and demote*, not *block in advance*; the real
502
+ guard against overspend is still the provider-side "stop when exhausted" switch.
503
+
504
+ ### Configuration
505
+
506
+ ```yaml
507
+ quotaWatch:
508
+ enabled: true
509
+ provider: bailian-dell-vostro # which configured provider the bill describes
510
+ productCode: sfm # 百炼's product code on the Aliyun bill
511
+ pollMs: 1800000 # 30 min (minimum 60000)
512
+ balanceFloorCny: 10 # below this Aliyun balance, every paid level pauses; 0 = off
513
+ demoteOnSpend: true # any spend on a guarded model retires its route
514
+ deepseekBalanceFloor: 10 # below this, only deepseek-official pauses; 0 = off
515
+ deepseekApiKeyRef: DEEPSEEK_API_KEY
516
+ accessKeyIdRef: ALIBABA_CLOUD_ACCESS_KEY_ID
517
+ accessKeySecretRef: ALIBABA_CLOUD_ACCESS_KEY_SECRET
518
+ timeoutMs: 20000
519
+ ```
520
+
521
+ ### Aliyun RAM: how to grant the least privilege
522
+
523
+ **Never use the root account's AccessKey.** Instead:
524
+
525
+ 1. Console → **RAM** → Users → create a user with **programmatic access only**;
526
+ 2. Give it **read-only billing**: attach the system policy `AliyunBSSReadOnlyAccess`, or a custom
527
+ policy allowing just `bss:QueryAccountBalance`, `bss:DescribeInstanceBill`,
528
+ `bss:QueryResourcePackageInstances`;
529
+ 3. Create an **AccessKey** and store it in the DSH credential store — nothing else, no write scope;
530
+ 4. The plugin only ever issues two GETs per poll.
531
+
532
+ > Never put the AccessKey **secret** in `cordis.patch.yml`, and never commit it. The config
533
+ > carries reference names (`accessKeyIdRef` / `accessKeySecretRef`) only.
534
+
535
+ ### Where credentials live
536
+
537
+ The watchdog resolves them through `ctx.credentials.resolve(ref)`, i.e. the **DSH credential
538
+ store** (`$DSH_HOME/.credentials.yaml`, the same file that holds provider API keys):
539
+
540
+ ```yaml
541
+ refs:
542
+ ALIBABA_CLOUD_ACCESS_KEY_ID: LTAI...
543
+ ALIBABA_CLOUD_ACCESS_KEY_SECRET: ...
544
+ DEEPSEEK_API_KEY: sk-...
545
+ ```
546
+
547
+ Two ways to write it:
548
+
549
+ - **GUI**: Plugins → built-in plugin settings → Models / credentials, using the ref names above;
550
+ - **Command line** (keeps the secret out of shell history and out of any chat):
551
+
552
+ ```powershell
553
+ $sec = Read-Host -AsSecureString 'Paste AccessKey Secret'
554
+ $plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
555
+ [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
556
+ # write it into $DSH_HOME/.credentials.yaml, then delete any temporary plaintext file
557
+ ```
558
+
559
+ ⚠️ **Multi-machine**: configure credentials once per machine. Quotas and balances live on the
560
+ provider side, so every machine reads the same numbers.
561
+
562
+ ### Status
563
+
564
+ `GET /api/mydsh-auto-model-router/status` → `quotaWatch`:
565
+
566
+ ```json
567
+ {
568
+ "enabled": true, "lastCheckAt": "2026-10-22T09:57:18Z", "lastError": null,
569
+ "balanceCny": 28.21, "cycle": "2026-10",
570
+ "spendByModel": { "glm-5.3": 62.5611, "qwen3.8-flash": 8.0478 },
571
+ "totalCny": 70.67, "exhausted": [], "balanceLow": false,
572
+ "deepseekBalanceCny": 158.25, "deepseekBlocked": false, "deepseekError": null
573
+ }
461
574
  ```
462
575
 
463
- ⚠️ The built-in default levels carry legacy values (`deepseek-v4-flash` + `medium`) that are
464
- both invalid on 0.2.x hosts — a fresh machine must ship its own levels block or Auto fails
465
- outright. `tier-cost` has zero hard dependencies beyond the host: without the router every
466
- turn folds into manual/unknown, without the billing plugin tokens are counted and cost
467
- stays 0 — both cases self-report in the panel notes.
576
+ Any step that fails lands in `lastError` / `deepseekError` and never breaks routing.
468
577
 
469
578
  ### Catalog freshness guard (modelsGuard)
470
579
 
package/README.zh.md CHANGED
@@ -385,37 +385,155 @@ contextGuard: { enabled: true, headroom: 0.85 }
385
385
 
386
386
  ## 部署到其它电脑
387
387
 
388
- 一台机器 = 两条命令(本仓库含两个插件):
388
+ 一台机器 = 三条命令(本仓库含两个插件):
389
389
 
390
390
  ```powershell
391
- git clone https://github.com/apinggithub/mydsh-auto-model-router
392
- cd mydsh-auto-model-router; cd ..
393
- # 宿主 0.2.x 会因插件 peer 钉死旧版本而拒装,先授予精确版本豁免(每个新版本各授一次):
394
- dsh plugin --profile desktop allow-version '@apinggithub/mydsh-auto-model-router@0.1.1' --dsh-version 0.2.0-rc.2 --accept-risk
395
- dsh plugin --profile desktop add ./mydsh-auto-model-router
396
- dsh plugin --profile desktop add ./mydsh-auto-model-router/tier-cost # 可选:按档位拆账
391
+ # 1. 从 npm 装(推荐;peer 范围已放宽为宿主自适应,不需要 allow-version 豁免)
392
+ dsh plugin --profile desktop add '@apinggithub/mydsh-auto-model-router@0.3.1'
393
+
394
+ # 2. 可选:按档位拆账
395
+ dsh plugin --profile desktop add '@apinggithub/dsh-tier-cost@0.1.0'
396
+
397
+ # 3. 重启 DSH(bundle 只在启动时组合;配置改动用热加载即可)
397
398
  ```
398
399
 
399
- 然后在该机器的 `profiles/<profile>/cordis.patch.yml` 里写完整档位块,**provider/model 必须
400
- 是那条机器自己能用的路由,别照抄 id**:
400
+ 也可以从源码装:`git clone …` 后 `dsh plugin --profile desktop add ./mydsh-auto-model-router`。
401
+
402
+ ### 每台机器必须自带档位块
403
+
404
+ 插件的出厂默认档位是**历史值**(`deepseek-official/deepseek-flash`),这台机器上不一定可用。
405
+ 装完在该机器 `profiles/<profile>/cordis.patch.yml` 末尾写自己的档位块,
406
+ **provider/model 必须是那条机器真实可用的路由,别照抄 id**:
401
407
 
402
408
  ```yaml
403
409
  - id: mydsh-auto-model-router-runtime
404
410
  name: '@apinggithub/mydsh-auto-model-router'
405
411
  config:
406
412
  levels:
407
- L0: { provider: ..., model: ..., reasoningEffort: off, contextWindow: ..., freeQuota: true }
408
- L1: { provider: ..., model: ..., reasoningEffort: low, contextWindow: ..., freeQuota: true }
409
- L2: { provider: ..., model: ..., reasoningEffort: high, contextWindow: ..., freeQuota: true }
410
- L3: { provider: ..., model: ..., reasoningEffort: max, contextWindow: ... }
413
+ # reasoningEffort: null = 该模型没有思考能力(Qwen/GLM flash 都是),必须写 null,
414
+ # 否则宿主会以 UNSUPPORTED_REASONING_EFFORT 拒绝请求。
415
+ L0: { provider: zhipu, model: glm-4.7-flash, reasoningEffort: null, freeQuota: true }
416
+ L1: { provider: zhipu, model: glm-4.5-flash, reasoningEffort: null, freeQuota: true }
417
+ L2: { provider: zhipu, model: glm-4-flash, reasoningEffort: null }
418
+ L3: { provider: deepseek-official, model: deepseek-flash, reasoningEffort: max }
419
+ freeQuota: { enabled: true, demoteOnError: true, notify: true }
411
420
  contextGuard: { enabled: true, headroom: 0.85 }
412
- freeQuota: { enabled: true, demoteOnError: true, notify: true }
421
+ quotaWatch: { enabled: true, provider: bailian-dell-vostro, balanceFloorCny: 10 }
422
+ ```
423
+
424
+ provider 本身要在 `cordis.patch.yml` 里先定义好(`llm-pi-ai` 的 `providers:` 段),
425
+ 密钥通过 DSH 凭据库或 `apiKeyEnv` 提供。
426
+
427
+ **多机同账号的注意点**:额度与余额都在**服务端**,所以本机累计的 token 数不参与判断——
428
+ `quotaWatch` 读的是云端账单/余额。但**每台机器都要各自配置一次**(凭据库不跨机同步)。
429
+
430
+ ## 额度看门狗(quotaWatch)
431
+
432
+ ### 它解决什么问题
433
+
434
+ 免费额度耗尽后,服务商**未必报错**——可能静默转按量计费。那时 `demoteOnError` 无错可捕,
435
+ 路由会一直烧钱(见文末「静默计费陷阱」)。看门狗改读**账单**,一旦某个"免费"模型出现花费,
436
+ 30 分钟内把它摘掉。
437
+
438
+ ### 它能看见什么
439
+
440
+ | 数据源 | 能拿到 | 限制 |
441
+ | --- | --- | --- |
442
+ | `QueryAccountBalance` | 账户可用余额(¥) | 准实时 |
443
+ | `DescribeInstanceBill` | 按模型拆分的当期花费 | **T+1 延迟** |
444
+ | `QueryResourcePackageInstances` | 资源包余量 | 无资源包时为空 |
445
+ | DeepSeek `GET /user/balance` | 开放平台余额 | 准实时 |
446
+
447
+ **没有**"查询免费额度剩余"的公开接口——那个进度条只在服务商控制台里。
448
+ 所以看门狗的语义是**发现即降级**,不是提前拦截;真正的超前拦截仍要靠服务商侧的「用完即停」。
449
+
450
+ ### 配置
451
+
452
+ ```yaml
453
+ quotaWatch:
454
+ enabled: true
455
+ provider: bailian-dell-vostro # 账单描述的是哪个已配置 provider(要摘的路由)
456
+ productCode: sfm # 百炼在阿里云账单里的商品码
457
+ pollMs: 1800000 # 轮询间隔,最小 60000
458
+ balanceFloorCny: 10 # 阿里云余额低于此值 → 暂停所有付费档;0 = 关闭
459
+ demoteOnSpend: true # 某模型出现花费 → 摘掉它的路由
460
+ deepseekBalanceFloor: 10 # DeepSeek 平台余额低于此值 → 只暂停 deepseek-official;0 = 关闭
461
+ deepseekApiKeyRef: DEEPSEEK_API_KEY
462
+ accessKeyIdRef: ALIBABA_CLOUD_ACCESS_KEY_ID
463
+ accessKeySecretRef: ALIBABA_CLOUD_ACCESS_KEY_SECRET
464
+ timeoutMs: 20000
465
+ ```
466
+
467
+ ### 阿里云 RAM 子账号怎么开(重要)
468
+
469
+ **不要用主账号 AccessKey。** 正确的做法:
470
+
471
+ 1. 阿里云控制台 → **RAM 访问控制** → 用户 → 创建用户,勾选**只允许编程访问**;
472
+ 2. 给它**只读账单权限**:直接附加系统策略 `AliyunBSSReadOnlyAccess`;
473
+ 想更小可用自定义策略,只允许这几个 Action:
474
+ `bss:QueryAccountBalance`、`bss:DescribeInstanceBill`、`bss:QueryResourcePackageInstances`;
475
+ 3. 创建 **AccessKey**,把 ID/Secret 存进 DSH 凭据库(见下),**不要再给它任何写权限**;
476
+ 4. 验证(只读,不花钱):
477
+
478
+ ```powershell
479
+ $ak = (Resolve the ID); $sk = (Resolve the secret)
480
+ # 期望返回 {"Success":true,"Data":{"AvailableAmount":"..."}}
481
+ ```
482
+
483
+ > AccessKey **Secret 不要写进 `cordis.patch.yml`,也不要提交进仓库**——配置文件里只有
484
+ > `accessKeyIdRef` / `accessKeySecretRef` 这样的**引用名**。
485
+
486
+ ### 凭据放哪里
487
+
488
+ 看门狗通过 `ctx.credentials.resolve(ref)` 读取,所以密钥存在 **DSH 凭据库**
489
+ (`$DSH_HOME/.credentials.yaml`,与 provider 的 API key 同一个文件),形如:
490
+
491
+ ```yaml
492
+ refs:
493
+ ALIBABA_CLOUD_ACCESS_KEY_ID: LTAI...
494
+ ALIBABA_CLOUD_ACCESS_KEY_SECRET: ...
495
+ DEEPSEEK_API_KEY: sk-...
413
496
  ```
414
497
 
415
- ⚠️ 内置默认 levels 是历史值(`deepseek-v4-flash` + `medium`),在 0.2.x 宿主上**两者都不是
416
- 合法值**——新机器必须自带档位块,否则选 Auto 会直接失败。
417
- `tier-cost` 对宿主其余部分零依赖:缺路由插件时全部归 manual/unknown,缺计费插件时只统计
418
- token、费用记 0,均会在面板 notes 里说明。
498
+ 两种写入方式,任选:
499
+
500
+ - **GUI**:插件 → 内置插件设置 → Models / 凭据,按 API key 的方式存(键名用上面的 ref 名);
501
+ - **命令行**(secret 不进 shell 历史,也不进对话):
502
+
503
+ ```powershell
504
+ $sec = Read-Host -AsSecureString 'Paste AccessKey Secret'
505
+ $plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
506
+ [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
507
+ # 写入 $DSH_HOME/.credentials.yaml 的 refs 段后,立即删除任何临时明文文件
508
+ ```
509
+
510
+ ⚠️ **多机部署**:每台机器都要各自配置一次凭据(凭据库不跨机同步)。
511
+ 额度/余额在服务端,所以多机读到的数字一致,不存在方案 A 那种"各机各算、互不知情"的偏差。
512
+
513
+ ### 状态接口
514
+
515
+ `GET /api/mydsh-auto-model-router/status` 的 `quotaWatch` 字段:
516
+
517
+ ```json
518
+ {
519
+ "enabled": true,
520
+ "lastCheckAt": "2026-10-22T09:57:18Z",
521
+ "lastError": null,
522
+ "balanceCny": 28.21,
523
+ "cycle": "2026-10",
524
+ "spendByModel": { "glm-5.3": 62.5611, "qwen3.8-flash": 8.0478 },
525
+ "totalCny": 70.67,
526
+ "exhausted": [],
527
+ "balanceLow": false,
528
+ "deepseekBalanceCny": 158.25,
529
+ "deepseekBlocked": false,
530
+ "deepseekError": null
531
+ }
532
+ ```
533
+
534
+ 任何一环失败都只写进 `lastError` / `deepseekError`,**不会**影响路由本身。
535
+
536
+ ## 发布到 npm
419
537
 
420
538
  ### 目录新鲜度守卫(modelsGuard)
421
539
 
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.1'
25
+ export const PLUGIN_VERSION = '0.3.2'
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'])
@@ -48,6 +48,8 @@ const DEFAULT_CONFIG = Object.freeze({
48
48
  heuristic: {
49
49
  windowSize: 3,
50
50
  counting: 'cjk2',
51
+ // Ask before leaving the heuristic ladder; set false to route silently.
52
+ askAboveLock: true,
51
53
  thresholds: [
52
54
  { maxChars: 10, level: 'L0' },
53
55
  { maxChars: 100, level: 'L1' },
@@ -259,6 +261,9 @@ function normalizeHeuristic(heuristic) {
259
261
  ...(heuristic ?? {}),
260
262
  }
261
263
  h.windowSize = clampInt(h.windowSize, 1, 20, DEFAULT_CONFIG.heuristic.windowSize)
264
+ // `askAboveLock: false` removes the "this input crossed the ceiling" dialog:
265
+ // anything above the top threshold just rides the last threshold's level.
266
+ h.askAboveLock = h.askAboveLock !== false
262
267
  if (!COUNTING_MODES.includes(h.counting)) {
263
268
  throw new Error(
264
269
  `mydsh-auto-model-router: heuristic.counting must be one of ${COUNTING_MODES.join(', ')}, got "${h.counting}"`,
@@ -0,0 +1,53 @@
1
+ # Copy-paste examples
2
+
3
+ These files are meant to be **copied into a profile**, not loaded from here.
4
+ Each one is a fragment for `profiles/<profile>/cordis.patch.yml`; merge the entry
5
+ into that file rather than replacing it.
6
+
7
+ | File | What it gives you |
8
+ | --- | --- |
9
+ | [`levels-free-first.yml`](levels-free-first.yml) | L0–L2 on free Zhipu flash models, L3 on the DeepSeek platform API |
10
+ | [`levels-deepseek-account.yml`](levels-deepseek-account.yml) | Everything on the logged-in DeepSeek account (no API key needed) |
11
+ | [`quota-watch.yml`](quota-watch.yml) | The cloud-bill watchdog block, with the RAM notes inline |
12
+ | [`add-credentials.ps1`](add-credentials.ps1) | Writes/updates/removes one credential in `$DSH_HOME/.credentials.yaml` |
13
+
14
+ ## Quick start on a fresh machine
15
+
16
+ ```powershell
17
+ dsh plugin --profile desktop add '@apinggithub/mydsh-auto-model-router@0.3.1'
18
+
19
+ # 1. store the keys the examples reference (secret is typed, never echoed)
20
+ .\examples\add-credentials.ps1 -Name ZHIPU_API_KEY
21
+ .\examples\add-credentials.ps1 -Name DEEPSEEK_API_KEY
22
+ # only if you enable quotaWatch:
23
+ .\examples\add-credentials.ps1 -Name ALIBABA_CLOUD_ACCESS_KEY_ID
24
+ .\examples\add-credentials.ps1 -Name ALIBABA_CLOUD_ACCESS_KEY_SECRET
25
+
26
+ # 2. append the levels block to the profile patch
27
+ notepad $env:DSH_HOME\profiles\desktop\cordis.patch.yml
28
+
29
+ # 3. restart DSH once (bundles compose at boot; later edits hot-reload)
30
+ ```
31
+
32
+ Check what the plugin sees at any time:
33
+
34
+ ```powershell
35
+ Invoke-RestMethod http://127.0.0.1:<port>/api/mydsh-auto-model-router/status
36
+ ```
37
+
38
+ (the port is in the GUI URL; the route is unauthenticated by design)
39
+
40
+ ## Rules of thumb that cost us real time
41
+
42
+ - **`reasoningEffort: null`** on any model without a thinking mode (Qwen/GLM
43
+ flash). Writing `off` makes the host reject the request with
44
+ `UNSUPPORTED_REASONING_EFFORT`.
45
+ - **Never mark a route `freeQuota: true`** unless you verified the provider
46
+ stops at the quota instead of silently billing. If in doubt, leave it out and
47
+ let `quotaWatch` retire the route once the bill shows spend.
48
+ - **Provider ids are per machine.** `zhipu`, `bailian-dell-vostro`,
49
+ `deepseek-account` only exist if that machine's `llm-pi-ai` `providers:` block
50
+ defines them.
51
+ - **Keep `L3` on a model with a large context window** (or rely on
52
+ `contextGuard`), otherwise a long session gets its answer truncated to a
53
+ handful of tokens and reads as "reached the output token limit".
@@ -0,0 +1,143 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Store, update or remove one credential reference in the DSH credential store.
4
+
5
+ .DESCRIPTION
6
+ DSH resolves provider keys (and the quota watchdog's cloud keys) from
7
+ $DSH_HOME/.credentials.yaml, under `refs:`. This script edits exactly one
8
+ entry and leaves every other line untouched.
9
+
10
+ The secret is read as a SecureString by default, so it never lands in the
11
+ shell history, in a log, or in a chat transcript. Pass -FromFile to import an
12
+ existing file and delete it afterwards.
13
+
14
+ .PARAMETER Name
15
+ The reference name, e.g. ZHIPU_API_KEY, DEEPSEEK_API_KEY,
16
+ ALIBABA_CLOUD_ACCESS_KEY_ID, ALIBABA_CLOUD_ACCESS_KEY_SECRET.
17
+
18
+ .PARAMETER Value
19
+ Optional literal value for non-interactive use (machine provisioning).
20
+ Prefer the prompt: a literal on the command line is recorded in history.
21
+
22
+ .PARAMETER FromFile
23
+ Read the value from a file (trimmed), then delete that file.
24
+
25
+ .PARAMETER Remove
26
+ Delete the reference instead of setting it.
27
+
28
+ .PARAMETER DshHome
29
+ DSH home directory. Defaults to $env:DSH_HOME, then $HOME\.dsh.
30
+
31
+ .EXAMPLE
32
+ .\add-credentials.ps1 -Name ZHIPU_API_KEY
33
+
34
+ .EXAMPLE
35
+ .\add-credentials.ps1 -Name DEEPSEEK_API_KEY -FromFile C:\temp\key.txt
36
+
37
+ .EXAMPLE
38
+ .\add-credentials.ps1 -Name ALIBABA_CLOUD_ACCESS_KEY_SECRET -Remove
39
+ #>
40
+ [CmdletBinding(DefaultParameterSetName = 'Prompt')]
41
+ param(
42
+ [Parameter(Mandatory = $true)]
43
+ [ValidatePattern('^[A-Z][A-Z0-9_]*$')]
44
+ [string]$Name,
45
+
46
+ [Parameter(ParameterSetName = 'Value')]
47
+ [string]$Value,
48
+
49
+ [Parameter(ParameterSetName = 'File')]
50
+ [string]$FromFile,
51
+
52
+ [Parameter(ParameterSetName = 'Remove')]
53
+ [switch]$Remove,
54
+
55
+ [string]$DshHome
56
+ )
57
+
58
+ $ErrorActionPreference = 'Stop'
59
+
60
+ function Resolve-DshHome {
61
+ param([string]$Explicit)
62
+ if ($Explicit) { return $Explicit }
63
+ if ($env:DSH_HOME) { return $env:DSH_HOME }
64
+ return (Join-Path $HOME '.dsh')
65
+ }
66
+
67
+ function Read-SecretValue {
68
+ $secure = Read-Host -AsSecureString "Paste the secret for $Name"
69
+ $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
70
+ try {
71
+ return [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
72
+ } finally {
73
+ [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) | Out-Null
74
+ }
75
+ }
76
+
77
+ function Quote-Yaml([string]$text) {
78
+ # Single-quoted YAML scalar: keys never contain quotes, but escape defensively.
79
+ return "'" + $text.Replace("'", "''") + "'"
80
+ }
81
+
82
+ $homeDir = Resolve-DshHome $DshHome
83
+ $store = Join-Path $homeDir '.credentials.yaml'
84
+
85
+ if (-not (Test-Path $store)) {
86
+ New-Item -ItemType Directory -Path $homeDir -Force | Out-Null
87
+ [IO.File]::WriteAllText($store, "version: 1`nrefs:`n", (New-Object System.Text.UTF8Encoding($false)))
88
+ Write-Host "created $store"
89
+ }
90
+
91
+ $raw = [IO.File]::ReadAllText($store, [Text.Encoding]::UTF8)
92
+ $linePattern = "(?m)^[ \t]{2}" + [regex]::Escape($Name) + ":[^\r\n]*$"
93
+
94
+ if ($Remove) {
95
+ if (-not [regex]::IsMatch($raw, $linePattern)) {
96
+ Write-Host "$Name is not present; nothing to remove."
97
+ return
98
+ }
99
+ $updated = [regex]::Replace($raw, $linePattern + "\r?\n?", '')
100
+ [IO.File]::WriteAllText($store, $updated, (New-Object System.Text.UTF8Encoding($false)))
101
+ Write-Host "removed $Name from $store"
102
+ return
103
+ }
104
+
105
+ if ($PSCmdlet.ParameterSetName -eq 'File') {
106
+ if (-not (Test-Path $FromFile)) { throw "file not found: $FromFile" }
107
+ $secret = ([IO.File]::ReadAllText($FromFile, [Text.Encoding]::UTF8)).Trim()
108
+ Remove-Item $FromFile -Force
109
+ Write-Host "imported and deleted $FromFile"
110
+ } elseif ($PSCmdlet.ParameterSetName -eq 'Value') {
111
+ $secret = $Value.Trim()
112
+ } else {
113
+ $secret = (Read-SecretValue).Trim()
114
+ }
115
+
116
+ if ([string]::IsNullOrWhiteSpace($secret)) { throw 'the secret is empty; nothing written' }
117
+
118
+ $entry = " ${Name}: " + (Quote-Yaml $secret)
119
+
120
+ if ([regex]::IsMatch($raw, $linePattern)) {
121
+ $updated = [regex]::Replace($raw, $linePattern, { param($m) $entry })
122
+ } elseif ([regex]::IsMatch($raw, '(?m)^refs:\s*$')) {
123
+ $updated = [regex]::Replace($raw, '(?m)^refs:\s*$', "refs:`n$entry", 1)
124
+ } else {
125
+ $updated = $raw.TrimEnd() + "`nrefs:`n$entry`n"
126
+ }
127
+
128
+ [IO.File]::WriteAllText($store, $updated, (New-Object System.Text.UTF8Encoding($false)))
129
+
130
+ # Restrict the file to the current user: it holds every provider key.
131
+ try {
132
+ & icacls $store /inheritance:r /grant:r "$($env:USERNAME):(R,W)" | Out-Null
133
+ } catch {
134
+ Write-Warning "could not tighten ACL on $store (non-fatal)"
135
+ }
136
+
137
+ $check = [IO.File]::ReadAllText($store, [Text.Encoding]::UTF8)
138
+ $stored = [regex]::Match($check, "(?m)^[ \t]{2}" + [regex]::Escape($Name) + ":\s*(.+)$")
139
+ if (-not $stored.Success) { throw "write failed: $Name not found after update" }
140
+
141
+ Write-Host ("stored {0} in {1} (length {2}, suffix {3})" -f `
142
+ $Name, $store, $secret.Length, $secret.Substring([Math]::Max(0, $secret.Length - 4)))
143
+ Write-Host 'Restart DSH only if the provider list changed; credentials are re-read per request.'
@@ -0,0 +1,39 @@
1
+ # Everything on the logged-in DeepSeek account (OAuth), no API key needed.
2
+ # Useful as a known-good baseline: if this routes, the plugin itself is fine and
3
+ # any failure is in the provider/credential of the other example.
4
+ #
5
+ # `deepseek-account` comes from the host's own account login; the model must be
6
+ # listed in that account's catalog (deepseek-flash on 0.2.x/0.3.x hosts).
7
+ - id: mydsh-auto-model-router-runtime
8
+ name: '@apinggithub/mydsh-auto-model-router'
9
+ config:
10
+ levels:
11
+ L0:
12
+ provider: deepseek-account
13
+ model: deepseek-flash
14
+ reasoningEffort: off
15
+ L1:
16
+ provider: deepseek-account
17
+ model: deepseek-flash
18
+ reasoningEffort: low
19
+ L2:
20
+ provider: deepseek-account
21
+ model: deepseek-flash
22
+ reasoningEffort: high
23
+ L3:
24
+ provider: deepseek-account
25
+ model: deepseek-flash
26
+ reasoningEffort: max
27
+ heuristic:
28
+ windowSize: 3
29
+ counting: cjk2
30
+ askAboveLock: false
31
+ thresholds:
32
+ - { maxChars: 10, level: L0 }
33
+ - { maxChars: 100, level: L1 }
34
+ - { maxChars: 1000, level: L2 }
35
+ - { maxChars: null, level: L3 }
36
+ costControl:
37
+ enabled: true
38
+ mode: balanced
39
+ defaultLevel: L1
@@ -0,0 +1,57 @@
1
+ # Free-first ladder: L0–L2 on Zhipu flash (genuinely free), L3 on the DeepSeek
2
+ # platform API. Requires the providers `zhipu` and `deepseek-official` to exist
3
+ # in that machine's cordis.patch.yml (llm-pi-ai providers:) and their keys in the
4
+ # credential store:
5
+ # ZHIPU_API_KEY → https://open.bigmodel.cn (glm flash tiers are free)
6
+ # DEEPSEEK_API_KEY → https://platform.deepseek.com
7
+ #
8
+ # reasoningEffort: null is REQUIRED for the glm flash models: they have no
9
+ # thinking mode, and any effort value makes the host reject the request with
10
+ # UNSUPPORTED_REASONING_EFFORT.
11
+ - id: mydsh-auto-model-router-runtime
12
+ name: '@apinggithub/mydsh-auto-model-router'
13
+ config:
14
+ levels:
15
+ L0:
16
+ provider: zhipu
17
+ model: glm-4.7-flash
18
+ reasoningEffort: null
19
+ freeQuota: true
20
+ L1:
21
+ provider: zhipu
22
+ model: glm-4.5-flash
23
+ reasoningEffort: null
24
+ freeQuota: true
25
+ L2:
26
+ provider: zhipu
27
+ model: glm-4-flash
28
+ reasoningEffort: null
29
+ L3:
30
+ provider: deepseek-official
31
+ model: deepseek-flash
32
+ reasoningEffort: max
33
+ # Long inputs ride the ladder silently instead of opening a dialog on every
34
+ # crossing of the heuristic ceiling.
35
+ heuristic:
36
+ windowSize: 3
37
+ counting: cjk2
38
+ askAboveLock: false
39
+ thresholds:
40
+ - { maxChars: 10, level: L0 }
41
+ - { maxChars: 100, level: L1 }
42
+ - { maxChars: 1000, level: L2 }
43
+ - { maxChars: null, level: L2 }
44
+ freeQuota:
45
+ enabled: true
46
+ demoteOnError: true
47
+ notify: true
48
+ routes: []
49
+ costControl:
50
+ enabled: true
51
+ mode: cost-first
52
+ defaultLevel: L1
53
+ fallbackChain:
54
+ - provider: deepseek-official
55
+ model: deepseek-flash
56
+ llmClassifier:
57
+ enabled: false
@@ -0,0 +1,42 @@
1
+ # Cloud-bill watchdog. Read-only: two GETs per poll against the Aliyun BSS
2
+ # OpenAPI, signed in-process. It exists because an exhausted free tier often
3
+ # keeps answering instead of returning an error — the router cannot see that,
4
+ # but the bill can.
5
+ #
6
+ # RAM setup (do this once, in the Aliyun console):
7
+ # 1. RAM → Users → create a user with PROGRAMMATIC ACCESS ONLY.
8
+ # 2. Attach the system policy `AliyunBSSReadOnlyAccess`, or a custom policy
9
+ # that allows exactly:
10
+ # bss:QueryAccountBalance
11
+ # bss:DescribeInstanceBill
12
+ # bss:QueryResourcePackageInstances
13
+ # 3. Create an AccessKey for that user. Never use the root account's key and
14
+ # never give it write scope.
15
+ # 4. Store the pair in the DSH credential store (examples/add-credentials.ps1):
16
+ # ALIBABA_CLOUD_ACCESS_KEY_ID
17
+ # ALIBABA_CLOUD_ACCESS_KEY_SECRET
18
+ #
19
+ # Secrets never appear in this file — only the reference names.
20
+ - id: mydsh-auto-model-router-runtime
21
+ name: '@apinggithub/mydsh-auto-model-router'
22
+ config:
23
+ quotaWatch:
24
+ enabled: true
25
+ # Which configured provider the bill describes; only its routes are retired.
26
+ provider: bailian-dell-vostro
27
+ # 百炼's product code on the Aliyun bill.
28
+ productCode: sfm
29
+ # 30 minutes. Minimum 60000; billing data itself lags (T+1), so a faster
30
+ # poll buys nothing.
31
+ pollMs: 1800000
32
+ # Below this Aliyun balance every paid route pauses. 0 disables the floor.
33
+ balanceFloorCny: 10
34
+ # Any spend on a guarded model retires its route for the session.
35
+ demoteOnSpend: true
36
+ # DeepSeek bills its own platform account, which no cloud bill covers:
37
+ # give that provider a separate floor. 0 disables it.
38
+ deepseekBalanceFloor: 10
39
+ deepseekApiKeyRef: DEEPSEEK_API_KEY
40
+ accessKeyIdRef: ALIBABA_CLOUD_ACCESS_KEY_ID
41
+ accessKeySecretRef: ALIBABA_CLOUD_ACCESS_KEY_SECRET
42
+ timeoutMs: 20000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apinggithub/mydsh-auto-model-router",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",
@@ -25,6 +25,7 @@
25
25
  "scoring.mjs",
26
26
  "client.js",
27
27
  "cordis.patch.yml",
28
+ "examples",
28
29
  "README.md",
29
30
  "README.zh.md"
30
31
  ],
package/router.mjs CHANGED
@@ -134,7 +134,9 @@ export class ModelRouter {
134
134
  // MODE A heuristic.
135
135
  const width = windowWidth(state.inputs, this.config.heuristic.windowSize, this.config.heuristic.counting)
136
136
  const ceiling = lockCeiling(this.config.heuristic.thresholds)
137
- if (ceiling !== null && width > ceiling) {
137
+ // `askAboveLock: false` keeps the ladder silent: the last threshold's level
138
+ // simply takes the long input instead of interrupting with a question.
139
+ if (ceiling !== null && width > ceiling && this.config.heuristic.askAboveLock !== false) {
138
140
  return { needsAsk: 'above-lock', width }
139
141
  }
140
142
  const level = levelForWidth(this.config.heuristic.thresholds, width)
package/text-width.mjs CHANGED
@@ -58,16 +58,14 @@ export function levelForWidth(thresholds, width) {
58
58
  }
59
59
 
60
60
  /**
61
- * The top finite threshold's maxChars, or null when every threshold is
62
- * unbounded. Used by the router to detect "above the heuristic lock".
61
+ * The heuristic ceiling: the top threshold's `maxChars`, or null when that
62
+ * threshold is unbounded (`maxChars: null`) — an open-ended ladder accepts every
63
+ * width, so the router never has to ask what to do.
63
64
  * @param {object[]} thresholds
64
65
  * @returns {number | null}
65
66
  */
66
67
  export function lockCeiling(thresholds) {
67
- for (let i = thresholds.length - 1; i >= 0; i--) {
68
- if (thresholds[i]?.maxChars !== null && thresholds[i]?.maxChars !== undefined) {
69
- return thresholds[i].maxChars
70
- }
71
- }
72
- return null
68
+ const last = thresholds[thresholds.length - 1]
69
+ if (last === undefined) return null
70
+ return last.maxChars === null || last.maxChars === undefined ? null : last.maxChars
73
71
  }