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.
- package/LICENSE +21 -0
- package/README.md +162 -2
- package/README.zh-CN.md +170 -0
- package/bundle/cordis.patch.yml +19 -0
- package/dist/authorized-route.d.ts +68 -0
- package/dist/automatch.d.ts +98 -0
- package/dist/chains.d.ts +186 -0
- package/dist/client/ConversationFallbackSwitch.d.ts +91 -0
- package/dist/client/FallbacksCard.d.ts +36 -0
- package/dist/client/GeneralFallbacksRow.d.ts +19 -0
- package/dist/client/ModelsPanel.d.ts +64 -0
- package/dist/client/SubagentRoleBadge.d.ts +53 -0
- package/dist/client/fallbacks-store.d.ts +631 -0
- package/dist/client/index.d.ts +64 -0
- package/dist/client/index.js +3420 -0
- package/dist/client/locales.d.ts +454 -0
- package/dist/client/switch-guard.d.ts +17 -0
- package/dist/client/use-snapshot.d.ts +11 -0
- package/dist/commands.d.ts +380 -0
- package/dist/config.d.ts +239 -0
- package/dist/cooldown.d.ts +57 -0
- package/dist/events.d.ts +52 -0
- package/dist/gateway.d.ts +359 -0
- package/dist/index.d.ts +195 -0
- package/dist/index.js +4587 -0
- package/dist/models.d.ts +306 -0
- package/dist/override.d.ts +39 -0
- package/dist/presets.d.ts +20 -0
- package/dist/recovery.d.ts +92 -0
- package/dist/role-notice.d.ts +184 -0
- package/dist/role-persona.d.ts +71 -0
- package/dist/role-projection-key.d.ts +41 -0
- package/dist/role-projection.d.ts +152 -0
- package/dist/role-resolution.d.ts +105 -0
- package/dist/roles.d.ts +90 -0
- package/dist/route-allowlist.d.ts +43 -0
- package/dist/schema.d.ts +46 -0
- package/dist/seeds.d.ts +237 -0
- package/dist/selectors.d.ts +38 -0
- package/dist/state.d.ts +205 -0
- package/dist/subagent-policy.d.ts +123 -0
- package/dist/subagents-seam.d.ts +305 -0
- package/dist/time-slots.d.ts +147 -0
- package/dist/tui-settings.d.ts +145 -0
- package/dist/tui.d.ts +63 -0
- package/dist/virtual-adapter.d.ts +259 -0
- package/icon.svg +16 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- 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
|
-
#
|
|
1
|
+
# dsh-fallback-router
|
|
2
2
|
|
|
3
|
-
|
|
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).
|
package/README.zh-CN.md
ADDED
|
@@ -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>;
|