🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

模型轮转器 chimneyQ/dsh-model-rotator

DeepSeek Harness
chimneyQ13 天前有提交已提供中文文档扫描:含敏感能力

dsh-model-rotator · 模型自动排序与故障转移

30/100
综合分
1
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/25 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

自动兼容检查有告警:未发布到 npm registry,仅可从源码安装。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add chimneyQ/dsh-model-rotator
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:待复验
是什么
dsh 原生插件 · model
装得上吗
曾在 dsh 0.2.0-rc.2 下真实安装成功,但当前 dsh 已更新至 0.2.1-rc.5,本站尚未在新版下复验
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 13 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查⚠ 装前注意

未发布到 npm registry,仅可从源码安装

展开查看检查明细 ↓

以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。

✗npm 包dsh-model-rotator(未发布到 npm,仅可源码安装)
✓Node 引擎要求 >=18 · 基线 Node 22.19 满足
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明

验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/16 04:09:16

安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

读写/删除本地文件发起外部网络请求
查看扫描证据(共 60 处,展示前 34 条)
  • 读取本地文件
    index.js:182
    for (const profile of readdirSync(join(dshHome, 'profiles'))) {
  • 读取本地文件
    index.js:195
    for (const profile of readdirSync(join(dshHome, 'profiles'))) {
  • 写入本地文件
    index.js:249
    mkdirSync(dir, { recursive: true })
  • 写入本地文件
    index.js:251
    writeFileSync(join(dir, 'schema-probe.json'), JSON.stringify(payload, null, 2) + '\n')
  • 读取本地文件
    index.js:553
    const parsed = JSON.parse(readFileSync(path, 'utf8'))
  • 读取本地文件
    index.js:1147
    text = readFileSync(configPath, 'utf8')
  • 写入本地文件
    index.js:1268
    mkdirSync(dirname(configPath), { recursive: true })
  • 写入本地文件
    index.js:1269
    writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf8')
  • 读取本地文件
    index.js:1274
    try { cachedFileText = readFileSync(configPath, 'utf8') } catch { cachedFileText = null }
  • 写入本地文件
    index.js:1337
    mkdirSync(dirname(statsPath), { recursive: true })
  • 写入本地文件
    index.js:1338
    writeFileSync(statsPath, JSON.stringify({ version: 1, rows }, null, 2) + '\n', 'utf8')
  • 发起网络请求
    client.js:580
    return fetch(MODELS_PATH, { headers: { accept: "application/json" } })
  • 发起网络请求
    client.js:605
    fetch(MODELS_PATH, {
  • 发起网络请求
    client.js:1064
    const read = (url) => fetch(url, { headers: { accept: "application/json" } })
  • 读取本地文件
    release-check.mjs:9
    const read = (f) => readFileSync(pub + '/' + f, 'utf8')
  • 读取本地文件
    release-check.mjs:21
    ck('1 dsh.bundle patch declared and on disk', manifest.dsh?.bundle?.patch === './cordis.patch.yml' && existsSync(pub + '/cordis.patch.yml'))
  • 读取本地文件
    release-check.mjs:23
    ck('3 every files[] entry exists', (manifest.files ?? []).every(f => existsSync(pub + '/' + f)), JSON.stringify(manifest.files))
  • 读取本地文件
    release-check.mjs:24
    ck('4 every exports[] target exists', Object.values(manifest.exports ?? {}).every(v => existsSync(pub + '/' + v)), JSON.stringify(manifest.exports))
  • 读取本地文件
    release-check.mjs:27
    ck('7 main entry resolves', manifest.main === 'index.js' && existsSync(pub + '/index.js'))
  • 读取本地文件
    release-check.mjs:53
    ck('14 the submission entry is present', existsSync(pub + '/' + yamlFile))
  • 读取本地文件
    test-client.mjs:58
    const source = readFileSync(join(pluginDir, 'client.js'), 'utf8')
  • 发起网络请求
    test-offline.mjs:768
    const get = await h.request('GET')
  • 发起网络请求
    test-offline.mjs:780
    const off = await h.request('POST', { key: 'provA/alpha', enabled: false })
  • 发起网络请求
    test-offline.mjs:792
    const on = await h.request('POST', { key: 'provA/alpha', enabled: true })
  • 发起网络请求
    test-offline.mjs:797
    await h.request('POST', { key: 'beta', enabled: false })
  • 发起网络请求
    test-offline.mjs:800
    const bareOff = await h.request('GET')
  • 发起网络请求
    test-offline.mjs:802
    const bad = await h.request('POST', { key: ' ', enabled: false })
  • 写入本地文件
    test-pin-cooling.mjs:16
    writeFileSync(cfgFile, JSON.stringify({ pinned: 'provA/beta' }), 'utf8')
  • 写入本地文件
    test-pin-cooling.mjs:17
    writeFileSync(statsFile, JSON.stringify({
  • 写入本地文件
    test-settings.mjs:94
    writeFileSync(cfgPath, JSON.stringify({ enabled: true, maxCandidates: 2, cooldownBaseMs: 123456 }), 'utf8')
  • 写入本地文件
    test-settings.mjs:141
    writeFileSync(cfgPath, JSON.stringify({ pinned: '' }), 'utf8')
  • 读取本地文件
    test-settings.mjs:143
    const onDisk = JSON.parse(readFileSync(cfgPath, 'utf8'))
  • 写入本地文件
    test-settings.mjs:152
    writeFileSync(cfgPath, JSON.stringify({ enabled: true, maxCandidates: 2, cooldownBaseMs: 123456 }), 'utf8')
  • 删除本地文件递归
    test-settings.mjs:164
    rmSync(tmp, { recursive: true, force: true })
扫描于 2026/10/31(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

为 DeepSeek Harness 提供模型自动排序与透明故障转移:给发现的模型排名,当前模型无输出即失败时自动切到下一个候选。

README

由 DeepSeek 最新模型翻译生成
dsh-model-rotator · 模型自动排序与故障转移 给 DeepSeek Harness 装上一个「换模型的自动驾驶」:当前模型撞上额度墙、密钥失效、被限流,或者网络刚好抖动时,插件会在同一轮对话之内换到下一个最合适的已配置模型,继续把这轮回答完——用户看到的仍然是一条普通回复,不会中途断掉。 本机实测规模:906 个 provider/model,其中 384 个在轮转池里,跨 48 个渠道(24 个对话渠道 + 24 个识图渠道)。 它到底做了什么 一轮对话失败时,插件依次做这几件事: 1. 摸清家底:调 ctx.llm.listProviders() 加各适配器的 listModels(),枚举本机所有能拨通的 provider/model。 2. 剔除非对话模型:向量/重排、语音合成与识别、OCR、图像与视频生成、内容审核、评判模型等,一律不进池子。理由很直接——拨它们只可能让本轮失败,还会白占一个本该留给真模型的冷却位。 3. 跳过识图孪生:dsh-vision-router 会给每个渠道套一层 <provider>-vision 的「识图外壳」,它的作用是给同一个模型加读图能力。也就是说,它是同一个模型的第二条通路,不是第二个模型。把文本轮次切过去,等于让回答绕道图像管线;更糟的是「优先选同款模型」这条规则会把孪生排到最前面。
展开完整文档(README 剩余 1 节)
dsh-model-rotator · 模型自动排序与故障转移

给 DeepSeek Harness 装上一个「换模型的自动驾驶」:当前模型撞上额度墙、密钥失效、被限流,或者网络刚好抖动时,插件会在同一轮对话之内换到下一个最合适的已配置模型,继续把这轮回答完——用户看到的仍然是一条普通回复,不会中途断掉。

本机实测规模:906 个 provider/model,其中 384 个在轮转池里,跨 48 个渠道(24 个对话渠道 + 24 个识图渠道)。

它到底做了什么

一轮对话失败时,插件依次做这几件事:

1. 摸清家底:调 ctx.llm.listProviders() 加各适配器的 listModels(),枚举本机所有能拨通的 provider/model。
2. 剔除非对话模型:向量/重排、语音合成与识别、OCR、图像与视频生成、内容审核、评判模型等,一律不进池子。理由很直接——拨它们只可能让本轮失败,还会白占一个本该留给真模型的冷却位。
3. 跳过识图孪生:dsh-vision-router 会给每个渠道套一层 <provider>-vision 的「识图外壳」,它的作用是给同一个模型加读图能力。也就是说,它是同一个模型的第二条通路,不是第二个模型。把文本轮次切过去,等于让回答绕道图像管线;更糟的是「优先选同款模型」这条规则会把孪生排到最前面。所以这些路由永不作候选(注意:只是名字里带 vision 的真对话模型不受影响,仍然照常参与)。
4. 排序:按实测连通率(拉普拉斯平滑的成功率)、速度(TTFT 中位数与吞吐)、以及当前冷却状态排出一张候选表。你正在用的那个模型、或者它在别的渠道上的近亲,会拿到加权——毕竟你选它自有道理,多半只是这条渠道坏了。
5. 先重试,再判死:渠道瞬时抽风很常见(限流毛刺、连接被重置、信道过载),所以单次失败不算故障。同一个模型还会按 retriesPerModel 再试几次(默认 2 次,可设 0–5),每次间隔 retryBackoffMs(默认 300ms,之后翻倍)。重试不等于切换——它不问你、也不改任何状态。
6. 真的换:在 llm/stream 瀑布上接管。第一次尝试走宿主原路径;只有当这次失败一个字的正文都没产出时,才放弃这次尝试、关掉它的上游流,用全新的 ctx.llm.stream() 重新下发下一候选。

两条边界,是整件事的关键

已经出过正文就不再重试。 一旦有任何正文分片吐给用户,这次尝试就锁死了——再试一遍会让用户看到重复的文字。所以「可重试窗口」精确等于「零内容窗口」。

不是所有分片都算内容。 reasoning-delta 不算:重试时宿主会把思考过程连同那一轮一起折叠掉,这正是「思考死循环」「思考强度不被支持」这类失败可以安全重试的原因。标记分片(block-start,或携带思考块的 block-end)也不算。这里踩过坑:早先版本把一个 block-start 当成内容,于是窗口刚开就被关上,紧接着的一个普通错误被当成终局——一个明明活着的模型看起来像死了。

被取消的轮次不折腾。 用户点了停止、超时到点、客户端断开——走链立即停下,不会把候选浪费在没人再看的回复上。

自动调用不当作对话轮次

压缩摘要和会话标题也走同一条 llm/stream 接缝,但它们不是用户轮次。这类调用的拒绝,通常说的是那条专用通道的状况(比如代理回一句「压缩归 bili 管」),此时换一个 provider 去干这段管道活是错误反应:既花掉一个真模型去做没人读的工作,又是虚假切换的头号来源。所以凡是带 purpose: "compaction" 或 purpose: "session-title" 的调用,一律原样透传,错误也透传;没带标记的普通轮次照旧故障转移。

换了模型会告诉你

切换本来是无声的——回复只是换了个模型回来,你根本看不出来。现在它记录在对话顶部的 「模型切换」页签里(与 对话 / 轨迹 / 上下文 并列),逐条列出时间、从哪个模型到哪个模型、以及原因:

14:07:31 modelscope/deepseek-ai/DeepSeek-V4.1-Flash → trae/deepseek-v4.1-flash (quota)

页签的数据来自插件自己的记录(GET /_dsh/model-rotator/switches?session=<会话 id>),不占用对话上下文。页签所在的会话由宿主注入:注册时声明 inject: (sessionId) => ({ sessionId }),组件从 props.sessionId 取——与内置的 对话 / 轨迹 页签同一条契约(0.2.11 前这里读的是 sessions.list 上一个并不存在的字段,页签因此认不出自己在哪个对话里)。记录按会话归属:宿主不向插件路由注入会话,所以会话 id 在切换发生处当场取定(ctx.get('agents').currentInitiator() 返回发起该次调用的 agent),而不是等轮次边界——边界是进程级的,谁能停轮次谁就能把别人的记录整批带走。若某次调用确实没有活动轮次(插件路由、宿主未挂 agents),记录照写、会话留空,空会话只在「全量视图」里可见。

当本对话名下一条记录都没有、而记录总数不为零时,页签会改为展示全部会话的记录,并配一行提示说明这是回退视图——避免「有历史却显示空」。

若你仍想在对话正文里看到那一行(旧行为),把 noticeSwitch 打开即可:

模型已切换:硅基流动/deepseek-4.1 → Loomy/deepseek-4.1 (rate-limit)

已知失败类型时会带上括号,比如 (quota)、(rate-limit)。

| 项目 | 做法 |
| 默认 | 不贴(noticeSwitch: false)。记录在页签里,通知行是可选项 |
| 形态 | 复用宿主自带的通知机制:一条 user/message,source 是 { kind: 'model-rotator/switch', form: 'notice', summary }。对话界面把它渲染成一条上下文行,而不是你发出去的消息 |
| 时机 | 通知行放在轮次边界,经 agent/turn-stopping + agent.steer 投递。切换已经发生、回答已经在流,若在轮次中途插一行,就会插在它所描述的回复正中间(记录本身不在此刻写,见上文) |
| 范围 | 只作用于根对话。子代理记录里的切换属于别人的日志,不打扰你 |
| 打开 | noticeSwitch: true,或设置页里的开关 |

事件类型刻意没自创。持久化读取器会拒绝解释含有未知类型的事件日志(dsh-session 的 KNOWN_SESSION_EVENT_TYPES),自创一个类型会让带它的日志直接加载失败。user/message 是已知类型,notice 是客户端已经理解的形式——宿主自己那条「模型已切换」用的就是这一对。

渠道名优先用宿主发布的 provider displayName(siliconflow 显示成「硅基流动」、modelscope 显示成「魔塔」、buddy 显示成 «CodeBuddy (腾讯)»),没有标签的路由才回落到 id;两者不同时会写成「标签 (id)」的形式。

换之前先问你

故障转移会替换你亲手选的模型,而且可以不声不响——所以默认先问。当前模型零内容失败时,插件经宿主的人机交互接缝(ctx.userQuestions.askTimed())在对话里抛一个问题,给出两个选项:切换 和 保持当前模型。

| 你的回答 | 结果 |
| 切换(或任何不算拒绝的回答) | 下一候选接手本轮 |
| 保持当前模型(或直接打出「不/否/取消/拒绝/no/cancel/stop」之类) | 不再拨任何候选,本轮以原始错误结束 |
| 窗口内没回 | 继续切换——这是一次限时征询,不是否决权 |

提问每轮只问一次:第一次批准即覆盖这一轮剩下的走链,所以一串坏模型不会变成一串一模一样的卡片。默认等 20 秒,可配。

两类失败不问了。 额度耗尽(quota)与提供方不支持(unsupported)是确定性死路:余额不会在 10 秒里回来,不支持的请求下一次仍会被同样拒绝。这种时候「保持当前模型」并不能让模型继续工作,只会让本轮失败——白白多读一张卡片、再等一次超时。所以这两类直接切换,页签里的记录照常写明原因。鉴权(auth)仍然问:403 常常来自突发限速的代理,等一会儿再试是真实的选择而不是形式。

整个机制刻意是「失败即放行」的:接缝不存在、提问抛错、本轮没有活着的根代理(被委托的子代理没人可答)、或者等超时了——走链都会在未确认的情况下继续。无人值守的宿主绝不该卡在一张没人看的卡片上。

失败类型决定后事怎么办

失败不只是一个字符串,它会被归类,而类别决定这个模型要「歇」多久。

额度墙和限流是两码事。 429 insufficient balance 和 429 too many requests 是同一个 HTTP 状态码,但一个几秒后自己就活了,另一个得等额度重置。插件会从消息正文里读语义——insufficient quota、out of credits, please top up、余额不足,请充值、额度已用尽、402 Payment Required 这类——然后区别对待:

| 类型 | 冷却多久 | 要不要先重试 |
| quota(额度耗尽) | 歇到次日 quotaCooldownHour(默认本地 00:00),且不少于 quotaCooldownMs | 不重试——两次下发之间余额不会自己长回来 |
| auth(鉴权失败) | 常规指数退避 | 重试——被突发限速代理解出来的 403 确实会好 |
| unsupported(请求形态不被支持) | 常规指数退避 | 不重试——同一个 provider 对同一个请求只会给同样的拒绝 |
| rate-limit(限流) | 常规指数退避 | 重试 |
| server / network(服务端/网络) | 常规指数退避 | 重试 |
| 未分类 | 常规指数退避 | 重试 |

unsupported 指的是请求形态本身被拒,比如「provider X model Y does not support reasoning effort high」「unsupported model」。它在读请求之前就判定了,重试只会逐字复现同一个拒绝——跳过重试,才能把一次会话级不匹配压成一次切换,而不是三次。

鉴权失败会先于裸 429 被归类,因为等待永远修不好一个密钥:对 429 invalid api key 来说,诚实的名字是 auth 而不是 rate-limit。这个区分只用于记录,不改变处置——两者退避一样;只有 quota 会改变策略。

冷却的下限是「次日 quotaCooldownHour」。23:50 撞上的额度墙仍要付满 quotaCooldownMs,所以它会在次日 11:50 回来,而不是十分钟后的午夜。

model_rotator_status 会把这些都摊开给你看:排名表把这类行标成 QUOTA-REST(并附剩余小时数)而不是 COOLDOWN,type 列写明类别,last failure seen 行列出每个模型最近一次错误及其类型。

实测状态——计数器、最近错误与类型、冷却到期时间——跨进程存活。理由是现实的:「刚被告知余额用完」之后做的那次重启,不该把冷却一并抹掉。它写在 %DSH_HOME%\model-rotator\stats.json(或者 config.json 所在目录),去抖落盘,首次使用时读回一次。延迟采样则刻意只留在内存——它们是 20 个样本的滑动窗口,陈旧的窗口比短的更糟。statsPersistTtlMs 限制读回文件的年龄(默认 24 小时;设 0 表示完全不用这个文件)。

它不碰什么

它从不读、写或改写模型/provider 配置。 不碰 cordis.patch.yml、不碰 agent-default-model、不碰 llm-pi-ai、也不碰任何 provider 注册。它只在运行时观察流式瀑布。如果你的 provider 配置被人动过,那一定不是它干的。

哪些模型会被过滤掉

宿主适配器在交给 listModels() 之前,会把目录自带的 wire: 'chat' 标记剥掉,所以没法从返回行上读能力。名字匹配是唯一可用的信号,而且刻意写得很窄——模式绝不能误伤只是名字里提到某种模态的对话模型(Qwen3-VL-8B-Instruct 是对话模型,保留;Z-Image-Turbo 不是,剔除)。

过滤器覆盖向量与排序、语音、图像生成、文档解析、安全分类器这几大族,普通 provider 与 -vision 变体都算。它在发现阶段就跑,所以插件报的 discovered 已经是过滤后的池子。也因此,一台机器的数字完全取决于它配了哪些 provider——别从本文档抄数字,直接问实际安装:

model_rotator_status # 看 "discovered" 一行,那就是过滤后的候选数

三个工具

| 工具 | 干什么 |
| model_rotator_status | 只读:启用状态、配置路径、已发现池、实测排名,附带「当前生效设置来自哪一层」。 |
| model_rotator_benchmark | 用最小真实请求探测候选,刷新 TTFT/吞吐与冷却状态(有次数上限,会花掉一点额度)。 |
| model_rotator_select | 固定或取消固定转移顺序的队首;队尾仍然由排名决定。 |

状态工具还会报出两个手动清单和识图规则,这样「目录里看得见、排名里看不见」的模型,不必去读代码就能解释清楚:

disabled models : workbuddy/hy3
vision twins : never a candidate (the dsh-vision-router mode wraps a model, it is not a second one)
model list route : /_dsh/model-rotator/models (GET the grouped list; POST {key, enabled} to switch one off or on)

配置

可选,路径 %DSH_HOME%\model-rotator\config.json,改完即热加载。每个字段都有安全默认值,所以完全不建这个文件也能跑。

{
"enabled": true,
"allowImageFailover": false,
"maxCandidates": 4,
"cooldownBaseMs": 60000,
"maxCooldownMs": 1800000,
"quotaCooldownMs": 43200000,
"quotaCooldownHour": 0,
"statsPersistTtlMs": 86400000,
"confirmSwitch": true,
"confirmTimeoutMs": 20000,
"adoptSwitchedModel": true,
"retriesPerModel": 2,
"retryBackoffMs": 300,
"preferSameModel": true,
"weights": { "connectivity": 0.5, "speed": 0.3 },
"excludeProviders": [],
"excludeModels": [],
"excludePatterns": [],
"disabledModels": [],
"enabledModels": [],
"disabledProviders": [],
"pinned": ""
}

- enabled —— 总开关。
- allowImageFailover —— 图像轮次默认跳过。理由:纯文本备选读不了图,在那里转移只会得到「错误答案」而不是「缺失答案」。
- maxCandidates —— 每轮最多试几个(硬上限 8)。
- cooldownBaseMs / maxCooldownMs —— 指数退避的上下界(base * 2^(n-1),截断到 max)。
- quotaCooldownMs —— 额度墙之后的最短冷却(默认 12 小时,上限 26 小时)。从失败那一刻起算,不按整点取整。
- quotaCooldownHour —— 本地小时(0–23),耗尽的模型可以在这时刻再被尝试(默认 0 = 次日零点)。冷却总是走到次日的那个时刻,所以 00:05 撞的墙不会当晚就复活。
- statsPersistTtlMs —— 实测状态跨重启存活多久(默认 24 小时,上限 30 天;0 = 不持久化)。
- confirmSwitch —— 离开失败模型前先在对话里问一句(默认 true,见上文)。
- confirmTimeoutMs —— 那个问题等多久之后仍然照切(默认 20000;限 1 秒 … 10 分钟)。
- adoptSwitchedModel —— 切换之后,把真正作答的模型设成本会话的模型,让会话头部显示实际在用的模型(默认 true)。仅限会话内:不动 provider 配置、不写任何默认值;宿主自己那条「模型已切换」通告也会随之报出正确的组合。
- retriesPerModel —— 认定失败之前同一模型的额外尝试次数(默认 2,硬上限 5)。重试不需要确认,因为它不换模型。
- retryBackoffMs —— 每次重试前的停顿(默认 300;第 n 次重试等 n 倍,封顶 10 秒)。
- preferSameModel —— 优先排同款模型在别的渠道上,其次近亲,最后才按实测排名(默认 true)。
- windowGuard —— 上下文窗口装不下「当前对话 + 本轮回复」的故障切换候选直接跳过(默认 true)。窗口取自拥有该路由的适配器(resolveModelInfo),不是目录声明:profile 可以声明窗口,但只有实际发起请求的一方知道装不装得下。适配器没公布窗口的候选永不跳过;宿主报不出对话压力的场合整个守卫不生效;与当前路由同窗口的候选也豁免——守卫管的是「换过去」。被扣下的候选在模型列表里标记 window,status 工具会报出它依据的压力读数。
- noticeSwitch —— 是否额外在对话里贴那一行切换通知(默认 false;不贴时切换记录仍在「模型切换」页签里)。
- weights —— 评分里连通率与速度的配比。
- excludeProviders / excludeModels —— 把指定路由排除出池子。
- excludePatterns —— 追加到非对话黑名单的正则;写错的正则会被跳过,不会致命。
- disabledModels —— 在模型列表里逐行关掉的模型。每条是 "provider/model",也可以只写裸模型 id(那样会命中所有提供该模型的渠道)。行级条目压过渠道级规则。
- disabledProviders —— 在渠道表头上点「关闭本渠道」关掉的整条渠道,一条记录一个 provider id。关闭渠道时会一并清掉该渠道内遗留的逐行 enabledModels,免得按钮被静默撤销;此后单独开启某一行会写一条新记录,照样生效。
- enabledModels —— 相反方向。也是当某个 wrapper 命名不符合 <provider>-vision 约定时,把识图孪生单独放回轮转的唯一方式。由同一个开关写入。
- pinned —— 转移顺序的优先队首。

设置页与模型列表

插件两半都齐:服务端有给 model-rotator 命名空间用的 Config schema,浏览器端有 client.js(经 dsh.client 与 exports["./client"] 声明),同一个组件渲染到宿主的两个位置:

- 设置 → 模型自动切换:设置导航里的独立入口(settings.section,id model-rotator,order 20)。
- 插件面板里的 model-rotator 卡片(plugins.item),卡片正文是同一套表单。

两处都只在宿主真的提供该命名空间时出现。页面上只放「个性化安全」的字段:

| 字段 | 含义 |
| enabled | 总开关 |
| allowImageFailover | 图像轮次也参与转移 |
| maxCandidates | 一轮最多试几个模型 |
| cooldownBaseMs | 退避基数 |
| maxCooldownMs | 退避上限 |
| quotaCooldownMs | 额度墙后的最短冷却 |
| quotaCooldownHour | 耗尽模型归来的小时点 |
| statsPersistTtlMs | 实测数据跨重启存活多久 |
| confirmSwitch | 切换前先问 |
| confirmTimeoutMs | 问句等待时长 |
| adoptSwitchedModel | 把切换后的模型落到本会话 |
| retriesPerModel | 认定失败前的尝试次数 |
| retryBackoffMs | 重试之间的停顿 |
| preferSameModel | 优先同款模型换渠道 |

weights.connectivity / weights.speed 不在页面上:表单模型一次只承载顶层键,嵌套值留在 config.json。需要手工或工具批量编辑的长清单——excludeProviders、excludeModels、excludePatterns、pinned——也留在那里。

模型列表

表单下方,同一页渲染出模型列表:每一个已发现的 provider/model,带健康状态和自己那一行的开关;另外每条渠道的表头上都有一个开关,一键关掉该 provider 背后的所有模型。两级是刻意设计的:密钥或端点整条死掉时,「关整条渠道」才是你要的那一下;而某一个模型坏了、邻居还好着时,你需要的又是行级开关。

列表分成两段,因为它们回答的是两个不同的问题:

| 分区 | 内容 |
| 对话渠道 | 真正参与自动切换的候选 |
| 识图模式(vision-router 创建) | <provider>-vision 孪生,每条都标注它包裹的是哪个渠道(amd-vision → amd) |

折叠分三级、互不干扰:分区、分区内的渠道、整张表。对话渠道默认展开,识图分区默认收起——因为孪生数量和其余模型一样多(本机最近一次实测:906 行里 453 行是孪生),而且它们永不作候选,收起来才让页面可读。全部展开 / 全部收起 两个按钮一次操作所有分区与渠道。

真实长相(本机实测):

▼ 对话渠道 453 个 · 可用 361 · 冷却 23 · 关闭 69 ▼ 硅基流动 (siliconflow) 65 个 · 可用 63 · 冷却 2 · 关闭 0 [关闭本渠道] ● deepseek-v4.1-flash 冷却中 retry in 511m · rate-limit [关闭] ▼ AMD (amd) 8 个 · 可用 5 · 冷却 3 · 关闭 0 [关闭本渠道] ▶ 识图模式(vision-router 创建) 453 个 · 可用 0 · 冷却 0 · 关闭 453

| 列 | 含义 |
| 圆点 + 标签 | 可用(没在冷却、没有失败记录)、未测(还没试过)、冷却中、额度耗尽(歇到次日边界)、异常(上次失败)、已关闭 |
| 明细 | TTFT 与成功/尝试次数,或剩余冷却,或这一行为什么不在轮转里 |
| 开关 | 关闭 / 启用 |

一行「不在轮转」的原因会写明,而不是笼统灰掉:manual(在这里手动关的)、vision(识图孪生,默认排除但开关可以推翻)、provider / excluded(来自 config.json)、non-chat(非对话模型族)、combo(虚拟路由)。只有前两种能在页面上开关,其余要么是结构性的,要么归文件管。

渠道级开关的几条规则(都是实测踩出来的):

1. 表头按钮的标签取自渠道自己的开关状态,而不是「有没有某一行在轮转」——整条渠道的行因为行级原因都关着,它依然是开启状态;从表头关掉的渠道,必须给出能重新打开它的动作。
2. 已关闭的渠道按钮显示启用本渠道,同时下面的行级开关仍然可用,方便你把某一个模型单独放回,而不必整条渠道重开。
3. 关闭渠道会清掉渠道内遗留的逐行覆盖(这样按钮才说到做到);而你在这之后单独打开某一行,那条新记录照样赢(新的、明确的表态压过旧的、笼统的表态)。
4. 识图渠道不提供渠道级开关。它的行是被孪生规则挡在外面的,渠道开关撬不动它们——放个按钮在那里,等于写了一笔改不动任何东西的配置却看起来像个承诺。请用行级开关。

列表的数据不走设置表单,走 HTTP:路由 /_dsh/model-rotator/models。

- GET 返回分组列表:groups 是两个分区,channels 每个 provider 一条(带 kind 和孪生所包裹的 sourceChannel),models 是所有行。分区和渠道带同一套 models/ok/resting/off 计数器,所以一种行形状就能渲染两级,折叠时也照样报得出状态。
- POST {key, enabled, scope} 翻一个开关并回传刷新后的列表。scope: "provider" 用于渠道表头;省略或 "model" 用于单行。

表单承载你的策略(试几个、等多久),列表是名册——provider 一重新发布它就变;而它的每一行是逐个直接写进 config.json 的,所以刷新不会把你正打到一半的数字抹掉。路由通过 ctx.inject(['webServer'], …) 注册,因为宿主是在插件加载之后才发布 web 服务器的;它和其他所有非公开路径一样,由宿主统一做鉴权。

两层配置合并规则是 默认值 < config.json < 设置页。设置页只在它真正拥有的键上获胜:它没设过的键,值仍然来自文件。model_rotator_select 从不把「页面拥有的键」写回 config.json,所以一个陈旧的文件值永远不会伪装成权威。

命名空间就是插件的 cordis id(model-rotator)。只有服务端一半、没有客户端一半,页面上什么都看不到——这也是两半必须一起发的原因。Config 导出需要 schemastery 在宿主进程内,所以插件在 vendor/node_modules/ 里自带了副本,并在裸导入解析不到时按绝对路径加载它——这样在「manifest link 指向 profile 的 node_modules 之外」的安装形态上,页面也照样出得来。

model_rotator_status 的最后有一块 in force right now,逐行列出每个生效键的合并值与它来自哪一层([default] / [config.json] / [settings page]),不用你自己读两个文件在脑子里做合并。再后面几行给出实时画面:ask channel(切换前的问句到底能不能送达你,不能的话为什么)、adopt on switch、model in use now(从本会话请求头读)、session switch、failure verdict、switch order。

安装

从 npm 安装(推荐):

dsh plugin --profile desktop add dsh-model-rotator

从 GitHub 安装:

dsh plugin --profile desktop add github:chimneyQ/dsh-model-rotator

如果你手上是本地目录,同一条命令换成路径即可:

dsh plugin --profile desktop add <本目录路径>

包内声明了 dsh.bundle.patch,所以它自带的 cordis.patch.yml 会自动接线;它没有任何需要构建的依赖。装完需要完整重启应用才会加载。

确认宿主是否真的把它接上了(首次安装失败可能只写下依赖、却没把插件加入 dsh.profile.bundles):检查 <DSH_HOME>/profiles/desktop/package.json 的 dsh.profile.bundles 里是否列出 dsh-model-rotator。

测试

node test-offline.mjs # 故障转移 + 确认闸门,跑在 stub 宿手上
node test-settings.mjs # 服务端设置契约,对着宿主自己的规则
node test-client.mjs # 浏览器端:加载器形态、卡片注册、字段
node release-check.mjs # 发布自检:manifest / files / exports / 收录条目

四条都不需要网络、也不需要跑着 DSH。离线套件覆盖「失败后重试」「一旦出正文就不再重试」「图像轮次跳过」「所有候选都失败」「冷却记账」「取消信号透传」「经真实瀑布的非对话过滤」「工具注册」「确认闸门」(提问关闭 / 批准 / 按钮拒绝 / 文字拒绝 / 超时 / 每轮一问 / 各条失败即放行路径)。客户端套件用真实的 hooks 运行时渲染名册(状态、副作用、记忆化回调、重渲染)并按下读者会按的按钮,断言返回的东西——两个分区、推导出来的计数、每一级折叠的收放、以及列表只被拉取一次。正则只能证明某个功能被提到过,只有渲染才能证明它真的能用;早先一个重复拉取的循环,正是被这套渲染测出来的。

许可

MIT
预览分享卡片长什么样 ↓chimneyQ/dsh-model-rotator
分享 · DeepSeek Harness Hub
chimneyQ/dsh-model-rotator

dsh-model-rotator · 模型自动排序与故障转移

回到顶部