liuyun847/dsh-llm-auto ↗★ 0

dsh-llm-auto

注册自动尝试多条模型路由的备用路由服务 适合需要模型请求在输出前失败时自动重试、按顺序切换备用路由的用户。

包名
dsh-llm-auto
兼容性
待验证
Harness 依赖范围
^0.1.6-alpha.2
Cordis 依赖范围
^4.0.2
版本
0.4.0
许可证
MIT
最近更新
2026年9月26日

安装

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:liuyun847/dsh-llm-auto

2. 配置

配置有两个入口:

  • 插件页里的表单(0.4.0 起):「已安装」→ 点开 dsh-llm-auto 卡片 → 表单在描述与行之间, 只暴露 compactWindow 一个字段,保存才写入(见下节「插件页里的 compactWindow 表单」);
  • 包内 cordis.patch.yml 的注册行(= 本包自带、随组合包加载的那一行):结构性配置 routes / retry 只能在这里改;profile 层要覆盖就按 id: llm-auto 写覆写行(profile 层在包层之后应用)。

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

- insert:
    - id: llm-auto
      name: 'dsh-llm-auto'
      config:
        routes:
          - { provider: opencode-go, model: deepseek-v4.1-flash }
          - { provider: commandcode, model: deepseek/deepseek-v4.1-flash }
          - { provider: deepseek-official, model: deepseek-flash }
        # 压缩点(默认就是 500000,写出来只是显式化)
        compactWindow: 500000
        # retry 不写 = 官方默认(每路由 5 次重试);要关掉或改参数再放开:
        # retry:
        #   maxRetries: 5
        #   retryableCodes: [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT]
        #   backoff: { initialDelayMs: 500, maxDelayMs: 10000, jitterRatio: 0.1 }
键必填默认热改说明
routes✅—✗有序回退链,第一项即首选。每项 { provider, model };model 要写全(settings.yaml 里的真实 id,如 deepseek-v4.1-flash、deepseek/deepseek-v4.1-flash,别省前缀)。改它要改包内 cordis.patch.yml(包层 patch:不需要重启,但改完要有一次触发才被读入 —— 见 §4 第 2 条;插件会重新 apply)
compactWindow500000✅让自动压缩发生在这个 token 数附近(正整数)。对外宣称的窗口由它反算(见下)
nameAuto✅模型显示名(选择器里的分组名恒为 Auto)
contextWindow见下✅【旧键】直接声明对外宣称的上下文窗口(正整数)。与 compactWindow 同时给出时以 compactWindow 为准并被忽略(warn 会点名两者)
logLimit50✅路由日志环形缓冲条数(仅内存)
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。

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 ⚠ 宿主里 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(键 #), 表单只能从「行 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 服务,只落 compactWindow 这一个键,routes 原样不动 (routes / retry 不是 .volatile() 字段,写了也不会原地生效 ⇒ 仍改包内 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 这类结构性配置没有表单,只能走这两条。

导出 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(),插件不缓存这些值 (每次用到时重新读引用),所以值一改就生效、不需要重启;routes / retry 是结构性配置, 仍走包内 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 这种数字字段)。
  • 表单只覆盖标了 .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 是自递归(挂载时 warn 并跳过该条); ② 别写本机不可解析的通道或模型 id,否则那一跳每次都以 NO_ADAPTER / UNKNOWN_MODEL / MISSING_CREDENTIAL 白撞一次(点开 /api/llm-auto/routes 能看到真实 code)。 ⚠ 本文档里的链是"当时的实例",权威定义在包内 cordis.patch.yml(profile 层按 id: llm-auto 的覆写行优先):那份改了而本文没同步时, 以文件为准(curl http://127.0.0.1:3080/api/llm-auto/routes 的 chain 字段永远反映当前生效值)。


导出 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(),插件不缓存这些值 (每次用到时重新读引用),所以值一改就生效、不需要重启;routes / retry 是结构性配置, 仍走包内 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 这种数字字段)。
  • 表单只覆盖标了 .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 是自递归(挂载时 warn 并跳过该条); ② 别写本机不可解析的通道或模型 id,否则那一跳每次都以 NO_ADAPTER / UNKNOWN_MODEL / MISSING_CREDENTIAL 白撞一次(点开 /api/llm-auto/routes 能看到真实 code)。 ⚠ 本文档里的链是"当时的实例",权威定义在包内 cordis.patch.yml(profile 层按 id: llm-auto 的覆写行优先):那份改了而本文没同步时, 以文件为准(curl http://127.0.0.1:3080/api/llm-auto/routes 的 chain 字段永远反映当前生效值)。