liuyun847/dsh-llm-auto

dsh-llm-auto

DSH 宿主插件:注册 auto 模型,按有序回退链在多条 provider+model 间静默切换 (DeepSeek Harness)

待分类dsh

dsh-llm-auto

给 DSH 加一个 auto 模型:模型选择器里多出一个 Auto 分组,组内一条 auto。 选它以后,请求依次尝试多条「provider + model」,某条失败会先按官方同款策略在该路由内原地重试 (瞬时错误,默认最多 5 次),重试耗尽才静默切下一条 —— 对上层(agent loop / 会话日志 / 压缩链路)它就是一个普通模型。用来把多个模型订阅"合并"成一个入口。

模型选择器给 auto 两档推理等级(0.10.0 起):「Default」(插件一个档位都不写,由宿主/上游 自己的默认决定)与「max」(逐跳取该路由能给的最高档,= 0.9.x 的旧行为)。细节与前提见 §3「推理档位:默认 / max 两档(0.10.0 起)」。

0.8.0 起,顺序默认由插件按订阅额度自动决定:有月度重置时刻的订阅按「越早重置越靠前」排, 查不到重置时刻的按你写的顺序,永不过期的按量兜底永远最后;某一跳因窗口额度耗尽被拒 (错误码 QUOTA,或报文自带窗口语义 —— 0.9.1 起判据放宽:Command Code 那种 You've reached your weekly usage limit for your plan. Your limit resets at … 也算,见 §2) 时,插件会补查该来源的额度接口,确认后把它冷却到对应档位的重置时刻 (面板上写明是哪一档,例如「月度已耗尽,10-24 00:00 恢复」),期间不再尝试,到点自动恢复。 0.11.0 起这份冷却表落盘(宿主领域存储,跨 dsh 重启与插件重组合存活到重置时刻 —— 不再有"重启后 第一条白撞");被 QUOTA 拒的当下先同步记一条 5 分钟短闸门(补查确认后换成重置时刻),面板冷却行 右侧多一个「解除」按钮(额度提前恢复时用)。0.11.1 起那条「解除」指令的"已消费"记录也落盘 —— 面板写进配置的值一变就生效,但面板不会把它清掉,所以"这个值已经消费过了"这份记性得插件自己存: 不存的话宿主一重启,旧指令就被当成新指令再消费一次(清冷却 + 删介质记录 + 第一条请求又白撞那家, 2026-10-12 真机实测)。同轮还加了一条陈旧判据:指令里的时刻早于本进程启动时刻 ⇒ 只记为已消费、 什么都不动(面板不可能在宿主没运行时被点到)。想回到「完全按你排的顺序、只失败时切换」的旧行为, 把 ordering.mode 设成 manual(面板上就有这个开关)。

还能把自动压缩点钉在你要的位置(compactWindow,默认 50 万 token):DSH 的压缩引擎按 "该请求声明的窗口"算阈值,而本插件的窗口是按压缩点反算出来的。

插件页那张卡片上还有一块 回退链面板:0.5.0 起只读,0.6.0 起可编辑 —— 拖拽行首手柄改顺序、点行内模型名从 live 模型目录里换一条、按行开关 keepThinking/breakToolLoop, 点保存即写入 profile 的 cordis.patch.yml 并免重启生效;面板下半仍是最近若干次请求实际 怎么回退的(哪条失败、什么错误码、切给了谁、花了多久)。详情见 §2「回退链面板:可编辑(0.6.0 起)」。

0.7.0 起面板上多一段只读的「额度」摘要;0.8.0 起多一段「自动排序」—— 一行模式开关(auto/manual,写 ordering.mode,免重启切换)+ 一行实际尝试顺序 (冷却中的条目也列在这里,带「冷却中」、哪一档打满与恢复时刻,例如「月度已耗尽,10-24 00:00 恢复」; 它们不会被尝试;只记了短闸门的那种写「疑似耗尽,{时刻} 后重试」)。0.11.0 起同一段末尾还多一行 冷却表落盘状态(就绪 / 未就绪 / 降级 + 原因),每条冷却行右侧一个「解除」按钮。 0.9.0 起这一段与链编辑器合成一段、按模式显示:开关挪到卡片最上面, 自动模式主视图是「生效顺序」、可编辑的链折进默认收起的「配置顺序」,手动模式反过来。这段是行为, 「额度」那段是观测,两者可能同时出现同一个来源 —— 不是故障,见 §2「排序与耗尽冷却(0.8.0 起)」。

                    ┌─ 用户选了 auto/auto ─┐
   请求 ──► AutoAdapter.stream()
                    │  (0.8.0 起先按额度排一次序、滤掉冷却期内的 provider;
                    │   `ordering.mode: manual` 时这一步跳过,完全按配置顺序)
                    │
                    ├─ 1) commandcode/deepseek/deepseek-v4.1-flash
                    │      ├─ 第 1 次失败(SERVER 502) ──► 白名单内 ⇒ 退避 500ms 后原地重试
                    │      ├─ 第 2 次失败 ──────────────► 退避 1s 后原地重试 ……(默认最多重试 5 次)
                    │      └─ 重试耗尽 ────────────────┐
                    ├─ 2) opencode-go/deepseek-v4.1-flash   ▼
                    │      └─ 成功 ⇒ 分片原样透传给上层(前面各次的 usage 等协议分片已被丢弃)
                    └─ 3) deepseek-official/deepseek-flash   ──► (没轮到)
                    │
                    └─ 全部失败 ⇒ 抛 AUTO_ROUTES_EXHAUSTED,消息里逐条列出「哪条路由、试了几次、怎么失败的」

