@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 +129 -20
- package/README.zh.md +136 -18
- package/config.mjs +6 -1
- package/examples/README.md +53 -0
- package/examples/add-credentials.ps1 +143 -0
- package/examples/levels-deepseek-account.yml +39 -0
- package/examples/levels-free-first.yml +57 -0
- package/examples/quota-watch.yml +42 -0
- package/package.json +2 -1
- package/router.mjs +3 -1
- package/text-width.mjs +6 -8
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 =
|
|
435
|
+
One machine = three commands (this repository ships both plugins):
|
|
436
436
|
|
|
437
437
|
```powershell
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
#
|
|
442
|
-
dsh plugin --profile desktop
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
dsh plugin --profile desktop add
|
|
396
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
62
|
-
*
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
}
|