dsh-fallback-router 0.0.0-stage → 0.1.0

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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +162 -2
  3. package/README.zh-CN.md +170 -0
  4. package/bundle/cordis.patch.yml +19 -0
  5. package/dist/authorized-route.d.ts +68 -0
  6. package/dist/automatch.d.ts +98 -0
  7. package/dist/chains.d.ts +186 -0
  8. package/dist/client/ConversationFallbackSwitch.d.ts +91 -0
  9. package/dist/client/FallbacksCard.d.ts +36 -0
  10. package/dist/client/GeneralFallbacksRow.d.ts +19 -0
  11. package/dist/client/ModelsPanel.d.ts +64 -0
  12. package/dist/client/SubagentRoleBadge.d.ts +53 -0
  13. package/dist/client/fallbacks-store.d.ts +631 -0
  14. package/dist/client/index.d.ts +64 -0
  15. package/dist/client/index.js +3420 -0
  16. package/dist/client/locales.d.ts +454 -0
  17. package/dist/client/switch-guard.d.ts +17 -0
  18. package/dist/client/use-snapshot.d.ts +11 -0
  19. package/dist/commands.d.ts +380 -0
  20. package/dist/config.d.ts +239 -0
  21. package/dist/cooldown.d.ts +57 -0
  22. package/dist/events.d.ts +52 -0
  23. package/dist/gateway.d.ts +359 -0
  24. package/dist/index.d.ts +195 -0
  25. package/dist/index.js +4587 -0
  26. package/dist/models.d.ts +306 -0
  27. package/dist/override.d.ts +39 -0
  28. package/dist/presets.d.ts +20 -0
  29. package/dist/recovery.d.ts +92 -0
  30. package/dist/role-notice.d.ts +184 -0
  31. package/dist/role-persona.d.ts +71 -0
  32. package/dist/role-projection-key.d.ts +41 -0
  33. package/dist/role-projection.d.ts +152 -0
  34. package/dist/role-resolution.d.ts +105 -0
  35. package/dist/roles.d.ts +90 -0
  36. package/dist/route-allowlist.d.ts +43 -0
  37. package/dist/schema.d.ts +46 -0
  38. package/dist/seeds.d.ts +237 -0
  39. package/dist/selectors.d.ts +38 -0
  40. package/dist/state.d.ts +205 -0
  41. package/dist/subagent-policy.d.ts +123 -0
  42. package/dist/subagents-seam.d.ts +305 -0
  43. package/dist/time-slots.d.ts +147 -0
  44. package/dist/tui-settings.d.ts +145 -0
  45. package/dist/tui.d.ts +63 -0
  46. package/dist/virtual-adapter.d.ts +259 -0
  47. package/icon.svg +16 -0
  48. package/locale/en.json +6 -0
  49. package/locale/zh.json +6 -0
  50. package/package.json +138 -6
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-external
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,163 @@
1
- # Temporary Holding Version
1
+ # dsh-fallback-router
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [English](README.md) | [中文](README.zh-CN.md)
4
+
5
+ Model routing and failure fallback for dsh (DeepSeek Harness). It merges `dsh-llm-fallbacks` and `dsh-llm-response-guard`: both "re-run this step on another route" and "this step's output is broken" are driven from one **model-configuration** surface.
6
+
7
+ The difference in one line: you used to maintain four things separately (fallback chain, time slots, provider retry, residue detection); now you maintain one list of **model profiles** and everything else is compiled from it.
8
+
9
+ ## 0. Install
10
+
11
+ ```sh
12
+ dsh plugin --profile desktop add github:hongxin-y/dsh-fallback-router
13
+ ```
14
+
15
+ Pin a commit (`#<sha>`) if you want the code that runs to stop changing. pnpm ≥ 10 does not run a git dependency's build script by default, so the first `add` may stop with `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`; run `pnpm approve-builds` inside the profile directory (`$DSH_HOME/profiles/desktop`), or add the package to that profile's `pnpm-workspace.yaml` allow list (`allowBuilds`, or `onlyBuiltDependencies` on older pnpm), then run the `add` again. Details, verification and uninstall → [docs/install.md](docs/install.md).
16
+
17
+ The npm package name is `dsh-fallback-router`, but it is **not published to the registry yet** (the release workflow's npm step is gated off) — install from the GitHub source above.
18
+
19
+ ## 1. The core idea: model profiles
20
+
21
+ One profile = one real route + its own time windows, fallback policy, and failure rules.
22
+
23
+ ```yaml
24
+ fallbacks: # the settings namespace stays `fallbacks` for compatibility
25
+ models:
26
+ - id: flex-flash
27
+ name: DeepInfra Flex Flash
28
+ kind: route # route (single model) | combo (free combination)
29
+ provider: deepinfra-flex
30
+ model: deepseek-ai/DeepSeek-V4.1-Flash-1017
31
+ windows: [] # empty = all day
32
+ fallback: official # official | loop | none
33
+ officialModel: deepseek-official/deepseek-flash
34
+ reasoningEffort: '' # empty = follow the system default
35
+ extraRoutes: # tried in order after the head, before the official tail
36
+ - route: ruinique/gpt-6.1-sol
37
+ reasoningEffort: high # empty = follow the system default
38
+ rules:
39
+ codes: [AUTH, QUOTA, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT, PI_AI_ERROR]
40
+ regex: ['Upstream rejected the request']
41
+ structural: true
42
+ markers: [DSML, 'antml:', function_calls, tool_calls, tool_use, invoke, parameter, antml_function_calls]
43
+ ```
44
+
45
+ ### Fallback policy (one button per profile)
46
+
47
+ | Mode | Compiled chain | Meaning |
48
+ |---|---|---|
49
+ | `official` | `[this model, officialModel]` | fall back to the official model (default `deepseek-official/deepseek-flash`) |
50
+ | `loop` | `[this model, every other model once]` | ring fallback: walk the declared list once, so the last route still has somewhere to go |
51
+ | `none` | `[this model]` | no fallback; failure surfaces |
52
+
53
+ ### Time windows
54
+
55
+ `windows` rows compile into time-slot rows whose chain is that profile's fallback chain. Two spellings:
56
+
57
+ - `{preset: liang-peak}` — a frozen preset (`liang-peak`, `liang-valley`, `glm-peak`, `glm-valley`), defined in code and expanded at compile time so it can never drift;
58
+ - `{start, end, days}` — hand-written, `HH:mm`, midnight-crossing allowed, `days` 0–6.
59
+
60
+ The first matching window wins; outside every window the first profile with `windows: []` applies.
61
+
62
+ ### Free combinations (combo)
63
+
64
+ A `kind: combo` row owns no route. Its windows name an existing profile with `use`:
65
+
66
+ ```yaml
67
+ - id: peak-valley
68
+ name: Peak / valley
69
+ kind: combo
70
+ windows:
71
+ - { preset: liang-peak, use: flex-flash } # peak → flex-flash's whole chain
72
+ - { preset: liang-valley, use: deepseek-auto } # valley → deepseek-auto's whole chain
73
+ ```
74
+
75
+ - `use` may only point at a **single-model** row, so a reference cycle cannot be built;
76
+ - the referenced chain applies as-is (its own fallback and extra hops included); the combo adds nothing on top;
77
+ - a combo never becomes `rootChain`; a combo with "show in the model picker" ticked appears there as a fixed chain flattened from its windows in order (de-duplicated), while its real routing still follows the time windows;
78
+ - a window without `use`, or pointing at an unknown id, is ignored (with a warning in `/fallbacks` and the log).
79
+
80
+ ### Failure rules
81
+
82
+ Three checks run in parallel; any hit means failure:
83
+
84
+ - **Codes** — `codes`, defaulting to every code the harness and pi-ai report today (including `PI_AI_ERROR`, which is where `Upstream rejected the request` lands).
85
+ - **Regex** — `regex`, matched against the failure message, to catch upstream prose.
86
+ - **Structural** — `structural` + `markers`: the assistant message ends inside a tool-call marker (`</|DSML|invoke>`, `</invoke>`, a lone `</parameter>` …) while the message carries no tool call and the request did declare tools. This is the response-guard check, rewritten as a structural test: the marker must sit at the **end of the answer**; a mention in mid-sentence does not count.
87
+
88
+ A structural hit takes a different recovery path: `model.select()` to the next hop plus a `steer()` telling the model its tool call was cut off and must be re-issued — instead of replaying the whole turn.
89
+
90
+ ## 2. Per-provider retry
91
+
92
+ ```yaml
93
+ providers:
94
+ - provider: deepinfra-flex
95
+ enabled: true
96
+ maxRetries: 3 # failures allowed inside the window
97
+ windowMs: 300000 # 5-minute sliding window
98
+ initialDelayMs: 800
99
+ maxDelayMs: 15000
100
+ jitterRatio: 0.1
101
+ ```
102
+
103
+ Strictly per-provider opt-in: a provider absent from `providers` is never retried — it falls back immediately. The window is sliding: retrying stops once failures inside `windowMs` exceed `maxRetries`.
104
+
105
+ > **Retry ownership**: the plugin consumes `agent/request-error` itself, so the built-in `@deepseek-ai/dsh-llm-retry` has to be switched off. Otherwise it burns its own five default attempts (`EMPTY_RESPONSE`/`RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`) before this plugin's per-provider rule is ever consulted:
106
+ >
107
+ > ```yaml
108
+ > - id: llm-retry
109
+ > name: "@deepseek-ai/dsh-llm-retry"
110
+ > disabled: true
111
+ > ```
112
+
113
+ ## 3. Compilation (why the old fields no longer need editing)
114
+
115
+ When `models` is non-empty the plugin compiles the list — on every config read — into the three fields the engine already consumes:
116
+
117
+ - `rootChain` — the fallback chain of the first **single-model** profile without windows (combos never qualify; with none present the first single-model profile is used, and failing that the official flash route);
118
+ - `timeSlots` — one row per window (a preset window compiles to `kind: preset`, a custom one to `kind: custom`); a window carrying `use` gets the referenced profile's whole compiled chain instead;
119
+ - `namedChains` — one entry per profile with "show in the model picker" ticked (a single model uses its own compiled chain, a combo uses its window chains flattened in order and de-duplicated), named after the profile.
120
+
121
+ So while profiles exist, hand-written `rootChain` / `timeSlots` / `namedChains` are derived, read-only results; editing them has no effect. With `models` empty the plugin reverts to the old behaviour: the three fields are used verbatim, so an existing `llm-fallbacks` config carries over unchanged.
122
+
123
+ ## 4. UI
124
+
125
+ Settings → Plugins → **模型配置 (Model profiles)** — the whole panel is this one thing:
126
+
127
+ - one card per profile: pick the 配置类型 (single model / free combination) first, then the name; a single model gets Provider + model + 推理等级 fields, the three-way fallback switch (official / loop / none) and extra fallback routes (`extraRoutes`, each row Provider + model + 推理等级, as many rows as needed); both kinds get "show in the model picker" (`expose`);
128
+ - 推理等级 (`reasoningEffort`) left empty follows the system default (the host fills in whatever the model advertises); the options are the levels that model advertises in the runtime catalog, and changing the model or provider clears it (effort ids are model-specific, a stale one is rejected by the host). An extra fallback route pins its own level the same way, and that level applies only when that hop is the one actually serving the request;
129
+ - **every Provider and model field is a dropdown** fed by the running host's provider/model catalog (`llm/listConfigurableProviders` + `session/modelCatalog`). A value the catalog does not advertise is kept as an extra option, so a hand-written route is never silently replaced; with no catalog at all the field degrades to free text;
130
+ - time-window rows: pick a preset (Liang peak / Liang valley / GLM peak / GLM valley) or "custom" and fill in the clock fields and weekdays; the trailing 使用配置 dropdown decides which existing profile serves that window (required on a combo, "本模型" by default on a single model);
131
+ - failure rules: code checkboxes, an extra-code input, regex lines, the structural toggle and markers;
132
+ - a "Provider retry" section at the bottom for the per-provider rows;
133
+ - the legacy "main agent / subagent roles / advanced" panels are gone entirely; provider credentials (api-key, baseURL) stay in the provider's own config and are not editable here.
134
+
135
+ ## 5. Fallback features kept
136
+
137
+ Profiles are the only authoring surface; the engine is untouched (it simply has no UI any more):
138
+
139
+ - cooldown (`cooldownMs`) and `revertPolicy` (`cooldown-expiry` / manual), `recovery: timer|half-open`;
140
+ - `namedChains` (compiled from `expose`, shown as `FallbacksChain / <name>`), `maxSwitchesPerStep`, `alwaysModeRetryCap`;
141
+ - role-based subagents (`roles.list` / `roles.rules`, five preset roles, `**Execute as**: <id>`, persona and badge);
142
+ - context-window-aware fallback (`CONTEXT_WINDOW_EXCEEDED`);
143
+ - the dsh-tui surface (`/fallbacks` diagnostics, `/settings` section).
144
+
145
+ ## 6. Verifying
146
+
147
+ After saving, run `/fallbacks` for the session diagnostic (source, resolved role, chains, recent switches, cooldown). Expected:
148
+
149
+ - the effective chain matches the configured profile's fallback chain;
150
+ - after one upstream failure the log shows `llm-router: retrying <provider> (n/max) in <ms> after <code>`, then `llm-router: retry budget for <provider> is spent` once the window is exhausted, followed by the switch.
151
+
152
+ ## 7. Migrating from the old plugins
153
+
154
+ 1. Disable `llm-fallbacks` and `llm-response-guard` (their hooks overlap; running all three double-switches and double-retries).
155
+ 2. Split the old `rootChain` into `models`: one profile per candidate, the last one `fallback: none`.
156
+ 3. Express an old `namedChains` entry as a profile: its first route becomes the profile, tick "show in the model picker", and put the remaining hops into "extra fallback routes".
157
+ 4. A route repeated inside an old chain (say a provider listed three times) becomes that provider's "Provider retry" row instead of duplicate entries.
158
+ 5. Copy the old `llm-response-guard` `markers` into the relevant profile's `rules.markers`; `degrade: true` becomes `structural: true`.
159
+ 6. Rewrite the old `retry` block as `providers` rows.
160
+
161
+ ## License
162
+
163
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,170 @@
1
+ # dsh-fallback-router
2
+
3
+ [English](README.md) | [中文](README.zh-CN.md)
4
+
5
+ dsh(DeepSeek Harness)的**模型路由与故障兜底**插件。它是 `dsh-llm-fallbacks` 与 `dsh-llm-response-guard` 的合并版:把「换一条路由重跑」和「这一步的输出坏了」两类故障,收进同一套以**模型配置**为中心的面板里。
6
+
7
+ 一句话区别:以前你要分别维护「降级链」「分时槽」「provider 重试」「残片判定」四处配置;现在你只维护一张**模型配置**列表,其余全部由它编译出来。
8
+
9
+ ## 〇、安装
10
+
11
+ ```sh
12
+ dsh plugin --profile desktop add github:hongxin-y/dsh-fallback-router
13
+ ```
14
+
15
+ 想锁定代码版本就带上提交号(`#<sha>`)。pnpm ≥ 10 默认不执行 git 依赖的构建脚本,第一次 `add` 可能停在 `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`:在 profile 目录(`$DSH_HOME/profiles/desktop`)里跑一次 `pnpm approve-builds`,或把该包写进该 profile 的 `pnpm-workspace.yaml` 允许列表(pnpm 11 用 `allowBuilds`,pnpm 10 用 `onlyBuiltDependencies`),再执行一次。详细步骤、验证与卸载 → [docs/install.md](docs/install.md)。
16
+
17
+ 本包的 npm 包名是 `dsh-fallback-router`,但**还没有发布到注册表**(发布流程里的 npm 步骤默认关闭),请按上面的 GitHub 源安装。
18
+
19
+ ## 一、核心概念:模型配置
20
+
21
+ 每一条「模型配置」= 一个可被路由到的真实模型 + 它自己的时间区间、兜底策略、失效判定规则。
22
+
23
+ ```yaml
24
+ fallbacks: # 设置命名空间沿用 fallbacks,兼容旧配置
25
+ models:
26
+ - id: flex-flash
27
+ name: DeepInfra Flex Flash
28
+ kind: route # route(单一模型)| combo(自由组合)
29
+ provider: deepinfra-flex
30
+ model: deepseek-ai/DeepSeek-V4.1-Flash-1017
31
+ windows: [] # 空 = 全时段
32
+ fallback: official # official | loop | none
33
+ officialModel: deepseek-official/deepseek-flash
34
+ reasoningEffort: '' # 留空 = 跟随系统;否则填该模型广告的等级 id
35
+ extraRoutes: # 追加兜底路由:主路由之后、官方兜底之前,按顺序尝试
36
+ - route: ruinique/gpt-6.1-sol
37
+ reasoningEffort: high # 留空 = 跟随系统
38
+ rules:
39
+ codes: [AUTH, QUOTA, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT, PI_AI_ERROR]
40
+ regex: ['Upstream rejected the request']
41
+ structural: true
42
+ markers: [DSML, 'antml:', function_calls, tool_calls, tool_use, invoke, parameter, antml_function_calls]
43
+ ```
44
+
45
+ 两条形状:
46
+
47
+ - **单一模型**(`kind: route`,默认)—— 绑定一个 `provider/model`,自带时间区间、兜底策略、追加路由与失效判定。
48
+ - **自由组合**(`kind: combo`)—— 不绑定模型,每个时间区间用 `use: <另一个模型的 id>` 指定「这一段用哪个已有配置」。例如梁高峰用 `flex-flash`、梁低谷用 `deepseek-auto`,两边的兜底与判定规则各自沿用,不用复制一遍。
49
+
50
+ ### 兜底策略(每个模型配置一个按钮)
51
+
52
+ | 模式 | 编译出的链 | 含义 |
53
+ |---|---|---|
54
+ | `official` | `[本模型, officialModel]` | 挂了就切官方模型(默认 `deepseek-official/deepseek-flash`) |
55
+ | `loop` | `[本模型, 其余模型各一次]` | 循环兜底:按声明顺序绕一圈,最后一条失效也还能试到别的 |
56
+ | `none` | `[本模型]` | 不兜底,挂了就报错 |
57
+
58
+ ### 时间区间
59
+
60
+ `windows` 里的每一行在编译时变成一条分时槽,链内容就是该模型的兜底链。两种写法:
61
+
62
+ - `{preset: liang-peak}` —— 冻结预设(`liang-peak` 梁高峰 / `liang-valley` 梁低谷 / `glm-peak` / `glm-valley`),定义在代码里,编译期展开,不会随时间漂移;
63
+ - `{start, end, days}` —— 自定义,`HH:mm`,可跨午夜,`days` 是 0–6。
64
+
65
+ 第一个命中的窗口胜出;没有窗口命中的时刻回落到第一条 `windows: []` 的模型。
66
+
67
+ ### 自由组合(combo)
68
+
69
+ `kind: combo` 的行不绑定 `provider/model`,它只在窗口上写 `use`:
70
+
71
+ ```yaml
72
+ - id: peak-valley
73
+ name: 峰谷组合
74
+ kind: combo
75
+ windows:
76
+ - { preset: liang-peak, use: flex-flash } # 梁高峰 → 用 flex-flash 的整条链
77
+ - { preset: liang-valley, use: deepseek-auto } # 梁低谷 → 用 deepseek-auto 的整条链
78
+ ```
79
+
80
+ 规则:
81
+
82
+ - `use` 只能指向另一个 **单一模型** 行,所以引用天然无环;
83
+ - 被引用的那条链原样生效(含它自己的兜底与追加路由),组合行自己不再追加兜底;
84
+ - 组合行不参与 `rootChain`;勾了「在模型选择器里显示」的组合会以「各区间链按顺序拼接、去重」的固定链出现在选择器里(实际分时仍按时间区间生效);
85
+ - 窗口没有 `use`、或指向不存在的 id 时,该窗口被忽略(`/fallbacks` 与日志里有告警)。
86
+
87
+ ### 失效判定规则
88
+
89
+ 三条并行,任一命中即视为失效:
90
+
91
+ - **错误码** —— `codes`,默认带上 harness 与 pi-ai 目前会报的全部码(含 `PI_AI_ERROR`,`Upstream rejected the request` 就落在它上面)。
92
+ - **正则** —— `regex`,对失败消息做匹配,用来抓上游原文里的特征句。
93
+ - **结构判定** —— `structural` + `markers`:assistant 消息末尾停在工具调用标记中间(`</|DSML|invoke>`、`</invoke>`、孤立 `</parameter>` 等),且整条消息里没有 tool-call、但本次请求确实带了 tools。这是 `llm-response-guard` 的判定,已改成结构判定而非文本拟合:要求标记出现在**答案结尾**,句中引用不算命中。
94
+
95
+ 命中结构残片时走的是另一条恢复路径:`model.select()` 换到兜底链下一跳 + `steer()` 一条「你上次的工具调用被截断了,重新发一次」的提示,而不是重跑整轮。
96
+
97
+ ## 二、每个 provider 单独的重试
98
+
99
+ ```yaml
100
+ providers:
101
+ - provider: deepinfra-flex
102
+ enabled: true
103
+ maxRetries: 3 # 窗口内允许的失败次数
104
+ windowMs: 300000 # 滑动窗口 5 分钟
105
+ initialDelayMs: 800
106
+ maxDelayMs: 15000
107
+ jitterRatio: 0.1
108
+ ```
109
+
110
+ 语义是**严格逐 provider opt-in**:没写进 `providers` 的 provider 一次都不重试,失败直接走兜底链。窗口是滑动窗口——`windowMs` 内失败次数超过 `maxRetries` 才放弃重试。
111
+
112
+ > **重试归属**:插件自己消费 `agent/request-error`,所以要把内置的 `@deepseek-ai/dsh-llm-retry` 关掉,否则它会先用自己的 5 次默认策略(`EMPTY_RESPONSE`/`RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`)把失败吃掉,本插件的 per-provider 规则永远轮不到:
113
+ >
114
+ > ```yaml
115
+ > - id: llm-retry
116
+ > name: "@deepseek-ai/dsh-llm-retry"
117
+ > disabled: true
118
+ > ```
119
+
120
+ ## 三、编译关系(为什么旧字段不用再改)
121
+
122
+ `models` 非空时,插件在每次读配置时把模型列表编译成引擎原本读的三个字段:
123
+
124
+ - `rootChain` —— 第一条无窗口的**单一模型**的兜底链(组合行不参与;没有这样的行时取第一条单一模型,再没有就退回官方 flash);
125
+ - `timeSlots` —— 每个窗口一条行(预设窗口编译成 `kind: preset`,自定义窗口编译成 `kind: custom`);带 `use` 的窗口,链换成被引用模型编译出的整条链;
126
+ - `namedChains` —— 每个勾了「在模型选择器里显示」的模型配置一条(单一模型用自己编译的链,组合用它各区间链按顺序拼接去重后的链),名字即配置名。
127
+
128
+ 因此有模型配置时,手写的 `rootChain` / `timeSlots` / `namedChains` 都是只读的派生结果,改它们不会生效。`models` 为空时,插件完全退回旧行为:手写的三个字段原样使用,`llm-fallbacks` 的老配置文件可以直接搬过来。
129
+
130
+ ## 四、界面
131
+
132
+ 设置 → 插件配置 → **模型配置**,整个面板只有这一件事:
133
+
134
+ - 每个模型一张卡片:先选「配置类型」(单一模型 / 自由组合),再填名称;单一模型有 Provider + 模型 + 推理等级三格、兜底三选一(官方兜底 / 循环兜底 / 不兜底)、追加兜底路由(`extraRoutes`,每行 Provider + 模型 + 推理等级,可加多行);两种类型都有「在模型选择器里显示」(`expose`)。
135
+ - 推理等级(`reasoningEffort`)留空表示跟随系统(由内核按该模型声明的默认等级决定);下拉选项来自该模型在运行时目录里声明的等级,换模型或换 Provider 会自动清空(等级 id 是模型专属的,留着会被内核拒绝)。追加兜底路由的等级同理:它只在该跳真正成为当前请求路由时生效,不会沿用主路由的等级。
136
+ - **所有 Provider 与模型都是下拉**,选项来自当前运行环境的 provider / 模型目录(`llm/listConfigurableProviders` + `session/modelCatalog`)。目录里没有的值仍会保留成一个额外选项,手写的路由不会被静默替换;宿主完全拿不到目录时退化成文本框。
137
+ - 时间区间行:可下拉选预设(梁高峰 / 梁低谷 / GLM 高峰 / GLM 低谷),也可以选「自定义时间段」后填起止与星期;末尾的「使用配置」下拉决定这一段用哪个已有配置(组合行必选,单一模型行默认「本模型」)。
138
+ - 失效判定:错误码复选 + 追加自定义码 + 正则 + 结构判定 + markers。
139
+ - 底部「Provider 重试」段:逐行开关 + 窗口参数。
140
+ - 旧插件带进来的「主代理 / 子代理角色 / 高级」面板已整块移除;provider 的 api-key、baseURL 等仍由模型 provider 自己的配置管理,这里不可编辑。
141
+
142
+ ## 五、保留的 fallback 能力
143
+
144
+ 模型配置是唯一创作面,引擎能力原样保留(只是不再有编辑界面):
145
+
146
+ - 冷却(`cooldownMs`)与 `revertPolicy`(`cooldown-expiry` / 手动)、`recovery: timer|half-open`;
147
+ - `namedChains`(由 `expose` 编译生成,模型选择器里显示为 `FallbacksChain / <名字>`)、`maxSwitchesPerStep`、`alwaysModeRetryCap`;
148
+ - 角色化 subagent(`roles.list` / `roles.rules`、五个内置预设角色、`**Execute as**: <id>` 声明、persona 与徽章);
149
+ - 上下文窗口感知降级(`CONTEXT_WINDOW_EXCEEDED`);
150
+ - dsh-tui 操作面(`/fallbacks` 诊断、`/settings` 分区)。
151
+
152
+ ## 六、验证
153
+
154
+ 保存后输入 `/fallbacks` 看会话诊断(来源、解析角色、链、最近切换、冷却)。期望看到:
155
+
156
+ - 生效链与你配置的模型兜底链一致;
157
+ - 触发一次上游失败后,日志出现 `llm-router: retrying <provider> (n/max) in <ms> after <code>`,窗口用尽后是 `llm-router: retry budget for <provider> is spent`,随后发生切换。
158
+
159
+ ## 七、从旧插件迁移
160
+
161
+ 1. 停用 `llm-fallbacks` 与 `llm-response-guard`(两者与本插件的钩子重叠,同时启用会重复切换/重复重试)。
162
+ 2. 把旧 `llm-fallbacks` 的 `rootChain` 拆成 `models`:每个候选一条,最后一条 `fallback: none`。
163
+ 3. 旧的 `namedChains` 用模型配置表达:那条链的首个路由建成一个模型,勾上「在模型选择器里显示」;链中间的跳数填进它的「追加兜底路由」。
164
+ 4. 旧链里同一个路由重复出现(例如某个 provider 连写三次)用该 provider 的「Provider 重试」行表达,而不是重复写路由。
165
+ 5. 旧 `llm-response-guard` 的 `markers` 抄进对应模型的 `rules.markers`,`degrade: true` 对应 `structural: true`。
166
+ 6. 旧 `retry` 配置改写成 `providers` 行。
167
+
168
+ ## 许可
169
+
170
+ MIT,见 [LICENSE](LICENSE)。
@@ -0,0 +1,19 @@
1
+ # The dsh-fallback-router profile-bundle patch: ONE insert of the plugin row over
2
+ # the profile's bundle stack (`dsh.profile.bundles` order — dsh-base first,
3
+ # this bundle after the llm-retry bundle; later layers win per row).
4
+ #
5
+ # Insert position matters for the host waterfall: the profile must list the
6
+ # llm-retry bundle BEFORE dsh-fallback-router, so this plugin's
7
+ # `agent/request-error` listener composes after llm-retry and only intervenes
8
+ # once retry policy is exhausted (normal mode) or delegates downstream first
9
+ # (always mode). See plan Risk table.
10
+ #
11
+ # A patch replaces the targeted row's whole `config` rather than merging into
12
+ # it, so a user-level override must restate every field it keeps. The row
13
+ # ships only neutral defaults; profile-layer (`cordis.patch.yml`) overrides
14
+ # win.
15
+
16
+ - insert:
17
+ - id: llm-router
18
+ name: 'dsh-fallback-router'
19
+ config: {}
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Authorized-route detection from durable child state (plan
3
+ * dsh-012-subagent-routing T2; spec D2).
4
+ *
5
+ * Pure, host-free module: a plain-data session view in, one route out. The
6
+ * wiring captures the view at a subagent's first request from the detection
7
+ * sources the plan names (shapes verified against upstream
8
+ * `packages/subagent/subagent/src/child-agent.ts:68-85` and
9
+ * `packages/api/session-controller/src/types.ts:284`):
10
+ *
11
+ * - the session's `model/selection` log entries (plain descriptors; payload
12
+ * is the `ModelSelection` shape — provider, model, optional
13
+ * reasoningEffort), an explicit host-recorded selection for this session;
14
+ * - the durable `request/header` config fold (`requestHeader()?.config`);
15
+ * - the resolved creation options (`agent.options`) as the pre-first-request
16
+ * fallback — upstream precedence: the durable header wins, options
17
+ * fallback (`parentAgentOptionsForDelegation`);
18
+ * - the pure-inheritance baseline: the delegating parent's current route,
19
+ * resolved by the wiring the same way (`requestHeader()?.config` else
20
+ * creation options).
21
+ *
22
+ * A source yields a route only when BOTH provider and model are present
23
+ * non-empty strings — an effort alone is not a route (spec Terms). A
24
+ * `model/selection` entry is definitionally an explicit selection, so it is
25
+ * authorized as-is. A route read from the header or the creation options is
26
+ * explicit only when it is NOT the pure-inheritance continuation: when the
27
+ * baseline is provable (complete) and equals the candidate exactly, the
28
+ * candidate is the parent's inherited route, not a selection — pure
29
+ * inheritance is NOT an authorized route (spec Terms; upstream
30
+ * `resolveChildAgentOptions` materializes the parent route into child
31
+ * options even without an explicit selection, so equality with the baseline
32
+ * is the only child-side inheritance signal). With the baseline unprovable
33
+ * the candidate stands: D2 keeps authorized chain heads unoverwritten, and
34
+ * the allowlist-constrained inject below this decision holds D1 either way.
35
+ *
36
+ * Never throws; malformed source shapes yield `undefined`, not routes.
37
+ *
38
+ * @module dsh-fallback-router/authorized-route
39
+ */
40
+ import type { PolicyRoute } from './subagent-policy.ts';
41
+ /** One plain route-bearing source shape (header config, options, selection payload, baseline). */
42
+ export type RouteSource = {
43
+ readonly provider?: unknown;
44
+ readonly model?: unknown;
45
+ readonly reasoningEffort?: unknown;
46
+ };
47
+ /** Structural session view consumed by {@link detectAuthorizedRoute} — plain data only, no live host objects. */
48
+ export interface AuthorizedRouteSession {
49
+ /** Durable request-header config fold captured from the child session (absent before the first logged header). */
50
+ readonly requestHeader?: RouteSource | undefined;
51
+ /** Plain event descriptors (`type` + optional `data`) captured from the child session's log. */
52
+ readonly events?: readonly {
53
+ readonly type: string;
54
+ readonly data?: unknown;
55
+ }[] | undefined;
56
+ /** Resolved creation options captured from the agent (pre-first-request fallback). */
57
+ readonly options?: RouteSource | undefined;
58
+ /** Pure-inheritance baseline: the delegating parent's current route (absent when unprovable). */
59
+ readonly inherited?: RouteSource | undefined;
60
+ }
61
+ /**
62
+ * Detect the subagent's explicit authorized route at its first request, or
63
+ * `undefined` for pure inheritance (semantics in the module doc). The wiring
64
+ * skips role-inject on a defined result — the authorized route is the chain
65
+ * head (spec D2) — and runs the allowlist-constrained four-stage inject on
66
+ * `undefined`.
67
+ */
68
+ export declare function detectAuthorizedRoute(session: AuthorizedRouteSession): PolicyRoute | undefined;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * LLM auto-match role selection (plan fallbacks-role-automatch Task 3).
3
+ *
4
+ * This module is the bounded LLM caller behind the resolver's stage-3
5
+ * auto-match hook (`resolveRoleAtDispatch`, `role-resolution.ts`). When the
6
+ * explicit and rules stages resolve to `'inherit'` and `roleAutoMatch` is
7
+ * enabled, `pickRoleByLlm` asks the model to pick the best-fit declared role
8
+ * from the taxonomy and returns its declared RAW id, or `null` (= `'inherit'`).
9
+ *
10
+ * Contract (architect-locked, plan Global Constraints · Auto-match bounded):
11
+ * - ONE bounded completion per decision: small `maxTokens`
12
+ * ({@link AUTOMATCH_MAX_TOKENS}) and a timeout
13
+ * ({@link AUTOMATCH_TIMEOUT_MS}, default 5s) that abandons the stream via
14
+ * the streaming contract's cancellation (`options.signal`) — no dangling
15
+ * iterator, no unhandled rejection.
16
+ * - Never throws. Every failure mode — throw, timeout, `none`, no valid id,
17
+ * garbage answer, malformed inputs — resolves to `null`, so the "never
18
+ * throws out of `agent/request`" invariant holds end-to-end (the resolver
19
+ * deliberately does NOT catch the hook).
20
+ * - Fast paths that avoid an LLM call entirely: empty taxonomy, absent
21
+ * `ctx.llm` service, and no concrete provider/model route for the judgment
22
+ * call. The plugin does NOT listen on `llm/stream`, so this call cannot
23
+ * recurse into the plugin.
24
+ *
25
+ * Judgment-call route choice (documented): the concrete provider/model for the
26
+ * judgment call is taken from the roles' DECLARED context first — the first
27
+ * exact (non-wildcard) `provider/model` selector across the declared chains,
28
+ * in declaration order (the operator's own taxonomy is the most stable route
29
+ * set). If no declared chain yields an exact selector, the agent's current
30
+ * `options.provider`/`options.model` is used when both halves are present;
31
+ * otherwise the call is skipped (`null`).
32
+ *
33
+ * Pure helpers exported for unit testing:
34
+ * - {@link buildAutomatchPrompt} — the taxonomy prompt (declared ids +
35
+ * personas + agent origin/agentPreset; instructs exactly-one-declared-id-or-
36
+ * `none`).
37
+ * - {@link parseAutomatchAnswer} — parses a declared id out of the model
38
+ * answer with surrounding whitespace/punctuation/quote/backtick tolerance;
39
+ * `none` (case-insensitive) and unknown ids → `null`. `roleIds` is the
40
+ * trimmed-id → declared-raw-id map, the same canonicalization the resolver
41
+ * uses.
42
+ *
43
+ * @module dsh-fallback-router/automatch
44
+ */
45
+ import { type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm';
46
+ import type { FallbacksRole, FallbacksRoles } from './config.ts';
47
+ import type { AgentLike } from './roles.ts';
48
+ /** Default auto-match completion timeout in ms — abandons the stream. */
49
+ export declare const AUTOMATCH_TIMEOUT_MS = 5000;
50
+ /** Output bound (tokens) for the judgment call: one id or the literal `none`. */
51
+ export declare const AUTOMATCH_MAX_TOKENS = 32;
52
+ /**
53
+ * Minimal `llm` service surface {@link pickRoleByLlm} reads. The real
54
+ * `LlmRuntime.stream(options)` matches structurally; this face keeps the
55
+ * module free of the full cordis Context merge.
56
+ */
57
+ export interface AutomatchLlm {
58
+ stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
59
+ }
60
+ /**
61
+ * Minimal context surface: `ctx.get('llm')` (optional service — absent →
62
+ * `null` fast-path). The broad `get(name: string)` face is satisfied by the
63
+ * real cordis `Context` (`ReflectService.get` overloads return `any`/the
64
+ * merged service), so the Task-4 wiring can pass `ctx` directly.
65
+ */
66
+ export interface AutomatchContext {
67
+ get(name: string): AutomatchLlm | undefined;
68
+ }
69
+ /** Options for {@link pickRoleByLlm}. */
70
+ export interface PickRoleByLlmOptions {
71
+ /** Completion timeout in ms; defaults to {@link AUTOMATCH_TIMEOUT_MS}. */
72
+ timeoutMs?: number;
73
+ /** Warning sink — the decision path injects the plugin logger. */
74
+ warn: (message: string) => void;
75
+ }
76
+ /**
77
+ * Build the auto-match taxonomy prompt: every declared role's trimmed id and
78
+ * persona, the agent's origin (and non-empty `agentPreset` when present), and
79
+ * the exactly-one-declared-id-or-`none` instruction. Pure — no I/O.
80
+ */
81
+ export declare function buildAutomatchPrompt(roles: readonly FallbacksRole[], agent: AgentLike): string;
82
+ /**
83
+ * Parse the model's answer into a declared role id.
84
+ *
85
+ * The answer is expected to BE an id (possibly wrapped in whitespace, quotes,
86
+ * backticks, or trailing punctuation); surrounding characters outside the id
87
+ * grammar (`[a-zA-Z0-9-]`) are stripped, then the remaining string must match
88
+ * a declared id exactly (case-insensitive) via `roleIds` (trimmed id →
89
+ * declared RAW id, the resolver's canonicalization). `none` (case-insensitive)
90
+ * and any unknown/malformed answer → `null`.
91
+ */
92
+ export declare function parseAutomatchAnswer(answer: string, roleIds: ReadonlyMap<string, string>): string | null;
93
+ /**
94
+ * Bounded LLM role-selection call (see module header). Resolves the declared
95
+ * RAW id of the best-fit role, or `null` on any fast path or failure. NEVER
96
+ * throws.
97
+ */
98
+ export declare function pickRoleByLlm(ctx: AutomatchContext, roles: FallbacksRoles, agent: AgentLike, opts: PickRoleByLlmOptions): Promise<string | null>;