1. 快速开始

本包是组合包(bundle):注册行(id: llm-auto)随包发布在包内 cordis.patch.yml 里, 宿主按 profile package.json 的 dsh.profile.bundles 加载 —— 不需要再往 profile 的 cordis.patch.yml 里贴任何 - insert: 行。

在 DSH profile 目录(~\.dsh\profiles\desktop\)下:

# ① 装进 profile:dshpm 会顺带把包名写进 dsh.profile.bundles
node <工作区>\dsh\dsh-plugin-manager\dshpm.mjs add file:./plugins/dsh-llm-auto --profile desktop
#    公开环境:profile 的 package.json 里加 "dsh-llm-auto": "github:liuyun847/dsh-llm-auto"
#    再 pnpm install(或 npm install) —— 同样只要包名在 dsh.profile.bundles 里

# ② 按本机 provider 改 routes:改包内 cordis.patch.yml 那一行(profile 层也可按 id 覆写)

# ③ 重启一次 DSH(本机桌面端:关掉再打开 DeepSeek Harness 窗口,见 §4「生效条件」)
  • 启停:Web 侧栏「插件」页 →「已安装」区里本卡的总开关(写 dsh.profile.bundles); 点开卡片后每一行还有行级开关(向 profile 的 cordis.patch.yml 写 disabled 覆盖 —— profile 层在包层之后应用,所以覆写优先)。
  • 卸载:node dshpm.mjs remove dsh-llm-auto --profile desktop。 ⚠ 本 profile 的 pnpm 带供应链策略,pnpm remove 会报 ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED;绕过办法是进 profile 目录直接跑 pnpm remove dsh-llm-auto --config.minimum-release-age=0 (dshpm 的 --fast 只对 add 有效 —— pnpm remove 不接受 --minimum-release-age 这类参数)。
  • ⚠ 0.4.0 新增的浏览器半侧必须重启一次 dsh 才会被收录:宿主 dsh-client-modules 把 "本包不是客户端包"这一否定结论按 specifier 缓存在 pkgMeta 里,只写不删 (其 lib/index.js:510 / :703),HMR 不会重新扫描 ⇒ 半侧新增后先重启,插件页才会 长出配置表单(见 §2)。

重启后:模型选择器出现 Auto 分组 → 选 auto → 发一条消息 → 打开 http://127.0.0.1:19387/api/llm-auto/routes 看它到底走了哪条(以及当前声明的上下文窗口)。 compactWindow 的图形入口在插件页:「已安装」→ 点开 dsh-llm-auto 那张卡片,表单就在 卡片描述与行列表之间(0.4.0 起,见 §2「插件页里的 compactWindow 表单」)。


2. 配置

配置有两个入口:

  • 插件页里的表单(0.4.0 起):「已安装」→ 点开 dsh-llm-auto 卡片 → 表单在描述与行之间。 这里能改的是 compactWindow(0.4.0 起)、routes(0.6.0 起,「回退链」面板)与 ordering.mode(0.8.0 起,「自动排序」那一行的开关);
  • 包内 cordis.patch.yml 的注册行(= 本包自带、随组合包加载的那一行):结构性配置 (现在只剩 retry)只能在这里改;profile 层要覆盖就按 id: llm-auto 写覆写行 (profile 层在包层之后应用)。routes 从 0.6.0 起、ordering 从 0.8.0 起都是 .volatile() ⇒ 面板里改完即时生效,不必重启,所以"改链/改模式"不必动文件。

标了「热改」的键同时是 schemastery 的 .volatile() 字段,经宿主的设置服务可改、改完即时生效 (效果边界见本节末尾)。

- insert:
    - id: llm-auto
      name: 'dsh-llm-auto'
      config:
        routes:
          - { provider: commandcode, model: deepseek/deepseek-v4.1-flash }
          - { provider: opencode-go, model: deepseek-v4.1-flash }
          - { provider: deepseek-official, model: deepseek-flash, keepThinking: true, breakToolLoop: true }
        # 压缩点(默认就是 500000,写出来只是显式化)
        compactWindow: 500000
        # 额度感知的路由行为(0.8.0 起):**包内这份 patch 里没有这一行** —— 不写就是默认的 auto。
        # 要改成 manual(或想在文件里留痕)时才放开下面两行;
        # 面板上那个「自动排序」开关写的就是它,改完即时生效(volatile)。
        # ordering:
        #   mode: auto        # auto(默认)| manual;其余值只 warn 回落 auto
        # retry 不写 = 官方默认(每路由 5 次重试);要关掉或改参数再放开:
        # retry:
        #   maxRetries: 5
        #   retryableCodes: [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT]
        #   backoff: { initialDelayMs: 500, maxDelayMs: 10000, jitterRatio: 0.1 }
