dsh-llm-fallbacks 0.1.0-alpha.1

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 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 ADDED
@@ -0,0 +1,124 @@
1
+ # dsh-llm-fallbacks
2
+
3
+ [English](README.md) | [中文](README.zh-CN.md)
4
+
5
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ ![node](https://img.shields.io/badge/node-%3E%3D22-339933.svg)
7
+ ![pnpm](https://img.shields.io/badge/pnpm-%3E%3D10-f69220.svg)
8
+ ![dsh](https://img.shields.io/badge/dsh-DeepSeek%20Harness%20compatible-4B32C3.svg)
9
+
10
+ Automatic provider/model fallback chains for dsh (DeepSeek Harness): when an agent's LLM requests keep failing — retries exhausted, auth errors, quota exceeded, rate limiting (429) — the plugin switches provider/model along the fallback chain for the current role, and the current step/turn continues on the target model: tasks are not interrupted by model problems.
11
+
12
+ Install with a single command (pnpm ≥ 10 needs one build-allow step — see [Install](#install)):
13
+
14
+ ```sh
15
+ dsh plugin --profile web add github:btspoony/dsh-llm-fallbacks # pin a commit with #<sha>
16
+ ```
17
+
18
+ ## Features
19
+
20
+ - **Automatic fallback for root and subagents**: any agent switches down the chain to the next available provider/model on model failure — no manual model switching.
21
+ - **Two-block config**: block 1 `rootChain` — the root agent's single fallback chain (empty = root does not fall back); block 2 declared roles — `roles.list` role entities (id/label/description/chain/fallback) that `roles.rules` reference by id (or the built-in `inherit`); no rule match → `inherit` → `rootChain`.
22
+ - **Entry syntax**: chain entries are `provider/model` (exact switch) or `provider/*` (keep the failed model id, switch provider only) — the old chain-key namespace (provider/model keys, role-name keys) is gone.
23
+ - **Cooldown and revert**: models that were switched away from / failed are not re-selected during the cooldown period; `revertPolicy: cooldown-expiry` automatically returns to the primary model when the cooldown expires, while `never` does not return within the session.
24
+ - **Visible behavior**: every switch appends a persisted session event `fallbacks/switch` (from/to/role/reason), alongside info-level logs (candidate attempt order and skip reasons) and the read-only status block on the Settings → 插件配置 → Fallbacks card — no silent model switching.
25
+ - **Safety valves**: switching stops and the original error semantics are kept once `maxSwitchesPerStep` is exceeded for a step, preventing chain loops from amplifying latency; `mode: 'always'` providers additionally have a retry cap (`alwaysModeRetryCap`).
26
+ - **No-config no-op**: `enabled` defaults to off (`false`); with no `rootChain`/role chains, unmatched trigger codes, or unresolved roles the plugin is a complete no-op — identical to not being installed, and no events are emitted.
27
+
28
+ ## Install
29
+
30
+ ### One-line git install
31
+
32
+ ```sh
33
+ dsh plugin --profile web add github:btspoony/dsh-llm-fallbacks # pin a commit with #<sha>
34
+ ```
35
+
36
+ A git install fetches **sources, not built artifacts**, so the bundle builds itself on install (`prepare` self-build). The plugin is **mount-only**: it never modifies the dsh source tree, and no patch / postinstall step exists — dsh upgrades never require re-patching. pnpm ≥ 10 blocks a git dependency's `prepare` by default: the first `add` fails with `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`, and pnpm prints the exact package key. Allow the build in the profile's `pnpm-workspace.yaml` (`onlyBuiltDependencies: [dsh-llm-fallbacks]`, or run `dsh plugin --profile web approve-builds`), then re-run the `add`. Treat that allowance as permission to execute the package's code on your machine at install time, and pin a commit (`github:btspoony/dsh-llm-fallbacks#<sha>`) so a later push cannot silently change what runs. The full URL form works equivalently: `dsh plugin --profile web add https://github.com/btspoony/dsh-llm-fallbacks.git`.
37
+
38
+ ### Local directory install (recommended for development / verification)
39
+
40
+ ```sh
41
+ # 1) Build in the plugin repo (the prepare self-build runs the pnpm toolchain: tsdown + tsc, no bun)
42
+ pnpm install
43
+ # 2) Add to the target profile (example: web)
44
+ dsh plugin --profile web add .
45
+ ```
46
+
47
+ > **Development prerequisite**: type-checking and tests resolve the real
48
+ > `@deepseek-ai/*` packages (peer deps, host-provided at runtime) from the npm
49
+ > registry at `0.1.0-rc.6` — `autoInstallPeers` + the registry auth token in
50
+ > the user-level `~/.npmrc` (pnpm 11 no longer expands `${NPM_TOKEN}` from a
51
+ > project `.npmrc`), no local link farm.
52
+
53
+ > Both methods, uninstall, and `--dump-config` verification — including the bundle-layer ordering requirements — are covered in [docs/install.md](docs/install.md).
54
+
55
+ ## Quick start
56
+
57
+ ### Minimal configuration
58
+
59
+ Add a `fallbacks:` section to the dsh settings document (default `$DSH_HOME/settings.yaml`):
60
+
61
+ ```yaml
62
+ fallbacks:
63
+ enabled: true # feature switch; defaults to false — set explicitly to enable
64
+ rootChain: # block 1: root agent's chain; tried in order after the primary model fails
65
+ - anthropic/claude-3-5-sonnet
66
+ - openai/*
67
+ roles: # block 2: declare roles first, then let rules reference them
68
+ list:
69
+ - id: reviewer # role entity: unique id (/^[a-z0-9-]{1,32}$/); "inherit" is reserved
70
+ label: Reviewer
71
+ description: Code-review subagents
72
+ chain:
73
+ - openai/gpt-4o-mini
74
+ fallback: inherit-root # default: role chain, then append rootChain
75
+ rules:
76
+ - origin: subagent # all subagents → reviewer role (own chain + inherited root)
77
+ role: reviewer
78
+ ```
79
+
80
+ Roles are declared entities: `roles.list` holds role cards (id/label/description + optional chain/fallback), and `roles.rules` match origin/provider/model in order to a declared role id or the built-in `inherit` (first match wins) — **no rule match → `inherit` → `rootChain`**. A declared role is only ever hit when a rule references it. The legacy chain-key namespace and role-default field are gone (migration table: [docs/configuration.md](docs/configuration.md)).
81
+
82
+ Save and restart the web session for the changes to take effect. The feature switch `fallbacks.enabled` **defaults to off (`false`)** — the plugin only engages once it is turned on; `triggerCodes` defaults to `AUTH` / `QUOTA` / `RATE_LIMIT`; and with **no `rootChain`/role chains configured the behavior is identical to not having the plugin installed**. More examples (role entities, fallback strategies, rules referencing `inherit`) → [docs/configuration.md](docs/configuration.md).
83
+
84
+ > **Upgrade note (behavior change)**: an existing `fallbacks:` section **without an explicit `enabled` key** now resolves to `false` after upgrading — add `enabled: true` to keep the plugin active.
85
+
86
+ ## `/fallbacks` command (in-session diagnostics)
87
+
88
+ Type `/fallbacks` in any session to inspect this session's fallback state — no need to open the settings page:
89
+
90
+ - **Session origin** (`root` / `subagent`) and the **resolved role** (the `role` of the first matching `roles.rules` entry, otherwise the built-in `inherit`);
91
+ - the **resolved chain** for that role (the role's own chain entries, annotated `(inherit-root)` when `rootChain` is appended — `rootChain` entries render in full only when the role has no own chain; `fallback: none` with an empty own chain, or no chain at all, → `not configured`);
92
+ - the **recent switches** (`fallbacks/switch` events, newest first, up to 5): from/to provider/model, role, reason;
93
+ - the **cooldown status**: which `provider/model` keys are currently suppressed and until when.
94
+
95
+ The command is **read-only** — it never mutates fallback state (no cooldown reset, no pending-switch writes). It registers through a conditional `commands` child, so it appears only when the host composes the slash-command registry — with no registry the command is silently unavailable (no top-level inject pollution). Output is zh by default (the host carries no per-session locale signal); the en dictionary lives in the same copy table.
96
+
97
+ ## Mount-only (no dsh modification)
98
+
99
+ The plugin installs as a **pure mount** — it never modifies the dsh source tree:
100
+
101
+ - **Install = bundle insert + client inject + own gateway**: `bundle/cordis.patch.yml`
102
+ inserts the plugin row over the profile bundle stack, `dsh.client.inject` mounts
103
+ the Fallbacks card on the Settings → 插件配置 page, and settings read/write/reset
104
+ go through the plugin's own gateway channel (`/api/fallbacks/get|set|reset`).
105
+ - **No patches, no auto-apply step**: there are no dsh-body patch files and no install
106
+ lifecycle step that applies one. A one-line git install works as-is.
107
+ - **dsh upgrades never require re-patching**: a dsh upgrade that resets the source
108
+ tree changes nothing for this plugin — it keeps working without any re-apply step.
109
+ - **Stale leftover patches are harmless**: the plugin never depends on a patch
110
+ export (role resolution is rules-only; the model-selection marker coordination
111
+ was removed), so a previously patched dsh tree can be left as-is or manually
112
+ reverted — neither is required.
113
+
114
+ ## Documentation
115
+
116
+ | Doc | Content |
117
+ |---|---|
118
+ | [docs/install.md](docs/install.md) | profile install / git install / uninstall / `--dump-config` verification |
119
+ | [docs/configuration.md](docs/configuration.md) | full `fallbacks` namespace reference, selector syntax, example YAML, plugin-config card usage, behavior notes |
120
+ | [docs/verification.md](docs/verification.md) | verification records (test matrix, bundle layer order, runtime contracts, QA gate script) |
121
+
122
+ ## License
123
+
124
+ Released under the **MIT** License — see [LICENSE](LICENSE). The LICENSE file is authoritative for copyright and license terms.
@@ -0,0 +1,119 @@
1
+ # dsh-llm-fallbacks
2
+
3
+ [English](README.md) | [中文](README.zh-CN.md)
4
+
5
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ ![node](https://img.shields.io/badge/node-%3E%3D22-339933.svg)
7
+ ![pnpm](https://img.shields.io/badge/pnpm-%3E%3D10-f69220.svg)
8
+ ![dsh](https://img.shields.io/badge/dsh-DeepSeek%20Harness%20compatible-4B32C3.svg)
9
+
10
+ dsh(DeepSeek Harness)的自动模型降级插件:当 root agent 或 subagent 的模型请求持续失败(重试耗尽、权限、配额超限、限流 429)时,按角色/模型 fallback 链自动切换 provider/model,当前 step/turn 在目标模型上继续完成——任务不因模型问题中断。
11
+
12
+ 一句话安装(pnpm ≥ 10 需一次构建放行,见 [安装](#安装)):
13
+
14
+ ```sh
15
+ dsh plugin --profile web add github:btspoony/dsh-llm-fallbacks # 钉 commit:加 #<sha>
16
+ ```
17
+
18
+ ## 能力一览
19
+
20
+ - **root / subagent 自动降级**:任意 agent 在模型故障下按链切换到下一个可用 provider/model,无需手动换模型。
21
+ - **两块制配置**:块 1 `rootChain`——root 主代理唯一降级链(空 = root 不降级);块 2 声明式角色——`roles.list` 角色实体(id/label/description/chain/fallback)供 `roles.rules` 按 id 引用(或内置 `inherit`);未命中规则 → `inherit` → `rootChain`。
22
+ - **条目语法**:链条目为 `provider/model`(精确切换)或 `provider/*`(保留失败模型 id 仅换 provider)——旧链键命名空间(provider/model 键、角色名键)已删除。
23
+ - **冷却与回主**:被切离/失败的模型在冷却期内不再入选;`revertPolicy: cooldown-expiry` 冷却到期后自动回主模型,`never` 会话内不回。
24
+ - **行为可见**:每次切换追加持久化会话事件 `fallbacks/switch`(from/to/role/reason),配合 info 级日志(候选尝试顺序与跳过原因)与设置 → 插件配置 → Fallbacks 卡片上的只读状态块,无静默换模型。
25
+ - **安全阀**:每 step 的 `maxSwitchesPerStep` 超限后停止切换、保持原错误语义,防止链循环放大延迟;`mode: 'always'` 的 provider 另有重试上限(`alwaysModeRetryCap`)。
26
+ - **无配置回归(no-op)**:`enabled` 默认关闭(`false`),无 `rootChain`/角色链 / 未命中触发码 / 角色解析失败时插件完全 no-op——行为与未安装时一致,不产生任何事件。
27
+
28
+ ## 安装
29
+
30
+ ### 一句话 git 安装
31
+
32
+ ```sh
33
+ dsh plugin --profile web add github:btspoony/dsh-llm-fallbacks # 钉 commit:加 #<sha>
34
+ ```
35
+
36
+ git 安装拉取的是**源码而非构建产物**,安装时由包自行构建(`prepare` 自构建)。插件为**纯挂载**:对 dsh 源码树零修改,无任何补丁 / postinstall 步骤——dsh 升级无需重打。pnpm ≥ 10 默认不执行 git 依赖的 `prepare`:第一次 `add` 会失败并打印 `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`,同时给出精确的包 key。在该 profile 的 `pnpm-workspace.yaml` 中放行构建(`onlyBuiltDependencies: [dsh-llm-fallbacks]`,或运行 `dsh plugin --profile web approve-builds`),然后重跑 `add`。请把这次放行当作它本来的样子:允许该包代码在安装期于你的机器上执行——建议钉 commit(`github:btspoony/dsh-llm-fallbacks#<sha>`),防止后续 push 悄悄改变实际运行的代码。完整 URL 形式等价:`dsh plugin --profile web add https://github.com/btspoony/dsh-llm-fallbacks.git`。
37
+
38
+ ### 本地目录安装(开发/验证推荐)
39
+
40
+ ```sh
41
+ # 1) 在插件仓库目录构建(prepare 自构建走 pnpm 工具链:tsdown + tsc,无 bun)
42
+ pnpm install
43
+ # 2) 装入目标 profile(示例为 web)
44
+ dsh plugin --profile web add .
45
+ ```
46
+
47
+ > **开发前置**:类型检查与测试解析的是**真实** `@deepseek-ai/*` 包(peer 依赖,运行时由宿主提供),
48
+ > 来源为 npm registry 的 `0.1.0-rc.6`(`autoInstallPeers` + 用户级 `~/.npmrc` 的 registry
49
+ > 认证令牌——pnpm 11 起项目级 `.npmrc` 不再展开 `${NPM_TOKEN}`,无本地 link farm)。
50
+
51
+ > 两种安装方式、卸载与 `--dump-config` 验证(含 bundle 层顺序要求)详见 [docs/install.md](docs/install.md)。
52
+
53
+ ## 快速开始
54
+
55
+ ### 最小配置
56
+
57
+ 在 dsh 的设置文档(默认 `$DSH_HOME/settings.yaml`)中添加 `fallbacks:` 分节:
58
+
59
+ ```yaml
60
+ fallbacks:
61
+ enabled: true # 功能级开关;默认关闭(false),需显式打开后生效
62
+ rootChain: # 块 1:root 主代理降级链;主模型失败后按顺序尝试
63
+ - anthropic/claude-3-5-sonnet
64
+ - openai/*
65
+ roles: # 块 2:先声明角色,再让规则引用
66
+ list:
67
+ - id: reviewer # 角色实体:id 唯一(/^[a-z0-9-]{1,32}$/);inherit 为保留字
68
+ label: 审查者
69
+ description: 代码审查子代理
70
+ chain:
71
+ - openai/gpt-4o-mini
72
+ fallback: inherit-root # 默认:角色链后追加 rootChain
73
+ rules:
74
+ - origin: subagent # 所有 subagent → reviewer 角色(自身链 + 继承 root)
75
+ role: reviewer
76
+ ```
77
+
78
+ 角色是声明式实体:`roles.list` 存放角色卡(id/label/description + 可选 chain/fallback),`roles.rules` 按 origin/provider/model 顺序匹配到**已声明角色 id 或内置 `inherit`**(首个命中即停)——**未命中 → `inherit` → `rootChain`**。只声明不写规则的角色永不命中。旧链键命名空间与角色兜底字段已删除(迁移表见 [docs/configuration.md](docs/configuration.md))。
79
+
80
+ 保存并重启 web 会话后生效。功能级开关 `fallbacks.enabled` **默认关闭(`false`)**——打开开关后插件才会介入;`triggerCodes` 默认覆盖 `AUTH` / `QUOTA` / `RATE_LIMIT`;**未配置任何 `rootChain`/角色链时行为与未安装插件完全一致**。更多示例(角色实体、fallback 策略、引用 `inherit` 的规则)见 [docs/configuration.md](docs/configuration.md)。
81
+
82
+ > **升级提示(行为变更)**:已有 `fallbacks:` 配置若**未显式写 `enabled` 键**,升级后解析为 `false`——请补上 `enabled: true` 以保持插件继续生效。
83
+
84
+ ## `/fallbacks` 命令(会话内诊断)
85
+
86
+ 在任意会话中键入 `/fallbacks` 即可查看当前会话的 fallback 状态,无需打开设置页:
87
+
88
+ - **会话来源**(`root` / `subagent`)与**解析角色**(首个命中规则的 `role`,未命中则内置 `inherit`);
89
+ - 该角色的**解析链**(角色自身链 + 继承的 `rootChain`,`fallback: none` 时仅角色链)——无链时显示「未配置」;
90
+ - **最近切换**(`fallbacks/switch` 事件,最新在前,至多 5 条):from/to provider/model、role、reason;
91
+ - **冷却状态**:哪些 `provider/model` 处于冷却、冷却至何时(`revertPolicy: 'never'` 显示「会话内不再回主」)。
92
+
93
+ 命令**只读**——绝不修改 fallback 状态(不重置冷却、不写待应用切换)。它经条件 `commands` 子注入注册,仅在宿主组合了斜杠命令注册表时出现;无注册表时命令静默不可用(无顶层 inject 污染)。输出默认中文(宿主侧无会话级 locale 信号);英文词典在同一副本表中。
94
+
95
+ ## 纯挂载(零 dsh 修改)
96
+
97
+ 插件以**纯挂载**方式安装,**从不修改 dsh 源码树**:
98
+
99
+ - **安装 = bundle 行插入 + client inject + 自有 gateway**:`bundle/cordis.patch.yml`
100
+ 把插件行插入 profile bundle 栈,`dsh.client.inject` 在设置 → 插件配置页挂载
101
+ Fallbacks 卡片,设置读写/重置
102
+ 走插件自有 gateway 通道(`/api/fallbacks/get|set|reset`)。
103
+ - **无补丁、无自动打补丁**:没有 dsh 本体 patch 文件,也没有任何安装期 apply 步骤;
104
+ 一句话 git 安装即可用。
105
+ - **dsh 升级永不需重打**:dsh 升级重置源码树对本插件无影响——无需任何重打步骤。
106
+ - **残留旧补丁无害**:插件不依赖任何补丁导出(角色解析 rules-only;model-selection
107
+ 标记协调已移除)——已打过旧补丁的 dsh 树可原样保留或手动回滚,均非必需。
108
+
109
+ ## 文档
110
+
111
+ | 文档 | 内容 |
112
+ |---|---|
113
+ | [docs/install.md](docs/install.md) | profile 安装 / git 安装 / 卸载 / `--dump-config` 验证 |
114
+ | [docs/configuration.md](docs/configuration.md) | `fallbacks` 命名空间全字段、selector 语法、示例 YAML、插件配置卡使用、行为说明 |
115
+ | [docs/verification.md](docs/verification.md) | 验证记录(测试矩阵、bundle 层序、运行契约、QA gate 剧本) |
116
+
117
+ ## 许可
118
+
119
+ 本项目以 **MIT** 许可证发布,全文见 [LICENSE](LICENSE)。版权与许可条款以 LICENSE 文件为准。
@@ -0,0 +1,19 @@
1
+ # The dsh-llm-fallbacks 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-llm-fallbacks, 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-fallbacks
18
+ name: 'dsh-llm-fallbacks'
19
+ config: {}
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Fallback chain resolution (spec §7.2; plan fallbacks-role-runtime Task 1).
3
+ *
4
+ * `resolveChain` returns the ordered candidate list for a failing
5
+ * (provider, model). Candidates come from the role's own chain plus
6
+ * `rootChain` appended as an inherit-root fallback — **append-not-replace**:
7
+ * `[...(roleDef.chain ?? []), ...(roleDef.fallback === 'none' ? [] :
8
+ * rootChain)]` (role entries first, rootChain entries after). The reserved
9
+ * built-in `inherit` id (INHERIT_ROLE_ID) resolves to `rootChain` silently —
10
+ * it is the legal no-rule-match role, not a typo'd id. A truly undeclared
11
+ * role id (defense) resolves to `rootChain` alone + one warn. Chain-key
12
+ * specificity lookup (`exact` / `provider/*` / role / `default` keys) is
13
+ * deleted — the two-block config model has no chain keys.
14
+ *
15
+ * Wildcard entries (`provider/*`) are resolved against the failing model —
16
+ * keep the model id, swap only the provider; the "target provider has no
17
+ * such model id" skip is judged by {@link resolveCandidate} (via
18
+ * `modelExists`) or by the caller's filter.
19
+ *
20
+ * The concatenation itself lives in ONE helper — {@link buildRoleEntries}
21
+ * (qc1 S-3 SSOT) — shared by {@link resolveChainViews} and
22
+ * {@link hasWildcardEntry}, so the wildcard probe is exact (qc2 F-003:
23
+ * `fallback: 'none'` excludes `rootChain`) and can never diverge from the
24
+ * resolution. Defensive warns (unknown role / undeclared rule target) go
25
+ * through an injected `warn` callback — the wiring passes the cordis
26
+ * `logger.warn` (qc2 F-002 / qc3 S-3), with `console.warn` as the
27
+ * direct-call default.
28
+ *
29
+ * Cooldown / failed-set / same-as-current filtering is caller-side;
30
+ * {@link createCandidateFilter} provides the ready-made predicate.
31
+ *
32
+ * @module dsh-llm-fallbacks/chains
33
+ */
34
+ import { type FallbacksRole } from './config.ts';
35
+ import type { CooldownStore, StepFailureSet } from './cooldown.ts';
36
+ import { type Selector } from './selectors.ts';
37
+ /** The model that just failed — the anchor for chain resolution. */
38
+ export interface FailingModel {
39
+ provider: string;
40
+ model: string;
41
+ }
42
+ /**
43
+ * Resolve one chain entry against the failing model.
44
+ *
45
+ * - `provider/model` → that exact selector (not subject to `modelExists` —
46
+ * the user explicitly listed it).
47
+ * - `provider/*` → keeps the failing model id, swaps only the provider; when
48
+ * `modelExists` is given and the target provider has no such model id →
49
+ * `null` (skip).
50
+ * - Malformed entries → `null`: they "do not take effect"; the strict throw
51
+ * path is {@link parseSelector}, used by the Task 3 config-warning path.
52
+ */
53
+ export declare function resolveCandidate(entry: string, failing: FailingModel, modelExists?: (provider: string, model: string) => boolean): Selector | null;
54
+ /**
55
+ * Single-pass chain resolution for the decision path (T1 review Important
56
+ * #2). Walks the role's concatenated entries ONCE and returns the unfiltered
57
+ * view — `all` (every resolvable candidate, no filter and no existence
58
+ * probe: the caller's early-exit / annotation view) — plus the parallel
59
+ * `wildcard` provenance (`wildcard[i]` = candidate `all[i]` came from a
60
+ * `provider/*` entry). The caller builds the existence probe from `all` and
61
+ * then derives the surviving view with {@link selectCandidates}, so the
62
+ * decision path never resolves the chain twice (and an unknown role warns
63
+ * at most once per decision).
64
+ *
65
+ * The reserved built-in `inherit` id (INHERIT_ROLE_ID) is legal — it
66
+ * resolves to `rootChain` silently (the no-rule-match role, not a typo'd
67
+ * id). Only a truly undeclared id (∉ declared ids ∪ {'inherit'}) warns once
68
+ * (defensive — never crashes) through `warn` (defaults to `console.warn`
69
+ * for direct-call compatibility; the decision path injects the cordis
70
+ * `logger.warn` — qc2 F-002 / qc3 S-3). Malformed entries never become
71
+ * candidates.
72
+ */
73
+ export declare function resolveChainViews(roles: readonly FallbacksRole[], rootChain: readonly string[], role: string, provider: string, model: string, warn?: (message: string) => void): {
74
+ all: Selector[];
75
+ wildcard: boolean[];
76
+ };
77
+ /**
78
+ * Derive the surviving candidates from a {@link resolveChainViews} pass:
79
+ * the caller filter plus the wildcard-only existence probe. `modelExists`
80
+ * applies to `provider/*`-origin candidates only (scoped by the parallel
81
+ * `wildcard` provenance — T2 review Important #1: exact entries are never
82
+ * existence-filtered), mirroring how {@link resolveCandidate} receives it.
83
+ * Order and duplicates are preserved.
84
+ */
85
+ export declare function selectCandidates(all: readonly Selector[], wildcard: readonly boolean[], filter?: (candidate: Selector) => boolean, modelExists?: (provider: string, model: string) => boolean): Selector[];
86
+ /**
87
+ * Ordered fallback candidates for the failing (provider, model).
88
+ *
89
+ * Candidates are the role's concatenated entries (spec §7.2, see
90
+ * {@link buildRoleEntries} — the single concatenation SSOT): the declared
91
+ * role's own `chain` followed by `rootChain` unless `fallback: 'none'`
92
+ * (append-not-replace — role entries first, rootChain as the tail). The
93
+ * reserved built-in `inherit` id resolves to `rootChain` silently; a truly
94
+ * undeclared/unknown `role` id resolves to `rootChain` alone and warns once
95
+ * (defensive — never crashes). `filter` optionally drops candidates — the
96
+ * caller owns cooldown/failed-set/same-model filtering (see
97
+ * {@link createCandidateFilter}). The `modelExists` existence probe is NOT
98
+ * applied per entry here: {@link resolveChainViews} returns the parallel
99
+ * `wildcard` provenance, and {@link selectCandidates} applies `modelExists`
100
+ * to `provider/*`-origin candidates ONLY (qc1 S-2 — exact entries are never
101
+ * existence-filtered, spec §2 clause 2 / T2 review Important #1
102
+ * decision-path contract). `warn` forwards to {@link resolveChainViews}
103
+ * (defaults to `console.warn`; the decision path injects `logger.warn`).
104
+ *
105
+ * Single-pass: delegates to {@link resolveChainViews} + {@link selectCandidates}
106
+ * (one concatenated-entry walk, warn at most once per call).
107
+ */
108
+ export declare function resolveChain(roles: readonly FallbacksRole[], rootChain: readonly string[], role: string, provider: string, model: string, filter?: (candidate: Selector) => boolean, modelExists?: (provider: string, model: string) => boolean, warn?: (message: string) => void): Selector[];
109
+ /**
110
+ * Whether any entry of the role's concatenated candidates is a wildcard
111
+ * (`provider/*`). F-002: {@link resolveChain} resolves wildcard entries to
112
+ * concrete models, so the wildcard provenance is invisible on the resolved
113
+ * candidate list — the decision path consults the same concatenation
114
+ * `resolveChain` walks to decide whether the catalog existence probe is
115
+ * needed at all.
116
+ *
117
+ * The probe walks {@link buildRoleEntries} — the SAME concatenation
118
+ * {@link resolveChainViews} resolves (qc1 S-3 single SSOT) — so it is
119
+ * exact, never an over-approximation (qc2 F-003 / qc3 S-1): for
120
+ * `fallback: 'none'` roles `rootChain` is excluded, and a wildcard that can
121
+ * never reach the candidate list never builds a catalog probe. No
122
+ * false-negative is possible: any wildcard the resolution could reach is on
123
+ * this concatenation by construction. Malformed entries never become
124
+ * candidates and are skipped.
125
+ */
126
+ export declare function hasWildcardEntry(roles: readonly FallbacksRole[], rootChain: readonly string[], role: string): boolean;
127
+ /** Inputs for {@link createCandidateFilter}. */
128
+ export interface CandidateFilterOptions {
129
+ /** The currently active (failing) model — skipped as "same as current". */
130
+ current: FailingModel;
131
+ /** Cooldown store: suppressed candidates are skipped (keyed `provider/model`). */
132
+ cooldown: Pick<CooldownStore, 'isSuppressed'>;
133
+ /** Failed-set for the current step: already-failed candidates are skipped. */
134
+ failed: Pick<StepFailureSet, 'has'>;
135
+ /** Optional existence probe: candidates the target provider lacks are skipped. */
136
+ modelExists?: (provider: string, model: string) => boolean;
137
+ }
138
+ /**
139
+ * The caller-side candidate filter (Task 3): a candidate is usable when it
140
+ * differs from the current model, is not cooldown-suppressed, has not failed
141
+ * in this step, and (when `modelExists` is given) exists on its provider.
142
+ */
143
+ export declare function createCandidateFilter(options: CandidateFilterOptions): (candidate: Selector) => boolean;
144
+ /** Why one considered candidate was excluded from the selection (spec §2 行为可见性). */
145
+ export type CandidateSkipReason = 'same-as-current' | 'cooldown' | 'step-failed' | 'missing-id';
146
+ /** One entry of the ordered, per-candidate annotation (T3 review Minor 1). */
147
+ export interface AnnotatedCandidate {
148
+ candidate: Selector;
149
+ /** Why this candidate was excluded; `undefined` = it survived every exclusion. */
150
+ skip?: CandidateSkipReason;
151
+ }
152
+ /**
153
+ * Annotate the ordered "considered" candidate list with each candidate's skip
154
+ * reason (spec §2 行为可见性: the switch log must show the attempt order and
155
+ * why each candidate was skipped). The selection-relevant view is the
156
+ * `surviving` list the decision path resolved with filter + existence probe:
157
+ * a candidate absent from it failed one of the exclusions, and the concrete
158
+ * reason is derived from the same checks {@link createCandidateFilter}
159
+ * applies, in the same precedence. `missing-id` therefore only ever labels
160
+ * entries the existence probe dropped — exact entries are never
161
+ * existence-probed (T2 contract), so they stay in `surviving` and are
162
+ * reported as usable, never as missing-id.
163
+ *
164
+ * This is the diagnostic counterpart of {@link createCandidateFilter} — pure,
165
+ * order-preserving, and duplicate-preserving.
166
+ */
167
+ export declare function annotateCandidates(candidates: readonly Selector[], surviving: readonly Selector[], options: Pick<CandidateFilterOptions, 'current' | 'cooldown' | 'failed'>): AnnotatedCandidate[];
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Conversation-level fallback-switch visibility (plan fallbacks-aux-seams,
3
+ * task 2, D1+D2 seam).
4
+ *
5
+ * Every `fallbacks/switch` session event becomes its own chat-transcript
6
+ * node (`fallbacks-switch`), rendered as a compact system-style line at the
7
+ * switch's event seq — the user sees the recovery happen in place
8
+ * (provider/model A → B, role · reason), instead of the event existing only
9
+ * in the raw `sessions.history` event feed (it is NOT a SurfaceEventType,
10
+ * so the `unknown-surface` fallback never picked it up and the transcript
11
+ * showed nothing).
12
+ *
13
+ * Contract notes (dsh-private, verified 2026-08-12):
14
+ * - D1 registry: `ConversationEventRegistry.register(definition)` — service
15
+ * on the client Context (`runtime/src/client/index.ts:171,189-192`);
16
+ * external registration precedent `ui-workflow-run/src/client/index.ts:18-28`.
17
+ * The engine feeds EVERY session event to each definition's `match`
18
+ * (`runtime/src/client/sessions/conversation-assembler.ts:370-382`) —
19
+ * non-surface plugin events included — and the client session appends live
20
+ * events into the engine (`sessions/session.ts:673` `conversation.append`).
21
+ * - D2 seat: `conversation.chat.node` is a keyed seat dispatched by
22
+ * `ChatConversationViewNode.kind` (`ui-conversation contract/slots.ts:56-63`;
23
+ * `chat/ChatNodeSeat.tsx:48-51`), externally registrable as
24
+ * `{ name, key, locale }` (precedents: ui-tool `tool-call`, ui-goal
25
+ * `command-input`, ui-workflow-run `workflow-run`).
26
+ * - Purity: this file only type-imports `@deepseek-ai/dsh-client-runtime/client`
27
+ * and `@deepseek-ai/dsh-client-ui-conversation/client` (both erased at
28
+ * build); the renderer self-draws on `--dsw-alias-*` tokens. Render-only:
29
+ * the Definition is a pure view contribution — no message construction,
30
+ * no model-context injection (C4 excluded by scope).
31
+ */
32
+ import type { ReactNode } from 'react';
33
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
34
+ import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-runtime/client';
35
+ import type { FallbackSwitchReason } from '../events.ts';
36
+ /** Final chat payload of one decided fallback switch (snapshot of the event). */
37
+ export interface FallbacksSwitchChatData {
38
+ readonly seq: number;
39
+ readonly time: number;
40
+ readonly turn: number;
41
+ readonly step: number;
42
+ /** The model the request was using when the switch was decided. */
43
+ readonly from: {
44
+ readonly provider: string;
45
+ readonly model: string;
46
+ };
47
+ /** The chain candidate the switch moves to. */
48
+ readonly to: {
49
+ readonly provider: string;
50
+ readonly model: string;
51
+ };
52
+ /** The fallback-chain role the decision resolved for the agent. */
53
+ readonly role: string;
54
+ readonly reason: FallbackSwitchReason;
55
+ }
56
+ declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
57
+ interface ChatNodeDataMap {
58
+ /** One decided fallback provider/model switch, rendered at its event seq. */
59
+ 'fallbacks-switch': FallbacksSwitchChatData;
60
+ }
61
+ }
62
+ /**
63
+ * One switch event → one chat node. Each `fallbacks/switch` event is its own
64
+ * Context (id = event seq — the durable unique key), so every match is a
65
+ * `start`; `update` is a passthrough (no aggregation — D3's per-Turn
66
+ * counting is a separate, unselected seam).
67
+ */
68
+ export declare const fallbackSwitchDefinition: ConversationNodeDefinition<FallbacksSwitchChatData>;
69
+ /**
70
+ * Props delivered by the keyed chat-node seat: runtime share + the `fallbacks` locale seat.
71
+ */
72
+ export type ConversationFallbackSwitchProps = PropsRuntime<'conversation.chat.node', 'fallbacks-switch'> & PropsLocale<'fallbacks'>;
73
+ /**
74
+ * Render one fallback switch as a compact system-style transcript line.
75
+ *
76
+ * Geometry follows the upstream chat system rows (the compaction boundary
77
+ * notice: dim title + separator + ellipsized summary — `chat/MessageItem
78
+ * .module.css:38-122`); every color resolves through a `--dsw-alias-*`
79
+ * token. A reason outside the current union renders raw (forward-compatible
80
+ * durable log, same rule as the card/general row summaries). A malformed or
81
+ * partial payload (version skew) degrades to the title-only line instead of
82
+ * throwing during interpolation — the transcript slot stays visible with a
83
+ * truthful "a switch happened" notice and no summary details.
84
+ * @param props - composed keyed seat props.
85
+ * @returns the switch line element tree.
86
+ */
87
+ export declare function ConversationFallbackSwitch({ node, t }: ConversationFallbackSwitchProps): ReactNode;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Fallbacks settings card — the `fallbacks` plugin card on the web settings
3
+ * "插件配置" page (spec §4). Registered into the `settings.plugin.item` slot
4
+ * (id `fallbacks`, order 30, alongside the upstream bash/agent-loop/web-search
5
+ * cards and the advisor card); owner props are empty and all data flows
6
+ * through {@link FallbacksSettingsController}.
7
+ *
8
+ * The card chrome replicates the upstream `PluginCard` contract (self-drawn:
9
+ * the upstream client value face exports no reusable card): a collapsible
10
+ * `<li>` whose header is a button stacking the plugin name over its
11
+ * description, with a dirty "unsaved" pill and a rotating chevron
12
+ * (`IconChevronDownOutline14` from ui-primitives — a CLIENT_EXTERNALS value
13
+ * import), `aria-expanded`/`aria-label` like the upstream header; a divider
14
+ * under the header; then the form content; then a footer with
15
+ * Discard / Reset / Save carrying the upstream disabled semantics — save =
16
+ * `!dirty || saving || !writable`, discard = `!dirty || saving` (KD-U1).
17
+ * Disclosure is card-local state: which card a user has open is a reading
18
+ * gesture, and staged edits outlive collapsing — the pill rides the header
19
+ * (upstream rationale).
20
+ *
21
+ * The form body is the two-block editing surface (spec §8): the `enabled`
22
+ * checkbox row, the 6 top-level scalar fields (trigger codes / revert
23
+ * policy / three numeric fields), the `rootChain` block (block 1 — the
24
+ * root agent's single chain, no key input), and the roles block (block 2 —
25
+ * declared role entity cards from `roles.list` plus the rule rows from
26
+ * `roles.rules`, whose role field is a dropdown bound to the declared ids
27
+ * + the built-in `inherit`, same-page live). Saving runs `validateDraft`
28
+ * first — id format/reserved word/duplicates, undeclared rule role
29
+ * references, and illegal selectors block the write with a validation
30
+ * banner + inline red borders (never touching the store error path); a
31
+ * non-empty `state.legacyKeys` renders the migration banner at the top of
32
+ * the card body. The row editors keep their filled editorCard surface
33
+ * inside the card, with `--dsw-alias-*` tokens throughout. The reset-
34
+ * to-defaults confirmation stays a `Modal` (the delete-confirm pattern of
35
+ * the Models page) — no `window.confirm`.
36
+ *
37
+ * The page-only chrome is gone (720px column wrapper, title/intro banners,
38
+ * page-bottom status block): the AC-7 read-only status (derived effective
39
+ * model + recent-switch summary) is folded into the card body above the
40
+ * footer, and the plugin-config section owns the column width.
41
+ *
42
+ * Degraded/error/loading states keep the same card chrome (KD-U3): the
43
+ * header always renders title+description+chevron, and the body carries the
44
+ * config-channel notice or the load error. A card that cannot reach the
45
+ * `fallbacks/get` gateway channel (`ready && !present`) keeps the USABLE
46
+ * skeleton — the form stays writable and saves are attempted (KD-G5) — with
47
+ * the `unavailable` notice ALWAYS visible (derived open — the header cannot
48
+ * collapse it away), while a healthy card is collapsed until the user
49
+ * expands it (AC-1, the documented divergence from upstream whose
50
+ * unavailable card renders nothing). A hard load failure (`status ===
51
+ * 'error'`) also forces the body open with an error notice and — when the
52
+ * form is inert (`!writable`, i.e. the load never landed) — a Retry button;
53
+ * a save failure keeps the editable form so the Save action itself is the
54
+ * retry (the single `state.error` surface covers both, unlike the advisor's
55
+ * separate apply-failure hints).
56
+ *
57
+ * The degraded derivation is latched in the card (the store stays untouched):
58
+ * `present` only ever changes inside the store's `accept()`, so the settled
59
+ * `ready` read is authoritative, and a card-local latch carries that value
60
+ * through refresh/save windows (`loading`/`saving`) so the notice body can
61
+ * never collapse mid-refresh (the advisor's latched `degraded` field,
62
+ * implemented without a store change); on a first mount the latch is false,
63
+ * so the healthy card starts (and stays) collapsed through its first load.
64
+ */
65
+ import type { ReactNode } from 'react';
66
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
67
+ import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react';
68
+ import { FallbacksSettingsController, type FallbacksSettingsState } from './fallbacks-store.ts';
69
+ /** Injected dependencies of {@link FallbacksCard} (slot `inject`). */
70
+ export interface FallbacksCardInjected {
71
+ /** The card store (loaded on mount, refreshed on pushed invalidations). */
72
+ controller: FallbacksSettingsController;
73
+ /** uSES subscription hook bound to the store (inject face — advisor pattern). */
74
+ useSnapshot: SnapshotSelectorHook<FallbacksSettingsState>;
75
+ }
76
+ /** Props delivered by the slot outlet: runtime share + locale seat + inject face. */
77
+ export type FallbacksCardProps = PropsRuntime<'settings.plugin.item'> & PropsLocale<'fallbacks'> & FallbacksCardInjected;
78
+ /**
79
+ * Render the Fallbacks settings card inside the plugin-config section,
80
+ * replicating the upstream PluginCard chrome (KD-U1). The body carries the
81
+ * existing form content unchanged plus the folded-in status block and the
82
+ * footer actions (Discard / Reset / Save).
83
+ * @param props - slot-delivered injected dependencies and the synthesized t seat.
84
+ * @returns the card.
85
+ */
86
+ export declare function FallbacksCard({ controller, useSnapshot, t }: FallbacksCardProps): ReactNode;