Skip to content

mydsh-auto-model-router

Verified

@apinggithub/mydsh-auto-model-router · v0.3.4 · MIT · Web UI

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.

Install

dsh plugin add @apinggithub/mydsh-auto-model-router

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Creators

Readme

mydsh-auto-model-router

一个适配 DeepSeek Harness(DSH)的混合式自动模型路由插件。通过 L0–L3 四个能力层级为每个 agent 请求选择模型——日常问答、代码测试、编写审查、复合多步任务——支持用户可配置的成本策略、可选的 LLM 任务分类器、以及失败降级链。

设计思路参考 open-world-project/model-router(Hermes Agent)与 opencode-model-router(fast/medium/heavy 分层):层级化路由 + 成本模式决定模糊时的偏移方向。

致谢

本插件的思路来自以下开源项目:

工作原理

用户消息
  │
  ▼
① agent/pre-step         任务文本 + token 记账
  │
  ▼
② system-prompt/assemble 让 {{model}} 与路由结果保持一致
  │
  ▼
③ agent/request   ★ 决策点:
  │
  ├─ Tier 1 规则:        子串 / 正则匹配 → 层级(L0…L3)——永远优先
  ├─ Tier 2 成本:        预算耗尽 → 钉到最便宜层级;
  │                      未命中规则 → 模式决定默认层级
  ├─ Tier 3 分类器:      可选便宜 LLM → 层级;层级缺模型 → 按模式漂移
  └─ Tier 4 降级:        请求失败 → 沿链切换模型并重试
  │
  ▼
LLM 分发

四个层级

层级 典型任务 模型建议
L0 日常问答、快速回答 最便宜/最快
L1 小改动、简单测试 普通
L2 写代码、审查、重构 较强
L3 复杂 BUG 修复、多步任务、汇报 最强

每个层级是一个用户自行指定的 provider/model 路由,可选附带 reasoningEffort:不写(或写 null)= 不发送该字段——GLM/Qwen flash 这类没有 思考模式的模型必须这样,否则宿主会拒绝请求;off = 显式关闭思考;high/max = 思考强度。层级可以留空不配置;路由会落到最近的已配置层级(方向由成本模式决定)。

模型选择框中的 Auto 模式

插件注册了一个虚拟的 auto provider,模型选择框会出现 Auto Router 分组, 内含一个 Auto 模型:

Auto Router
  └─ Auto
deepseek-official
  ├─ deepseek-v4-flash
  └─ deepseek-v4-flash
  • 选 Auto → 插件接管,按 L0–L3 层级自动路由。
  • 选具体模型 → 插件原样放行,你手选的模型优先,路由不干预。

设置界面页签(浏览器端)

浏览器端会在 DSH 设置面板(齿轮图标 → 侧栏)注册一个 Auto Router 页签,展示当前路由策略——即 host 在会话开始时注入的 <mydsh-auto-model-router-status> 状态报告——并附提示文案:如需修改配置, 直接在对话中告诉 AI(例如"把 L2 改成 deepseek-v4-flash 且 max 思考"或 "成本模式切换为 cost-first"),AI 会帮你编辑 cordis.patch.yml, 重启 DSH 后生效。

在模型选择框选 Auto 立即生效,不再有确认弹窗。

无需额外构建步骤:client.js 是本仓库维护的自包含 ModuleLoader bundle (pnpm run build:client 可从 client/ 源码重新生成)。

安装

dsh plugin --profile web add ./mydsh-auto-model-router

或通过已发布的 npm 包安装:

npm install @apinggithub/mydsh-auto-model-router
dsh plugin --profile web add @apinggithub/mydsh-auto-model-router

宿主兼容性

DSH 把 profile 的插件树当成一次组合:补丁失败的 bundle 可能让整个 Harness 起不来, 而抛错的插件条目会被标记为「did not activate」——路由器从会话里直接消失。 所以本插件的行为约定是能挂载、能降级、能打日志,而不是在接口变动时直接失败。

宿主版本线 状态
0.1.x – 0.3.x 已实测(0.2.1-rc.2 为基准宿主)
0.4.x – 1.x 允许挂载,但在日志与 GET /api/mydsh-auto-model-router/status 中标记为「未实测」