键必填默认热改说明
routes✅—✅有序回退链,第一项即首选。每项 { provider, model, keepThinking?, breakToolLoop? };model 要写全(provider 声明的真实 id,如 deepseek-v4.1-flash、deepseek/deepseek-v4.1-flash,别省前缀)。0.6.0 起可在插件页的「回退链」面板里直接编辑(写入 profile 的 cordis.patch.yml,免重启);手写 YAML 照旧可用,profile 层按 id: llm-auto 覆写优先
compactWindow500000✅让自动压缩发生在这个 token 数附近(正整数)。对外宣称的窗口由它反算(见下)
nameAuto✅模型显示名(选择器里的分组名恒为 Auto)
contextWindow见下✅【旧键】直接声明对外宣称的上下文窗口(正整数)。与 compactWindow 同时给出时以 compactWindow 为准并被忽略(warn 会点名两者)
logLimit50✅路由日志环形缓冲条数(仅内存)
ordering.modeauto✅额度感知的路由行为(0.8.0 起):auto = 按订阅月度重置时刻自动排序 + 额度耗尽时冷却该来源;manual = 完全按 routes 顺序、只失败时切换(0.7.0 的行为)。两个值之外的坏值(标量 / 数组 / 普通对象形态)只 warn 并回落 auto;⚠ 但值里不要写 !!js、函数或类实例(RegExp/Date/Map,YAML 里未加引号的日期也会变成 Date)—— 这类"非普通值"在 schema 层就被拒,会让整行插件加载失败(见本节末尾的 ⚠)
retry.maxRetries5✗每路由重试上限(不含首次);0 = 关闭重试,恢复"一次败就切"
retry.retryableCodesEMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT✗可重试的错误码白名单;永久错误(不在表里的)一次败就切,不白烧请求
retry.backoff.initialDelayMs500✗指数退避起步(毫秒)
retry.backoff.maxDelayMs10000✗退避封顶;上游 Retry-After 超过它则不等了,直接切下一条
retry.backoff.jitterRatio0.1✗±10% 对称抖动

「热改」= 该键是 schemastery .volatile() 字段 ⇒ 由宿主设置服务写入后原地更新引用, 插件下次用到时就是新值(不需要重启、也不需要重新 apply);✗ 的键是结构性配置, 由 apply() 一次性消费,只能改 cordis.patch.yml(包内或 profile 覆写行)。

routes 从 0.6.0 起也是 .volatile(),但类型写成 z.union([z.array(条目), z.any()]) —— 这不是画蛇添足:写路径要求目标是 volatile 字段,而 schemastery 禁止 volatile 之下再套 voluntary, 同时本插件"坏形状只 warn"的约定又要保住。union 两个成员各管一头: 第一个给出"数组 + 字段类型"的形状(编辑器写进来的值走这条),第二个兜底放行其它形状 (继续交给 normalizeRoutes() 逐条判)。

ordering 从 0.8.0 起同样是 volatile,形状照抄 0.6.0 的教训写成 z.union([z.object({ mode: z.string() }), z.any()]).volatile(): 必须 volatile,因为面板上那个「自动排序」开关承诺"改完即时生效、不用重启"; 必须 union 兜底,因为 schema 是加载期校验 —— 直接写 z.object(...) 会让 ordering: nope 这种坏值把整行插件搞成加载失败,那正是 0.6.0 踩过的坑。 坏值只由 normalizeOrdering() warn 并回落 auto。

⚠ 这条不绝对:schema 是加载期校验,值里含 !!js 标签、函数或类实例(RegExp / Date / Map) 时仍会被拒 ⇒ 整行插件加载失败(实测:ordering: [{ mode: auto }] 这种"数组里套对象"过得了 union,但 { d: new Date(0) }、{ f: () => {} } 一律抛)。这条对 routes / retry / quota / ordering 一视同仁 —— 配置值只写标量、数组与普通对象。

retry 块的形状与默认值全部复用官方 resolveRetryPolicy(@deepseek-ai/dsh-llm, 即自带 dsh-llm-retry 用的那个):选 auto 与选普通模型的重试语义一致。只支持 mode: 'normal' (always = 无上限重试,单请求可能无上限计费,挂载时 warn 并回落默认);坏值只 warn 不阻止注册。

compactWindow:压缩点 → 对外宣称的窗口

DSH 的自动压缩引擎 @deepseek-ai/dsh-compaction-basic 不看真实上游窗口,只用"该请求声明的 窗口"算阈值(该包 lib/index.js:124-147):

threshold = floor(min( cw × 0.8, cw − reserved − 65536 ))      # 0.8 / 65536 是引擎默认值
reserved  = 请求头 maxTokens ?? 适配器声明的 defaultMaxTokens ?? 0

本插件既不声明 defaultMaxTokens、调用方也不给 maxTokens ⇒ reserved = 0, 于是 threshold = floor(min(cw × 0.8, cw − 65536))。反过来解就能让压缩点落在你要的位置:

cw = max(ceil(T × 1.25), T + 65536)      # T = compactWindow;T = 500000 ⇒ cw = 625000

验算(T = 500000):0.8 × 625000 = 500000;625000 − 65536 = 559464 > 500000(min 取前者); 保留尾部 floor(625000 × 0.16) = 100000 < 500000 ✅。挂载日志会把这一步连同假设打出来:

auto: 压缩点 500000 → 声明窗口 625000(假设 compaction-basic thresholdRatio 0.8 / headroomTokens 65536)