挂载时 compat.mjs 会探测插件用到的每个接口(llm.registerAdapter、 llm.listModels、llm.stream、scoped ctx.inject、webServer.register、 可选的 tokenMeter),结果记录在状态接口的 host.compat 字段里。在未知宿主上:

  • tokenMeter 不再是硬依赖——软查找,缺失时改用插件自带的估算器。
  • 没有 registerAdapter 也能挂载——只记一条警告,模型选择框里不出现 Auto,路由逻辑本身不受影响。
  • HTTP 路由方法适配——webServer.register()、route()、addRoute() 都可接受;全都没有时设置页显示「暂无报告」,而不是报错。
  • 事件负载容错——既接受当前的 { agent } / (session, event) 形状,也接受 裸 agent / { session, event } 形状;处理函数抛错时原样返回宿主自己的提案, 路由器的 bug 不会再让用户请求失败。
  • 不再使用进程级全局变量——适配器按每次挂载创建并闭包引用该次挂载的 router,同进程多 profile 不会互相覆盖。
  • 版本报告——当宿主愿意暴露版本(环境变量、app 服务或可解析的 manifest)时, 超出 >=0.1.0-0 <0.4.0 会打一条同时含宿主版本与实测范围的警告。能力探测始终 执行,因此即使宿主隐藏版本,也能从状态接口看到它实际提供了哪些接口。

peer 范围(>=0.1.0-0 <1.0.0)和 engines.dsh 故意比实测范围宽,避免宿主升级后 直接拒装;真正的提示是运行时那条警告——看到它就请提 issue。

配置

在 profile 的 cordis.patch.yml 中添加:

- insert:
    - id: mydsh-auto-model-router
      name: '@deepseek-ai/cordis-plugin-group'
      group: true
      isolate:
        modelRouter: true
      config:
        - id: mydsh-auto-model-router-runtime
          name: '@apinggithub/mydsh-auto-model-router'
          config:
            # ── 四个层级(出厂默认)────────────────────────────────────
            # reasoningEffort:off = 无思考,medium/high/max = 思考强度
            levels:
              L0:                              # 日常问答
                provider: deepseek-official
                model: deepseek-v4-flash
                reasoningEffort: off
              L1:                              # 代码与测试
                provider: deepseek-official
                model: deepseek-v4-flash
                reasoningEffort: medium
              L2:                              # 编写与审查
                provider: deepseek-official
                model: deepseek-v4-flash
                reasoningEffort: high
              L3:                              # 复合多步任务
                provider: deepseek-official
                model: deepseek-v4-flash
                reasoningEffort: max

            # 关键词规则仅在全自动模式生效,默认为空
            rules: []

            # ── 模式A:字符统计锁(零延迟)────────────────────────────
            heuristic:
              windowSize: 3        # 统计最近 N 次用户输入
              counting: cjk2       # 中文计2字符/其他计1;可选 tokens
              thresholds:          # 宽度 → 层级(用户可改)
                - maxChars: 10
                  level: L0
                - maxChars: 100
                  level: L1        # 锁顶;超过 100 弹窗询问用户

            # ── 模式C:关键词积分(全自动)────────────────────────────
            scoring:
              weights:             # 每次命中积分(按层级)
                L1: 1
                L2: 2
                L3: 3
              bands:               # 积分 → 层级(用户可改)
                - maxScore: 0
                  level: L0
                - maxScore: 5
                  level: L1
                - maxScore: 15
                  level: L2
                - maxScore: null
                  level: L3

            # ── Tier 2:成本控制(用户可配置)─────────────────────────
            costControl:
              enabled: true
              mode: balanced        # cost-first | quality-first | balanced
              defaultLevel: L1      # 未命中规则时使用的层级
              tokenBudgetPerSession: 0   # 0 = 不设预算(默认);
                                        # 设置正值即按会话封顶
              # 会话预算耗尽时的行为:
              #   silent  — 静默切到最便宜层级(不告知用户)
              #   notify  — 注入用户可见消息说明降级原因
              #   ask     — 弹窗询问;用户可选"继续用当前模型"
              #             (本会话豁免预算)
              downgradeBehavior: notify

            # ── LLM 分类器(全自动模式兜底;默认开启)───────────────────
            llmClassifier:
              enabled: true
              model:
                provider: deepseek-official
                model: deepseek-v4-flash
              requestTimeoutMs: 10000

            # ── Tier 3:免费额度优先(可选)──────────────────────────
            # 声明哪些路由是「免费额度」:优先走它们,额度用尽/报错自动换线。
            freeQuota:
              enabled: true          # 没有任何免费声明时自动失效
              routes:                # 不在 levels 里的额外免费路由
                - provider: glm
                  model: glm-4.7-flash
                  maxLevel: L1       # 这一档及以下的活可以交给它(含),默认 L1
                  reasoningEffort: off
              demoteOnError: true    # 该路由一报错即在本会话内降级到付费路由
              notify: true           # 降级时插入一次可见提示

            # 固定层级模式每 N 次用户输入重新弹窗询问
            askEveryInputs: 3

            # ── Tier 4:失败降级链(模型级)───────────────────────────
            fallbackChain:
              - provider: deepseek-official
                model: deepseek-v4-flash

            maxRetries: 1

costControl 三种模式

模式 默认层级(未命中规则) 模糊时偏移方向
cost-first 最低已配置层级 向下(更便宜)
quality-first 最高已配置层级 向上(更强)
balanced costControl.defaultLevel 不动

规则命中永远优先于成本模式:用户显式匹配到的层级不会被成本层降级。

预算降级可见

tokenBudgetPerSession 默认为 0(不设预算)——成本层不会因预算耗尽而强制 降级,downgradeBehavior 此时不生效。设置一个正值即可开启按会话封顶:

  • 例如 tokenBudgetPerSession: 300000:会话用满 30 万 token 后降级。
  • 降级行为由 downgradeBehavior 控制(默认 notify):
    • notify:注入一条用户可见消息,说明预算耗尽、剩余请求使用哪个模型、 如何调高上限。
    • ask:弹窗询问"切到便宜模型还是继续用当前模型";选"继续"则本会话 豁免预算。
    • silent:静默切换(不推荐——用户会疑惑回答质量为何变化)。

免费额度优先(freeQuota)

很多 provider 会送免费额度(例如智谱的 glm-4.7-flash 长期免费),但免费通道 同时意味着限流、额度耗尽、参数上限更严。这一层让「免费」变成可声明的路由属性, 并在它不可用时自动换线,而不是把整轮对话做废。

声明方式有两种,可以混用:

levels:
  L0: { provider: glm, model: glm-4.7-flash, reasoningEffort: off, freeQuota: true }
  L1: { provider: glm, model: glm-4.7-flash, reasoningEffort: low,  freeQuota: true }
  L2: { provider: deepseek-account, model: deepseek-flash, reasoningEffort: high }
  L3: { provider: deepseek-account, model: deepseek-flash, reasoningEffort: max }

# 或者:层级仍指向付费模型,另外声明免费路由来「抢」这些档位的活
freeQuota:
  routes:
    - { provider: glm, model: glm-4.7-flash, maxLevel: L1, reasoningEffort: off }

行为:

  1. 优先——决策落在 maxLevel 及以下的档位时,走声明的免费路由(免费路由 总是带着显式 reasoningEffort,默认 off,不会掉进适配器的默认思考档)。
  2. 失败即降级——免费路由上任何一次请求失败(额度耗尽、429 限流、鉴权被拒、 参数被拒……)都会把该路由在本会话内标记为已耗尽,请求立刻重试到下一跳 可用路由(通常就是该档位原本的付费路由)。
  3. 不再重试坏路由——降级后,fallbackChain 的游走会跳过所有已耗尽的免费路由, 同一个会话不会反复撞同一堵墙。
  4. 可见——notify: true 时插入一条说明「哪个免费路由、为什么、现在改用什么」 的消息,每个会话最多一条。
  5. 会话级——降级只在当前会话内生效,下个会话重新尝试免费路由;freeQuota 完全未声明时整层失效,老配置行为不变。

注意:免费的视觉模型通常有更小的输出上限(例如 glm-4v-flash 只允许 max_tokens ≤ 1024)。这类限制要在 provider 的 models 条目上写 maxTokens 修正,路由器无法替你猜——遇到 INVALID_REQUEST: max_tokens参数非法 时按提示 给该模型加 maxTokens 即可。

加载时询问(无需客户端界面)

安装或热插拔插件后的第一个会话,DSH 会:

  1. 注入一条状态汇报消息(当前层级→模型映射、成本模式、预算、降级链)。 语言跟随 settings.yaml 的 locale.preference(zh/en,默认 en)。
  2. 弹窗询问三个问题(每轮插件加载只问一次):
问题 选项
Q1 匹配规则 (T1) 保持现有规则 / 查看现有规则(注入规则清单)
Q2 成本模式 (T2) 保持当前 / cost-first / balanced / quality-first(选择立即生效)
Q3 其他配置 跳过 / 检查某项配置(输入 budget、fallbackChain、classifier 等,注入当前值)