行为变化(升级到 0.3.0 就会看到):本机 auto 链的窗口从"第一条可解析路由的 884000" 变成"按默认压缩点反算的 625000",压缩触发点相应从 707200 提前到 500000 —— 后者正好等于本机全链最紧那一跳的可用输入预算(opencode-go 884000 − 384000 = 500000)。 这是有意的:声明窗口在这里只是"给压缩引擎定位用的刻度",不是对上游窗口的承诺。

优先级(纯函数 planDeclaredWindow(),实现在 lib/compact.js):

compactWindow(正整数)  →  反算声明窗口        默认 500000 ⇒ 625000
contextWindow(正整数)  →  直接声明(旧键)     不写 compactWindow 时才轮到它
逐跳解析               →  第一条可解析路由的窗口
兜底 65536

⚠ 宿主里 schema 总会给 compactWindow 补上默认值 500000 ⇒ 默认口径就是反算; contextWindow 只在"compactWindow 完全没出现"时才生效(直接调 apply() 的集成方)。 两者同时给出时会打一条 warn 说明用了哪个、忽略了哪个。 ⚠ compactWindow 低于最小可用值 12484(见 minimumUsableCompactWindow())会另打一条 warn:此时引擎的"保留尾部 < 阈值"校验过不去 (floor(0.16 × (T + 65536)) ≥ T),@deepseek-ai/dsh-compaction-basic 每轮抛 TargetPressureConfigError;该错误被引擎捕获(agent/pre-step 那层):第一次 warn、之后对同一目标 静默跳过压缩、回合照常继续 —— 净效果是压缩从不发生(不会报错,但上下文会一直涨)。 这个下界是算出来的,不是拍脑袋的常量(见 minimumUsableCompactWindow())。 ⚠ compactWindow/contextWindow 非法(非正整数)只 warn 并回落默认值,不会让宿主起不来。 ⚠ contextWindow ≤ 65536 时引擎的 pressure budget ≤ 0,连阈值都算不出来(同样每轮抛错)—— 这也是引入 compactWindow 的原因之一;反算路径下 T ≥ 12484 一定是安全的。 ⚠ 该反算依赖上面三个引擎默认常量。引擎换版本、或你把 compaction-basic 的 thresholdRatio/headroomTokens 改成别的值,映射就不再精确 —— 挂载日志里那行假设就是为 这种时候留的证据(可用 /api/llm-auto/routes 的 compactWindow/declaredContextWindow 复核)。

插件页里的 compactWindow 表单(0.4.0 起)

0.4.0 起本包自带浏览器半侧(lib/client.js),注册进插件页为组合包预留的 plugins.bundle.config slot(键 = 本包包名 dsh-llm-auto)⇒ 插件页 →「已安装」→ 点开 dsh-llm-auto 卡片, 官方 SettingsForm 的表单就渲染在卡片描述与行列表之间,不必再点进任何二级页。