Q1/Q3 注入的清单与当前值让用户决定是否调整;持久修改仍落在 cordis.patch.yml(消息中会说明)。headless 或无可询问通道时自动降级为 只汇报、不询问,不会阻塞。

规则语法

  • 纯文本:match: 'refactor' —— 大小写不敏感的子串匹配。
  • 正则字面量:match: '/\\bdebug\\b/' —— 首尾斜杠界定正则,末尾可加标志(如 /.../i)。
  • 规则按顺序求值,第一个命中生效。
  • match 可匹配用户消息文本、工具调用结果文本,或会话 cwd(以 cwd:<path> 匹配)。

挂载点

DSH 事件 作用
agent/session-start 重置每会话路由状态
agent/pre-step token 记账(预算跟踪)
system-prompt/assemble 保持 {{model}} 提示词变量一致
agent/request 路由决策点 —— 替换 provider/model
agent/request-error 降级链 → { kind: 'retry' }
session/event 从 assistant 消息统计 token 消耗

测试

npm test        # node --test *.test.mjs

无需外部服务——所有层级均以 fixture 做单元测试。

已知问题

  • 全自动模式延迟:开启 LLM 分类器后,每个新用户输入的第一步会增加一次分类器调用(LLM 请求)。虽然同一输入内的后续步骤已缓存、无额外开销,但多步任务的整体响应时间仍比直接选择模型慢。欢迎贡献优化方案——例如跨会话缓存分类结果,或用轻量启发式作为 LLM 分类的前置过滤。

配套插件:按档位拆账

tier-cost/ 里是本仓库附带的第二个插件 @apinggithub/dsh-tier-cost: 它把会话日志按路由档位折叠成 L0–L3 的 token 与花费(金额向计费插件 @kenz1117/dsh-ui-usage-billing 借 per-turn 数据,本身不维护价目表), 在设置页多出一栏「档位花费」——因为四个档位常常共用同一个模型,计费面板 按模型分账时看不出是哪一档花的钱。

dsh plugin --profile desktop add ./tier-cost

细节见 tier-cost/README.zh.md。

License

MIT

上下文窗口守卫(contextGuard)

档位可声明其模型的 contextWindow。会话实际上下文(最近一次请求的输入侧 token,含缓存读写)超过 window × headroom(默认 0.85)时,该档位被跳过, 路由向上漂到最近的装得下的档位;没有任何声明窗口装得下时保持原档(fail-open), 让 provider 用真实错误说话、交给降级链处理。跳过每 (档位, 会话) 记录一次, /status 的 contextGuard 字段可见。未声明任何窗口时整层不生效。

levels:
  L0: { provider: bailian, model: qwen-max, contextWindow: 32768 }   # 小窗口模型的保护
contextGuard: { enabled: true, headroom: 0.85 }

部署到其它电脑

一台机器 = 三条命令(本仓库含两个插件):

# 1. 从 npm 装(推荐;peer 范围已放宽为宿主自适应,不需要 allow-version 豁免)
dsh plugin --profile desktop add '@apinggithub/[email protected]'

# 2. 可选:按档位拆账
dsh plugin --profile desktop add '@apinggithub/[email protected]'

# 3. 改完 cordis.patch.yml 后先校验(见下),再重启
node scripts/check-patch.mjs --profile desktop

# 4. 重启 DSH(bundle 只在启动时组合;配置改动用热加载即可)

改配置前必读:先校验,再重启

cordis.patch.yml 里一个少的换行就能让宿主启动失败:

DesktopHostFatalError: failed to parse overlay … YAMLException: bad indentation (454:44)

更麻烦的是崩溃对话框里有 "Disable third-party plugins" 按钮——点它会把所有第三方 bundle 从 profile 里摘掉,插件(含 Auto 入口)随之消失,需要手工恢复。

所以每次改完都跑一次(会用宿主自己的解析器校验,并清理临时 profile):

node scripts/check-patch.mjs --profile desktop
# PASS  profile "desktop" composes (194 top-level entries)
# FAIL  … 会打印宿主的原始错误

参数:--home <DSH_HOME>、--profile <name>、--file <patch>(只做静态检查)、--keep(保留临时 profile 便于排查)。

也可以从源码装:git clone … 后 dsh plugin --profile desktop add ./mydsh-auto-model-router。