位置沿革(两版同为 2026-09-25):初版注册的是 plugins.row.config(键 <包名>#<行 id>), 表单只能从「行 llm-auto →「配置」」的二级页进入 —— 要钻四层。现按用户要求提到卡片上, 行上的「配置」控件随之消失(本插件不再占用 plugins.row.config)。 两个槽位的契约都在 dsh-client-ui-plugin-manager 的 lib/types/client/slot-contract.d.ts; 该页只在 ledger 收录了这个键时才渲染这一段(其 client.js:2879 的 ledger.bundles.has(pkg.name)), 而 ledger 直接读槽位注册的 key(其 client.js:48 的 keysOf)⇒ 键必须恰好是包名。

  • 只有一个字段「用于压缩的上下文窗口」(= compactWindow),与官方四个配置页 (ui-settings-shell / agent-loop / subagent / web-search)同构:用 SettingsFormModel / SettingsForm / SettingsValueField 暂存草稿,点保存才写入,自带「已覆盖」标记与「恢复默认」;
  • 写入经宿主 settings 服务。0.4.0–0.5.x 时这张表单只落 compactWindow 一个键;0.6.0 起 routes 也能写(见下节「回退链面板」),而且它现在就是 .volatile() 字段(union 兜底坏形状) ⇒ 改完不用重启、下一次请求就是新链;retry 仍是结构性配置,只能改包内 cordis.patch.yml;
  • 命名空间就是 loader 行 id llm-auto(dsh-settings 按 entry.options.id 投影)—— 与启停开关、覆写行的寻址键一致;
  • 前提:这个半侧是 0.4.0 新增的,首次启用必须重启一次 dsh 才会被收录(依据见 §1); 此后改这个文件本身也要重启(或至少刷新页面)才看得到新表单,见 §4 第 2 条;
  • 备选改法不变:手编 profile 的 cordis.patch.yml 追加按 id 覆写的顶层行 (- id: llm-auto + name: 'dsh-llm-auto' + config: { routes: [...], compactWindow: N }, profile 层在包层之后应用 ⇒ 遮蔽包内那行 insert),或直接改包内 cordis.patch.yml。 routes 从 0.6.0 起有表单(就在这张卡片的「回退链」面板里),上面这两条只是等价的备选改法; retry 这类结构性配置仍然没有表单,只能走这两条。

回退链面板:可编辑 + 按模式显示(0.6.0 起可编辑;0.9.0 起与「排序」合段)

同一张卡片上、compactWindow 表单下方多出一块「回退链」面板。0.9.0 起它按排序模式显示, 模式开关画在卡片最上面(「回退链」标题之前):

auto(默认)—— 主视图是生效顺序,可编辑的配置链折进「配置顺序」(默认收起):

排序方式                                            [当前:自动排序。切到手动]
──────────────────────────────────────────────────────────────────────
回退链                                            [放弃改动] [刷新] [保存]
自动排序生效中:按订阅的重置时刻排序、额度耗尽的来源暂时跳过;下面是本次实际会尝试的顺序。…

生效顺序
冷却中  opencode-go/deepseek-v4.1-flash   月度已耗尽,10-24 08:00 恢复
commandcode/deepseek/deepseek-v4.1-flash
deepseek-official/deepseek-flash   额度查不到,按配置顺序
⌄ 配置顺序(3 条)          ← 点开就是下面「手动」那张里可拖拽的链(改动照旧要点「保存」)
每路由最多重试 5 次,退避 500ms→10000ms
额度 ...............................................(只读;鼠标悬停给一句口径说明)
Command Code   余额 $45.23 · 5h $2.09/$14.00 · 周 $24.77/$35.00   individual-goat
OpenCode Go    5h 0% · 周 0% · 月 已耗尽                          10-24 08:00 重置

最近请求 ............................................ 共 13 条记录,容量 50
● 10:05:28  1m4s  成功
    1. commandcode/deepseek/deepseek-v4.1-flash   ✓ 成功

manual —— 主视图就是那条可编辑的链,不画生效顺序(那种模式本来就不排序):

排序方式                                            [当前:手动。切回自动]
──────────────────────────────────────────────────────────────────────
回退链                                            [放弃改动] [刷新] [保存]
按下面的顺序依次尝试:第 1 项即首选。某条重试耗尽、或错误码不允许重试时,才静默切下一条。
拖动行首的手柄改顺序,改完点「保存」写入配置并即时生效(不用重启)。

⠿ commandcode / DeepSeek V4.1 Flash              ⌄   选项 ⌄   ✕
⠿ opencode-go / DeepSeek V4.1 Flash              ⌄   选项 ⌄   ✕
⠿ deepseek-official / DeepSeek-V41-Flash  keepThinking · breakToolLoop  ⌄   选项 ⌄   ✕
     └─「选项」展开(这一行开着两个开关时):
          保留思考块            [开]
          工具循环收尾          [开]
[+ 添加模型]   [恢复包内默认链]
每路由最多重试 5 次,退避 500ms→10000ms
额度 … / 最近请求 …(与上面那张相同)
  • 配置链 0.6.0 起就地可编辑 —— 行首 ⠿ 拖拽排序、点行内模型名开选择器换一条、 「选项」展开两个逐条开关、行尾 ✕ 删除(最后一条不允许删)。改动先落在本地草稿, 只有点「保存」才写配置;「放弃改动」回到端点值。⚠ 0.9.0 起自动模式下这块默认收起 (折进「配置顺序」那一行,点开照旧可改)—— 链本身没变,只是不再跟「生效顺序」抢主视图。
  • 选择器长得像主页的模型菜单:顶部搜索(大小写不敏感的有序子序列,与官方 rankByName 同判据)、 按 provider 分组、行高 34px、当前项打勾、材质直接用官方 MenuSurface + MenuGroup (滚动时分组标题吸附)。数据来自新增的 GET /api/llm-auto/catalog。
  • 不在目录里的条目照样显示:目录读不到该模型时那一行标一个「不在当前目录」的标记, 但仍可拖、可删、可保存 —— 目录失败不该把链弄成不可编辑的。
  • 保存写到哪里:configForms → 宿主 settings.mutate → dsh-config-editor,落进 profile 的 cordis.patch.yml 里那一行 - id: llm-auto 的 config.routes(yaml 文档式写入 ⇒ 该文件里那些注释与 !!js 标签原样保留)。profile 层在包层之后应用 ⇒ 这一行会遮蔽包内 那段 insert;面板上的「恢复包内默认链」= unset 掉这个覆盖 —— 该按钮只在 profile 层确实 覆盖了 routes 时才出现(判据是端点响应的 user.routes,见 §5),没有覆盖时它压根不画。
  • 为什么不用重启:routes 是 cosmokit 的 volatile 引用,加载器重建配置时原地更新同一个引用, 而适配器只在每次请求开始时现取一次链(不是构造时抄一份)⇒ 改完链的下一次请求就是新链, 正在跑的那一次不受影响。
  • 下半是运行时的真实回退:端点的 calls 字段,按一次 auto 请求分组(adapter 给 每条日志写上本次 stream() 的序号 call,宿主侧 lib/calls.js 的 groupCalls() 还原)。 同一条路由的多次尝试合并成一行(试了 N 次),错误码取该路由的最终失败, → 切换至 … 指向下一条候选。最近结束的请求排最上面。这一段始终只读。
  • 刷新时机:组件挂载(进这张卡片)时读一次链与目录,点「刷新」再读一次;不轮询。 链在别处被改过(另一个标签页、手改 YAML)时,下一次刷新会重种草稿并说明一句; 而你在草稿里的改动如果基于过期版本保存,宿主会拒绝并提示重新读取(乐观并发控制,带 revision)。
  • 失败有提示而不是空白:端点非 200 / 网络错误 ⇒ 面板尾部一行"读取失败:原因" (插件没加载、端点不可达时正是这个);某几个 provider 的目录读不到 ⇒ 选择器里列出它们的 id。
  • 路由日志是进程内内存,重启即清空 ⇒ 面板读的就是它,重启后"最近请求"从空开始。
  • 模式开关与链是两件事:开关写的是 ordering.mode(0.9.0 起画在卡片最上面,见下节), 链仍是你排的顺序(chain 一字不变);它只决定"这一次实际会按什么顺序试、跳过谁" (ordering.effective)—— 自动模式下后者就是主视图「生效顺序」。
  • 备选仍是 curl:curl http://127.0.0.1:19387/api/llm-auto/routes(完整字段见 §5)。
  • 面板样式只用主题 --dsw-* token(浅色/深色两套随外壳),状态用官方 StateDot / Tag; 文案走本插件的字典命名空间 llmAutoSettings(中英双语,与 compactWindow 表单同一份)。

排序与耗尽冷却(0.8.0 起;0.9.0 起与链编辑器合段)

行为是 0.8.0 加的,界面在 0.9.0 收敛成一段:模式开关(「排序方式」+ 那个按钮)画在卡片 最上面、「回退链」标题之前;auto 时主视图是下面这块「生效顺序」、可编辑的链折进「配置顺序」, manual 时只给可编辑的链、不画「生效顺序」(两段不再各画一次"当前是什么模式"):

排序方式                                          [当前:自动排序。切到手动]
生效顺序
冷却中  OpenCode Go/deepseek-v4.1-flash    月度已耗尽,10-24 00:00 恢复  [解除]
Command Code/deepseek/deepseek-v4.1-flash
deepseek-official/deepseek-flash   额度查不到,按配置顺序
冷却表已落盘(跨重启存活)
⌄ 配置顺序(3 条)
  • 一行模式开关:点它就在 auto / manual 之间切(写 ordering.mode,即时生效、不用重启); 悬停给一句口径。提示语随模式换 —— manual 下"按下面的顺序依次尝试…"那句才成立, auto 下换成"自动排序生效中…"(旧那句在自动模式里是错的:它会排序、也会跳过)。
  • 实际顺序,一行一个来源(auto 时):这一次请求会依次尝试的路线,来自端点新增的只读字段 ordering.effective。冷却中的条目也列在这里(排在前面,带「冷却中」标签、哪一档打满 与解除时刻,如「月度已耗尽,10-24 00:00 恢复」),它们不会被尝试 —— 真正会走的是去掉这些 条目之后的顺序;查不到额度的标「额度查不到,按配置顺序」。
  • 它和「额度」段是两件事:这一段是行为(插件真的会跳过谁),「额度」那段是观测 (上游现在怎么说)。同一个来源可能一段写「冷却中」、另一段写「已耗尽」—— 两者都对。

规则(与设计档 §3 逐条对应):

项规则
排序键订阅来源取月度重置时刻升序(Command Code = 套餐 currentPeriodEnd;OpenCode Go = 月度档 resetsAt)。不用 5h/周窗口的重置点 —— 那是速率闸门,按它排会每几小时抖一次
三桶① 已知重置时刻的订阅(升序)→ ② 重置时刻未知的(按配置顺序)→ ③ 按量兜底(永不过期,按配置顺序,永远最后)。桶内保持配置顺序,且不改 routes 配置本身
粒度排序按条目;冷却是按 provider(额度是账号级的)⇒ 同一家所有条目一起冷
触发反应式:某条在会走回退的失败路径上返回 QUOTA,或报文命中窗口额度耗尽判据(0.9.1 起放宽:① insufficient quota/balance/credits ② (quota|usage limit) exceeded/exhausted/reached ③ 窗口词 + 额度词 + 耗尽/重置词三者同时成立,词序不限 —— 覆盖 Command Code 的 You've reached your weekly usage limit … Your limit resets at …)。AUTH / 其它码不触发;瞬时速率限制(报文里没有窗口词,如 429 rate limit exceeded)也不触发。0.11.0 起第一次记账是同步的:失败当下立刻冷 5 分钟(不等补查),因为补查要一次 HTTPS 往返,这期间已经排好序的并发请求本来会照旧白撞同一家(2026-10-12 实测:两条请求差 1 秒、连撞两次)
判定补查该 provider 的额度接口确认,不靠错误文本猜。三档都看:OpenCode Go 的 5 小时 / 周 / 月度,Command Code 的 5 小时窗 / 周窗 / 月度余额(判据:credits.belowThreshold 为真,或 monthly 与 total 两个键都在且都 ≤ 0 —— 字段缺失=不知道,绝不当成 0)。任一档耗尽 ⇒ 冷却到被耗尽那几档里最晚的重置时刻,面板写明是哪一档(「月度已耗尽」/「余额已耗尽」/「周已耗尽」…)
恢复不用定时器:每次请求重算时 now >= until 即自动解除
兜底过滤后一条都不剩(理论上到不了,按量不参与冷却)⇒ 忽略冷却,避免假故障「链已用尽」
持久化落盘(0.11.0):宿主领域存储,域 llm_auto_cooldown / 表 cooldowns,介质 <DSH_HOME>\storages\llm_auto_cooldown.json(键 = provider)。打开时水合 + 顺手删掉过期条目;每次 mark / clear 都写穿(串行写链,失败只 warn)。服务缺席 / 打开失败 / 记录坏 ⇒ 降级为纯内存并 warn,行为退回 0.10.0。0.9.1 起「窗口额度耗尽」的失败不原地重试(即使错误码是 RATE_LIMIT),代价只是一次失败往返
短闸门被 QUOTA 拒的当下先记 now + 5 分钟(来源 gate、不带窗口 id)⇒ 面板写「疑似耗尽,{时刻} 后重试」;补查确认后换成"被耗尽档位里最晚的重置时刻"(来源 confirm,面板写「月度已耗尽,… 恢复」)。闸门到点自然解除,过期条目连介质一起不留
提前恢复两条出口:① 自愈 —— 周期补查真的查到该家、且没有任何一档耗尽 ⇒ 自动解冻 + 删介质记录;② 面板冷却行右侧的**「解除」按钮(写设置服务的 ordering.unfreeze 一次性指令 <provider>@<epochMs>,值一变就解冻)。0.11.1 起:消费掉的值会写进冷却域的 global 槽 ⇒ 同一个值在宿主重启 / 插件重组合后不会再触发一次**(否则它会清掉刚水合起来的冷却、删掉介质记录,第一条请求又白撞那家);而指令里的时刻早于本进程启动时刻时按陈旧残留忽略 —— 只记为已消费,冷却表与介质记录一律不动。切 manual 也能立刻绕过(那种模式不跳过任何一跳)
查询失败fail-open:断网 / 超时 / 形状不认 / 401 ⇒ 不冷却,只记一条 warn(宁可下次白撞,也不误伤一条其实能用的订阅)。这一轮既然没查到,路由侧的额度缓存只记 10 分钟查询闸门后重试(见下面的 §4 说明;它管"什么时候再问",与上面那条 5 分钟冷却闸门不是一回事)

⚠ 路由侧的额度缓存不与面板那份共用(设计档 §4):面板的 quota 仍是 60 秒 TTL、 只在读端点时刷(只读观测,一行不动);路由侧另有一份低频缓存,有效期 = 该来源已知重置时刻里 最早的那个,另挂 6 小时上界兜底。"查过"的判据是这一轮真的拿到 status: ok —— 查失败(断网/超时/形状不认/401)只记 10 分钟查询闸门后重试,而不是把失败也当成"查到了"占满 6 小时(⚠ 它与 §2 那条 5 分钟冷却闸门是两件事:一个管"什么时候再问额度",一个管"这家先别试")。 两者复用同一套查询与解析(lib/quota.js),只换"什么时候查"的策略。 ⚠ 路由侧只在三个时机去问:① 进程内第一次有请求需要排序 ② 发生额度耗尽报错(码是 QUOTA, 或报文自带窗口语义 —— 判据见 §2 触发行;只查这一家、 绕缓存)③ 缓存过期。①③ 同挂在每次请求的排序路径上(适配器每请求判一次,过期才查)⇒ headless(没人读 /routes)也照样会补查;读端点那一次只是幂等地顺带推一下。 三处都是 fire-and-forget —— 查询不阻塞任何一次请求或端点响应;而且只补查真的过期的那几家 (链上查不到的来源永远"该查",不该把别的家的闸门顶穿)。没人用 auto 时零外部流量 (与 0.7.0 的额度观测同一条纪律)。 ⚠ manual 下冷却表照旧记着、也照旧落盘(切回 auto 立刻生效),但端点的 ordering.cooldown 恒为空数组 —— 那种模式不跳过任何一跳,把表画出来只会让人以为"它被跳过了"; 面板上那一行「解除」与落盘状态行同样只在自动模式下画。 ⚠ profile 遮蔽(0.6.0 的同一个坑):面板上保存过一次链之后,整块 config 被写进 profile 的 - id: llm-auto 行 ⇒ 之后改包内 cordis.patch.yml 的 ordering 不再生效,要改就改 profile 那一行 (或直接在面板上切)。

额度(只读,0.7.0 起)

同一张卡片上、「最近请求」上方还有一段只读的额度摘要:一家一行。它不是路由策略的一部分 —— 插件不会因此跳过或重排任何一跳(顺序完全由链决定),查不到也只体现在这一段里。

  • Command Code:余额 $59.92 · 5h $0.62/$14.00 · 周 $10.08/$35.00,右侧是套餐 id(如 individual-goat)。 余额 = monthly + purchased + free(充值额度可结转),两档窗口的 used/cap 同单位(美元等值)。
  • OpenCode Go:5h 0% · 周 0% · 月 已耗尽,右侧是该档的重置时间(本地时区,上游给的是 ISO/UTC 串)。 ⚠ 它的接口给的是百分比窗口(没有金额口径);status: "rate-limited" 表示该档已经耗尽 (服务端会把 percent 硬编码成 100)⇒ 界面写「已耗尽」,不写 100% 更不写 0%。
  • 查不到就说查不到:凭据没配 / 被 Cloudflare 按浏览器签名拦(403 + error_code 1010)/ 超时 / 网络不通 ⇒ 那一行写「不可查:原因」,一个数字都不给。用 0 顶替会把"已耗尽"说成"没用量"。
  • 按需刷新:只在 /api/llm-auto/routes 被命中且缓存过期(默认 60s)时才查一次,不轮询; 点面板的「刷新」即重读。第一次读到"查询中…"时面板会自动补拉一次(只补一次)。
  • 配置(可选,改包内 cordis.patch.yml;它不是 volatile 字段,面板里没有表单):
quota:
  enabled: true            # 只读观测,缺省开;false ⇒ 整段不显示
  ttlMs: 60000             # 缓存窗口(从"上次尝试"起算,成败都算)
  timeoutMs: 8000          # 单个 HTTP 请求上限(实测 CC 冷连接可达 2.3s,4s 会误报超时)
  # userAgent: '...'       # 查询用的 UA(默认浏览器 UA)
  commandcode: { apiKeyEnv: CMD_API_KEY }
  opencodeGo:  { apiKeyEnv: OPENCODE_API_KEY }

⚠ 凭据引用名默认 CMD_API_KEY / OPENCODE_API_KEY,走宿主凭据服务(与模型页写的是同一份); 拿不到凭据服务时才退环境变量。⚠ 面板上保存过一次链之后,quota 会被一并写进 profile 的 覆盖行(profile 层遮蔽包层)⇒ 之后要改 quota,改 profile 那一行。

导出 Config(= 成为可编辑配置条目),以及它的代价与效果边界

0.3.0 起本插件导出一个 schemastery Config(lib/index.js)。效果是配置成为宿主的可编辑条目: dsh-settings 的 SettingsForms.describe() 会为带 schema 的活动条目生成描述符 (lib/index.js:413-452;schema 取 entry.fiber.runtime.Config,同文件 :538-541), settings.describe() / settings.mutate() 这条远端通道因此能看到并写入它的字段。 name / compactWindow / contextWindow / logLimit 标了 .volatile(),插件不缓存这些值 (每次用到时重新读引用),所以值一改就生效、不需要重启;0.6.0 起 routes 也是 .volatile() (适配器每次请求现取一次链,面板里改完即时生效);0.8.0 起 ordering 同样是 .volatile() (面板那个「自动排序」开关写的就是它,每次排序现读一次模式)。仍是结构性配置的是 retry 与 0.7.0 的 quota(后者是只读观测,刻意不标 volatile),这两者仍走包内 cordis.patch.yml。

⚠ 效果边界(如实说明):导出 Config 这一步本身不生成任何表单 —— 随包发布的 Web 客户端 没有"按 schema 自动生成表单"的页面(@deepseek-ai/dsh-settings 的 README 自己写着 Each form reports autoGenerate … for clients that build pages from the schema; no shipped client does so yet),插件页(Plugins)只渲染经 slot 注册的表单页(dsh-client-ui-plugin-manager 的 plugins.item / plugins.bundle.config / plugins.row.config)。0.4.0 起本包自带浏览器半侧 (lib/client.js)注册进 plugins.bundle.config,才把 compactWindow 那一个字段变成插件页里可点的 表单(见上一节);宿主侧 schema 的作用是让 settings.describe() / settings.mutate() 这条远端 通道能看到并写入这些字段。 本节的结论来自源码阅读 + 单测(test/compact.test.mjs 里"真 schema 校验 ⇒ 解包 ⇒ 反算 625000")。

代价与取舍(原作者当初"有意不导出 Config"的理由依然成立,只是被权衡掉了):

  • schema 是加载期校验,校验失败 = 整行插件加载失败(不再是本插件那条"打 error 但不注册" 的软失败)。所以 routes / retry 用 z.any():形状校验继续留在 normalizeRoutes() / normalizeRetry() 里,坏值依旧只 warn/error;数值字段用 z.number() 但不加 .min()/.step(), 范围与整数性仍由插件自己判并 warn 回落。会硬失败的是两类:类型错误(例如把字符串写进 compactWindow 这种数字字段)与非普通值(!!js 标签、函数、类实例;YAML 里未加引号的日期 会被解析成 Date)—— 所以配置值只写标量、数组与普通对象。
  • 表单只覆盖标了 .volatile() 的字段(volatileForm(),dsh-settings lib/index.js:122-131; 一个 volatile 字段都没有时整条目被跳过),所以 routes / retry 不在可写字段里 —— 这是有意的: 它们由 apply() 一次性消费,标成 volatile 等于承诺一个做不到的"改了即时生效"。
  • .volatile() 字段在插件里拿到的是 cosmokit 的 Volatile 引用(不是值本身), 读之前必须 unwrapVolatile()(lib/compact.js)。

⚠ 宿主侧这条能力("配置成为可编辑条目")的依据是上面引用的源码位置 + 单测里 "真 schema 校验 → 解包 → 反算 625000"这条用例(见 §7);插件页里的表单入口见上面那节 (它走的是同一套 schema:ctx.configForms 只投影标了 .volatile() 的字段)。 重启后请先看 /api/llm-auto/routes 的 compactWindow/declaredContextWindow 是否符合预期。

contextWindow 不写且 compactWindow 也没给时:逐个试路由,取第一条能给出正整数窗口的那条的窗口; 全都拿不到就用保守值 65536(宁可让压缩早触发,也不要谎报一个大窗口导致请求必撞上游上限)。 解析结果缓存 30 秒,目录被反复重建时不会反复去问上游适配器。

⚠ provider 不要写 auto(自递归):挂载时会 warn 并跳过该条。 ⚠ routes 为空/缺失/解析后一条不剩:打一条 error 并拒绝注册(不抛错,宿主照常启动)。 ⚠ 写成 auto 的整条目的链要避开两个坑:① provider: auto 是自递归(挂