每台机器必须自带档位块

插件的出厂默认档位是历史值(deepseek-official/deepseek-flash),这台机器上不一定可用。 装完在该机器 profiles/<profile>/cordis.patch.yml 末尾写自己的档位块, provider/model 必须是那条机器真实可用的路由,别照抄 id:

- id: mydsh-auto-model-router-runtime
  name: '@apinggithub/mydsh-auto-model-router'
  config:
    levels:
      # reasoningEffort: null = 该模型没有思考能力(Qwen/GLM flash 都是),必须写 null,
      # 否则宿主会以 UNSUPPORTED_REASONING_EFFORT 拒绝请求。
      L0: { provider: zhipu, model: glm-4.7-flash, reasoningEffort: null, freeQuota: true }
      L1: { provider: zhipu, model: glm-4.5-flash, reasoningEffort: null, freeQuota: true }
      L2: { provider: zhipu, model: glm-4-flash,   reasoningEffort: null }
      L3: { provider: deepseek-official, model: deepseek-flash, reasoningEffort: max }
    freeQuota:   { enabled: true, demoteOnError: true, notify: true }
    contextGuard: { enabled: true, headroom: 0.85 }
    quotaWatch:  { enabled: true, provider: bailian-dell-vostro, balanceFloorCny: 10 }

provider 本身要在 cordis.patch.yml 里先定义好(llm-pi-ai 的 providers: 段), 密钥通过 DSH 凭据库或 apiKeyEnv 提供。

多机同账号的注意点:额度与余额都在服务端,所以本机累计的 token 数不参与判断—— quotaWatch 读的是云端账单/余额。但每台机器都要各自配置一次(凭据库不跨机同步)。

额度看门狗(quotaWatch)

它解决什么问题

免费额度耗尽后,服务商未必报错——可能静默转按量计费。那时 demoteOnError 无错可捕, 路由会一直烧钱(见文末「静默计费陷阱」)。看门狗改读账单,一旦某个"免费"模型出现花费, 30 分钟内把它摘掉。

它能看见什么

数据源 能拿到 限制
QueryAccountBalance 账户可用余额(¥) 准实时
DescribeInstanceBill 按模型拆分的当期花费 T+1 延迟
QueryResourcePackageInstances 资源包余量 无资源包时为空
DeepSeek GET /user/balance 开放平台余额 准实时

没有"查询免费额度剩余"的公开接口——那个进度条只在服务商控制台里。 所以看门狗的语义是发现即降级,不是提前拦截;真正的超前拦截仍要靠服务商侧的「用完即停」。

配置

quotaWatch:
  enabled: true
  provider: bailian-dell-vostro      # 账单描述的是哪个已配置 provider(要摘的路由)
  productCode: sfm                   # 百炼在阿里云账单里的商品码
  pollMs: 1800000                    # 轮询间隔,最小 60000
  balanceFloorCny: 10                # 阿里云余额低于此值 → 暂停所有付费档;0 = 关闭
  demoteOnSpend: true                # 某模型出现花费 → 摘掉它的路由
  deepseekBalanceFloor: 10           # DeepSeek 平台余额低于此值 → 只暂停 deepseek-official;0 = 关闭
  deepseekApiKeyRef: DEEPSEEK_API_KEY
  accessKeyIdRef: ALIBABA_CLOUD_ACCESS_KEY_ID
  accessKeySecretRef: ALIBABA_CLOUD_ACCESS_KEY_SECRET
  timeoutMs: 20000

阿里云 RAM 子账号怎么开(重要)

不要用主账号 AccessKey。 正确的做法:

  1. 阿里云控制台 → RAM 访问控制 → 用户 → 创建用户,勾选只允许编程访问;
  2. 给它只读账单权限:直接附加系统策略 AliyunBSSReadOnlyAccess; 想更小可用自定义策略,只允许这几个 Action: bss:QueryAccountBalance、bss:DescribeInstanceBill、bss:QueryResourcePackageInstances;
  3. 创建 AccessKey,把 ID/Secret 存进 DSH 凭据库(见下),不要再给它任何写权限;
  4. 验证(只读,不花钱):
$ak = (Resolve the ID);  $sk = (Resolve the secret)
# 期望返回 {"Success":true,"Data":{"AvailableAmount":"..."}}

AccessKey Secret 不要写进 cordis.patch.yml,也不要提交进仓库——配置文件里只有 accessKeyIdRef / accessKeySecretRef 这样的引用名。

凭据放哪里

看门狗通过 ctx.credentials.resolve(ref) 读取,所以密钥存在 DSH 凭据库 ($DSH_HOME/.credentials.yaml,与 provider 的 API key 同一个文件),形如:

refs:
  ALIBABA_CLOUD_ACCESS_KEY_ID: LTAI...
  ALIBABA_CLOUD_ACCESS_KEY_SECRET: ...
  DEEPSEEK_API_KEY: sk-...

两种写入方式,任选:

  • GUI:插件 → 内置插件设置 → Models / 凭据,按 API key 的方式存(键名用上面的 ref 名);
  • 命令行(secret 不进 shell 历史,也不进对话):
$sec = Read-Host -AsSecureString 'Paste AccessKey Secret'
$plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
  [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
# 写入 $DSH_HOME/.credentials.yaml 的 refs 段后,立即删除任何临时明文文件

⚠️ 多机部署:每台机器都要各自配置一次凭据(凭据库不跨机同步)。 额度/余额在服务端,所以多机读到的数字一致,不存在方案 A 那种"各机各算、互不知情"的偏差。

状态接口

GET /api/mydsh-auto-model-router/status 的 quotaWatch 字段:

{
  "enabled": true,
  "lastCheckAt": "2026-10-22T09:57:18Z",
  "lastError": null,
  "balanceCny": 28.21,
  "cycle": "2026-10",
  "spendByModel": { "glm-5.3": 62.5611, "qwen3.8-flash": 8.0478 },
  "totalCny": 70.67,
  "exhausted": [],
  "balanceLow": false,
  "deepseekBalanceCny": 158.25,
  "deepseekBlocked": false,
  "deepseekError": null
}

任何一环失败都只写进 lastError / deepseekError,不会影响路由本身。

发布到 npm

目录新鲜度守卫(modelsGuard)

模型版本升级后,provider 不再公布的 id 会变成「过期路由」。守卫在激活时与每 refreshMs(默认 10 分钟)用 ctx.llm.listModels(provider) 读取各 provider 实际公布的模型:过期档位的请求自动漂移到最近的鲜活档位(绝不静默打死), 状态报告列出过期档位与同族候选 id,改档位仍是一行编辑的事。目录读不到或 为空时一律 fail-open(不判过期)。未注册 listModels 的环境整层惰性。

modelsGuard: { enabled: true, refreshMs: 600000 }

静态列表本身仍是配置(cordis.patch.yml)——这是刻意的:插件不会替你决定 换成哪个模型(窗口/单价/能力都变了),它只保证坏档位不再被选中并告诉你该换什么。

发布到 npm

两个插件同一仓库、各自独立发布(root 与本目录的 package.json 都可直接 publish):

npm publish                 # @apinggithub/mydsh-auto-model-router(root)
cd tier-cost && npm publish # @apinggithub/dsh-tier-cost

0.2.0 起 peerDependencies 已放宽为宿主自适应范围,新机器安装不再需要 allow-version 豁免(豁免仅 0.1.1 及更早需要)。发布前各自 npm login --scope=apinggithub。

免费额度的静默计费陷阱(真实事故 · 守则)

本仓库作者机器上的实录事故:L1 指向百炼免费模型 glm-5.3(freeQuota: true),额度耗尽后 服务商没有按「用完即停」返回 403(批量开关未覆盖到该模型/生效延迟),调用照常成功—— 于是 demoteOnError 无错可捕,glm-5.3 静默转按量计费跑了一整天,直到账单通知才被发现。

三条守则,缺一不可:

  1. 先在服务商侧逐模型确认「用完即停」真的生效(发一条超额调用看是否返回 403,别信批量开关)。 路由器只能在看得见错误时换线;额度耗尽若被服务商静默转按量,插件无从得知。
  2. costControl.defaultLevel 指向稳定付费档(本文各处示例的 defaultLevel: L1 若 L1 是免费档, 正是本次事故形态)。免费档只放最低层(L0),或至少每个免费档都有明确的付费替代。
  3. 用计费面板按档核账:任何"免费"档的费用行持续大于 0 且不归零,立刻怀疑额度已尽、 在按量扣费——把该档从免费路由摘下来。

另一个易错点:免费额度池按 provider+model 独立计数。同一个模型 id 出现在多个 provider (如 qwen3.8-flash 同时在 bailian 与 qianwen)是不同的池子,额度与计费互不相干。