dsh-model-switch 0.4.21 → 0.4.22

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## v0.4.22
2
+
3
+ One addition: per-provider capability groups — save the compaction, subagent, and review routes together for a provider, and apply that group automatically when the main model's provider changes.
4
+
5
+ - **Provider groups.** New settings card (供应商配置组 / Provider groups) stores one route group per provider in the existing shared `model-switch` namespace (`providerGroups`, an entry array normalized to a provider map) covering exactly the three session-level capability routes — compaction, subagent, and auto review; search and image stay out of scope. Applying a group is a **full replacement** through one ordered write sequence: the pinned pairs are set, pairs the group does not pin are unset as a pair (never a half pair), and `subagentMode` is written last (`fixed` when the subagent pair is pinned — with its effort only when the effort is non-blank, blank meaning the provider default — else `follow-main`), so every unpinned capability reverts to its own default. Two plugin-owned switch surfaces trigger the apply: the composer picker's `directoryFace.select()` and the Main card's `saveMain()`, both gated on an actual provider change; a same-provider switch, a provider without a stored group, or any other switch surface (TUI) writes nothing. The apply rides the public ConfigForm `set`/`unset` onto the existing flat settings fields, so the consumers (`compaction.ts`, `subagent-runtime.ts`, `auto-review.ts`) are untouched, and the effect is profile-level like the fields it writes. Failures never fail the switch or the Main save: the picker logs a warning and still commits, while the Main card surfaces "Main model saved, but applying the provider group failed …". In the editor, a provider's draft seeds from the active routes the first time it is configured, each capability switch seeds from Main when turned on and stores the capability default when off, applying a group overwrites the three standalone cards (including manual edits, stated in the card copy), and **Delete group** removes the provider's entry (clearing the stored field when the last group goes, no-op when nothing is stored).
6
+ - **Storage and typing.** `providerGroups` is a `z.array(entry).default([]).volatile()` field on the exported `Config` (annotated explicitly so declaration emit stays inside this package), coerced per entry with `''` defaults; `normalizeProviderGroups` never throws, accepts both the stored entry array and a hand-written provider→routes record, drops blank fields, unknown fields, and blank/duplicate entries, and treats a literal `__proto__` provider id as data. `readConfig` omits the field when no group exists, keeping the exact empty-config shape unchanged.
7
+ - Verification: `pnpm run check` green (`tsc` + `tsdown`, 52 test files / 386 tests, pack gate); reverse verification — removing the picker's apply wiring turns exactly the adopt test red, green again after restore; lab 3082 smoke: two stored groups (one pinning compaction onto the keyless `deepseek-official` route, one onto a working route) apply on a picker provider switch (all three cards show the group values, switching back restores the previous routes), no-group providers leave the routes untouched, and `/compact` under the keyless pin fails with that route's `MISSING_CREDENTIAL` while the working group summarizes normally.
8
+
1
9
  ## v0.4.21
2
10
 
3
11
  Two product changes and one investigation: plan-exit auto-compaction is removed entirely; official compaction (`/compact` and official automatic compaction) gains one configurable summarization model; and the "endless automatic compaction" reports are investigated and attributed to the official engine.
package/PRODUCT.md CHANGED
@@ -20,7 +20,7 @@ Official `agent-presets` stay in the process. A nested `ctx.plugin(AgentPresets)
20
20
 
21
21
  ## Settings and lifecycle
22
22
 
23
- The settings surface aggregates public Settings namespaces, reads and mutates with optimistic revisions, and saves rows independently. Changing a Main or fixed Subagent model applies only the target catalog row's default effort, so an effort never leaks across providers or models. The Subagent card is a RouteCard with an instant on/off for the Default Subagent route; turning it off keeps the last provider/model on disk. Switch compaction is a B-row in the Send protection group (发送保护), default on, written immediately. The Auto review card is a RouteCard with an instant switch defaulting **off = reviews follow the session model**; turning it on seeds the pinned reviewer route from Main and stays editable. The Compaction card mirrors it: default **off = official default = compaction follows the conversation target model**; turning it on seeds the summarization route from Main and stays editable, and the pinned pair drives `/compact` and official automatic compaction alike. Every registration, watcher, and dynamic tool generation has clean disposal. All visible copy lives in zh/en locale dictionaries.
23
+ The settings surface aggregates public Settings namespaces, reads and mutates with optimistic revisions, and saves rows independently. Changing a Main or fixed Subagent model applies only the target catalog row's default effort, so an effort never leaks across providers or models. The Subagent card is a RouteCard with an instant on/off for the Default Subagent route; turning it off keeps the last provider/model on disk. Switch compaction is a B-row in the Send protection group (发送保护), default on, written immediately. The Auto review card is a RouteCard with an instant switch defaulting **off = reviews follow the session model**; turning it on seeds the pinned reviewer route from Main and stays editable. The Compaction card mirrors it: default **off = official default = compaction follows the conversation target model**; turning it on seeds the summarization route from Main and stays editable, and the pinned pair drives `/compact` and official automatic compaction alike. Provider groups store one compaction/subagent/review route group per provider in the shared `model-switch` namespace and apply it as a full replacement whenever the main model's provider changes through either plugin-owned switch surface (the composer picker or a Main card save); the apply rewrites only the existing flat fields (an unpinned capability reverts to its own default, `subagentMode` last), a same-provider switch or a provider without a stored group writes nothing, the effect is profile-wide rather than per session, and a failed apply never fails the switch or the Main save. Search and image routes are out of scope for groups. Every registration, watcher, and dynamic tool generation has clean disposal. All visible copy lives in zh/en locale dictionaries.
24
24
 
25
25
  ## Exclusions
26
26
 
package/README.md CHANGED
@@ -101,6 +101,10 @@ How the route reaches the engine depends on where the deployment runs its agent
101
101
 
102
102
  Known issue (upstream; recorded in `docs/seam-gap.md`): while the working set stays above the official engine's pressure threshold, automatic compaction can fire on every step and keep re-attempting ranges whose summaries fail the engine's own "summary must be smaller" guard — a visible loop of automatic compactions that this plugin does not control (verified in a session A/B with the plugin enabled and disabled). Mitigations on the forwarded row config: `auto: false` (keeps `/compact`), a higher `thresholdRatio`, a smaller mounted tool schema, or a pinned compaction model whose summaries are leaner.
103
103
 
104
+ ## Provider groups
105
+
106
+ The "Provider groups" card stores one route group per provider — the compaction, subagent, and review routes saved together — and applies it automatically whenever the main model's **provider** changes, whether through the composer picker or by saving a different provider on the Main card. Applying a group is one full replacement: all three routes are written into the cards above in a single decision, overwriting their current values **including manual edits**, while anything the group does not pin reverts to that capability's default (compaction and review follow the conversation model, subagent follows main). Switching to a provider without a stored group leaves the current routes untouched, and a switch within one provider never rewrites them. The stored effect is profile-wide (the shared settings namespace), not per session, and only these two switch surfaces trigger it — a TUI or other switch leaves the routes alone. In the editor, a provider's draft seeds from the active routes the first time you configure it, each capability row's off state means "use the capability default" and is stored as such, and **Delete group** removes the provider's group entirely (clearing the stored field when the last group goes). Search and image routes are out of scope. A failed apply never fails the switch or the Main save: the picker logs a warning and commits anyway, and the Main card reports "Main model saved, but applying the provider group failed …" while the routes stay as they were.
107
+
104
108
  ## What Model Switch does not change
105
109
 
106
110
  - `web_fetch` and its configured provider
package/README.zh.md CHANGED
@@ -99,6 +99,10 @@ Plan Review 拥有独立于 Main 的执行模型草稿。**确认执行**会先
99
99
 
100
100
  已知问题(上游侧,见 `docs/seam-gap.md`):当工作集持续超过官方引擎的压力阈值时,自动压缩可能每步触发,并反复重试摘要过不了官方「摘要必须更小」守卫的小片段——表现为"不停自动压缩",本插件不控制该行为(已在同一会话做过插件开/关 A/B 对照)。可用的缓解(都在转发行配置上):`auto: false`(保留 `/compact`)、调高 `thresholdRatio`、减少挂载的工具 schema,或固定一个摘要更精炼的压缩模型。
101
101
 
102
+ ## 供应商配置组(Provider groups)
103
+
104
+ 「供应商配置组」卡片为每个供应商保存一组路由——压缩、子代理、审查三条路由一起存;当**主模型的供应商**发生变化时自动应用,无论变化来自输入框选择器选模型、还是在 Main 卡片上保存成别的供应商。应用是一次完整替换:三条路由一次性写入上方对应卡片,覆盖卡片当前值**(包括手动修改)**;组里没有固定的项回到该能力的默认值(压缩/审查跟随会话模型、子代理跟随主模型)。切换到没有配置组的供应商时现有路由保持不变,同一供应商内部切换不会重写路由。存储是 profile 级的(共享设置命名空间),不按会话生效,且只由这两个切换面触发——TUI 等其他切换不会动路由。编辑器里首次配置某供应商时从当前生效路由种子化;每行能力开关的关闭态表示"用该能力的默认值"并按默认存储;「删除该组」彻底移除该组(删掉最后一组时同时清掉存储字段)。搜索与图片路由不在本功能范围内。应用失败不会让切换或主模型保存失败:选择器侧记录警告并照常完成切换,Main 卡片侧提示"主模型已保存,但供应商组应用失败 …",路由保持原样。
105
+
102
106
  ## Model Switch 不会改变什么
103
107
 
104
108
  - `web_fetch` 及其配置的 provider
package/lib/client.js CHANGED
@@ -80,6 +80,174 @@ window.__ModuleLoader__.load({
80
80
  return route !== void 0 && (route.provider ?? "").trim() !== "" && (route.model ?? "").trim() !== "";
81
81
  }
82
82
  //#endregion
83
+ //#region src/provider-group.ts
84
+ const ROUTE_FIELDS = Object.freeze([
85
+ "compactProvider",
86
+ "compactModel",
87
+ "autoReviewProvider",
88
+ "autoReviewModel",
89
+ "subagentProvider",
90
+ "subagentModel",
91
+ "subagentReasoningEffort"
92
+ ]);
93
+ /** Own data property, so a literal '__proto__' provider id can never touch a prototype. */
94
+ function setKey(target, key, value) {
95
+ Object.defineProperty(target, key, {
96
+ value,
97
+ enumerable: true,
98
+ writable: true,
99
+ configurable: true
100
+ });
101
+ }
102
+ /** A non-blank string. Blank values behave exactly like an absent field. */
103
+ function present(value) {
104
+ return typeof value === "string" && value.trim() !== "";
105
+ }
106
+ /** Normalize one raw entry to its owning provider and route view; undefined for non-object junk. */
107
+ function normalizeEntry(value) {
108
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
109
+ const source = value;
110
+ const group = {};
111
+ for (const field of ROUTE_FIELDS) {
112
+ const item = source[field];
113
+ if (present(item)) group[field] = item;
114
+ }
115
+ return {
116
+ provider: present(source.provider) ? source.provider : "",
117
+ group
118
+ };
119
+ }
120
+ /**
121
+ * Strip a raw stored value to well-formed groups; never throws. Both storage
122
+ * shapes are accepted: the stored entry array (keys live in each entry's
123
+ * `provider` field) and a hand-written provider → routes map (keys are the
124
+ * providers). All-empty entries stay: they pin a provider to every default.
125
+ */
126
+ function normalizeProviderGroups(raw) {
127
+ const groups = {};
128
+ if (Array.isArray(raw)) {
129
+ for (const entry of raw) {
130
+ const view = normalizeEntry(entry);
131
+ if (view === void 0 || view.provider === "") continue;
132
+ setKey(groups, view.provider, view.group);
133
+ }
134
+ return groups;
135
+ }
136
+ if (raw !== null && typeof raw === "object") for (const [key, value] of Object.entries(raw)) {
137
+ if (key.trim() === "") continue;
138
+ const view = normalizeEntry(value);
139
+ if (view === void 0) continue;
140
+ setKey(groups, key, view.group);
141
+ }
142
+ return groups;
143
+ }
144
+ /**
145
+ * The saved group for a provider.
146
+ * @returns the normalized group, or undefined when the provider has no group —
147
+ * which is what leaves the live routes untouched during a switch.
148
+ */
149
+ function groupOf(groups, provider) {
150
+ if (groups === void 0 || provider.trim() === "") return void 0;
151
+ if (!Object.prototype.hasOwnProperty.call(groups, provider)) return void 0;
152
+ return groups[provider];
153
+ }
154
+ /** A complete pinned pair; either half missing leaves the capability at its default. */
155
+ function pair(group, providerField, modelField) {
156
+ const provider = group[providerField];
157
+ const model = group[modelField];
158
+ return present(provider) && present(model) ? {
159
+ provider,
160
+ model
161
+ } : void 0;
162
+ }
163
+ /**
164
+ * Ordered writes applying one group with full-replacement semantics: pinned
165
+ * capabilities land as pairs, everything else reverts to its default state
166
+ * (compaction/review follow the session model; Subagent follows official
167
+ * inherit). The mode write comes last so a fixed route never publishes with
168
+ * the previous provider's fields still half-written.
169
+ */
170
+ function providerGroupWrites(group) {
171
+ const compact = pair(group, "compactProvider", "compactModel");
172
+ const review = pair(group, "autoReviewProvider", "autoReviewModel");
173
+ const subagent = pair(group, "subagentProvider", "subagentModel");
174
+ const effort = subagent !== void 0 && present(group.subagentReasoningEffort) ? group.subagentReasoningEffort : void 0;
175
+ return [
176
+ {
177
+ field: "compactProvider",
178
+ value: compact?.provider
179
+ },
180
+ {
181
+ field: "compactModel",
182
+ value: compact?.model
183
+ },
184
+ {
185
+ field: "autoReviewProvider",
186
+ value: review?.provider
187
+ },
188
+ {
189
+ field: "autoReviewModel",
190
+ value: review?.model
191
+ },
192
+ {
193
+ field: "subagentProvider",
194
+ value: subagent?.provider
195
+ },
196
+ {
197
+ field: "subagentModel",
198
+ value: subagent?.model
199
+ },
200
+ {
201
+ field: "subagentReasoningEffort",
202
+ value: effort
203
+ },
204
+ {
205
+ field: "subagentMode",
206
+ mode: subagent !== void 0 ? "fixed" : "follow-main"
207
+ }
208
+ ];
209
+ }
210
+ /** Storage entry for one provider's group; absent or blank route fields become ''. */
211
+ function entryOf(provider, group) {
212
+ return {
213
+ provider,
214
+ compactProvider: group.compactProvider ?? "",
215
+ compactModel: group.compactModel ?? "",
216
+ autoReviewProvider: group.autoReviewProvider ?? "",
217
+ autoReviewModel: group.autoReviewModel ?? "",
218
+ subagentProvider: group.subagentProvider ?? "",
219
+ subagentModel: group.subagentModel ?? "",
220
+ subagentReasoningEffort: group.subagentReasoningEffort ?? ""
221
+ };
222
+ }
223
+ /**
224
+ * The stored entry list with one provider's group replaced (an entry) or
225
+ * removed (undefined). Every entry is re-normalized and de-duplicated so a
226
+ * save can never write junk back; the input is never mutated. A blank
227
+ * provider only normalizes.
228
+ */
229
+ function withProviderGroup(storage, provider, group) {
230
+ const groups = { ...normalizeProviderGroups(storage) };
231
+ if (provider.trim() !== "") {
232
+ if (group === void 0) delete groups[provider];
233
+ else setKey(groups, provider, group);
234
+ }
235
+ return Object.entries(groups).map(([key, value]) => entryOf(key, value));
236
+ }
237
+ /**
238
+ * Apply a saved group's routes to the live flat settings.
239
+ * @returns true when a group existed and every write settled; false when the
240
+ * provider has no group (nothing was written). A rejected write propagates.
241
+ */
242
+ async function applyProviderGroupTo(sink, groups, provider) {
243
+ const group = groupOf(groups, provider);
244
+ if (group === void 0) return false;
245
+ for (const write of providerGroupWrites(group)) if (write.field === "subagentMode") await sink.set("subagentMode", write.mode);
246
+ else if (write.value === void 0) await sink.unset(write.field);
247
+ else await sink.set(write.field, write.value);
248
+ return true;
249
+ }
250
+ //#endregion
83
251
  //#region src/client/derived-config-form.ts
84
252
  function deriveConfigForm(source, project, fields) {
85
253
  let sourceSnapshot;
@@ -628,7 +796,40 @@ window.__ModuleLoader__.load({
628
796
  function compact(...values) {
629
797
  return values.filter((value) => value !== void 0 && value !== "").join(" · ");
630
798
  }
799
+ /** A complete pinned pair; either half blank leaves the capability at its default. */
800
+ function draftPair(provider, model) {
801
+ return provider !== void 0 && provider.trim() !== "" && model !== void 0 && model.trim() !== "" ? {
802
+ provider,
803
+ model
804
+ } : void 0;
805
+ }
631
806
  function RouteIcon({ kind }) {
807
+ if (kind === "providerGroups") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
808
+ width: "18",
809
+ height: "18",
810
+ viewBox: "0 0 18 18",
811
+ fill: "none",
812
+ "aria-hidden": "true",
813
+ children: [
814
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
815
+ d: "M3 5.5h12M3 12.5h12",
816
+ stroke: "currentColor",
817
+ strokeLinecap: "round"
818
+ }),
819
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
820
+ cx: "6.5",
821
+ cy: "5.5",
822
+ r: "2",
823
+ stroke: "currentColor"
824
+ }),
825
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
826
+ cx: "11.5",
827
+ cy: "12.5",
828
+ r: "2",
829
+ stroke: "currentColor"
830
+ })
831
+ ]
832
+ });
632
833
  if (kind === "compaction") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
633
834
  width: "18",
634
835
  height: "18",
@@ -840,7 +1041,7 @@ window.__ModuleLoader__.load({
840
1041
  })]
841
1042
  });
842
1043
  }
843
- function Actions({ t, busy, disabled, message, onCancel, onSave }) {
1044
+ function Actions({ t, busy, disabled, message, extra, onCancel, onSave }) {
844
1045
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
845
1046
  className: ModelSwitchSettings_module_css_default.cardFooter,
846
1047
  children: [
@@ -848,6 +1049,7 @@ window.__ModuleLoader__.load({
848
1049
  className: cx(ModelSwitchSettings_module_css_default.hint, ModelSwitchSettings_module_css_default.message),
849
1050
  children: message
850
1051
  }),
1052
+ extra,
851
1053
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
852
1054
  type: "button",
853
1055
  className: cx(ModelSwitchSettings_module_css_default.button, ModelSwitchSettings_module_css_default.secondaryButton),
@@ -926,6 +1128,8 @@ window.__ModuleLoader__.load({
926
1128
  const [message, setMessage] = (0, react.useState)();
927
1129
  const [compactBusy, setCompactBusy] = (0, react.useState)(false);
928
1130
  const [compactError, setCompactError] = (0, react.useState)();
1131
+ const [groupProvider, setGroupProvider] = (0, react.useState)("");
1132
+ const [groupDraft, setGroupDraft] = (0, react.useState)(void 0);
929
1133
  const loadSearchCapabilities = props.loadCapabilities;
930
1134
  const [searchSnapshot, setSearchSnapshot] = (0, react.useState)(void 0);
931
1135
  const [searchError, setSearchError] = (0, react.useState)(void 0);
@@ -964,6 +1168,60 @@ window.__ModuleLoader__.load({
964
1168
  if (timer !== void 0) clearTimeout(timer);
965
1169
  };
966
1170
  }, [loadSearchCapabilities]);
1171
+ const providerGroups = normalizeProviderGroups(switchSettings.value?.providerGroups);
1172
+ /** Seed an unsaved group from the live routes, mirroring how the switches seed from Main. */
1173
+ const seedGroupFromActive = () => {
1174
+ const compact = compaction.value !== void 0 && routePairConfigured(compaction.value) ? {
1175
+ provider: compaction.value.provider ?? "",
1176
+ model: compaction.value.model ?? ""
1177
+ } : void 0;
1178
+ const review = autoReview.value !== void 0 && routePairConfigured(autoReview.value) ? {
1179
+ provider: autoReview.value.provider ?? "",
1180
+ model: autoReview.value.model ?? ""
1181
+ } : void 0;
1182
+ const fixedSubagent = subagent.value !== void 0 && subagent.value.mode === "fixed" && routePairConfigured(subagent.value) ? {
1183
+ provider: subagent.value.provider ?? "",
1184
+ model: subagent.value.model ?? "",
1185
+ reasoningEffort: subagent.value.reasoningEffort
1186
+ } : void 0;
1187
+ return {
1188
+ ...compact === void 0 ? {} : {
1189
+ compactProvider: compact.provider,
1190
+ compactModel: compact.model
1191
+ },
1192
+ ...review === void 0 ? {} : {
1193
+ autoReviewProvider: review.provider,
1194
+ autoReviewModel: review.model
1195
+ },
1196
+ ...fixedSubagent === void 0 ? {} : {
1197
+ subagentProvider: fixedSubagent.provider,
1198
+ subagentModel: fixedSubagent.model,
1199
+ ...fixedSubagent.reasoningEffort === void 0 ? {} : { subagentReasoningEffort: fixedSubagent.reasoningEffort }
1200
+ }
1201
+ };
1202
+ };
1203
+ /** Reload the editor draft: the saved group when one exists, else a seed from the live routes. */
1204
+ const resyncGroupDraft = () => {
1205
+ if (groupProvider === "") {
1206
+ setGroupDraft(void 0);
1207
+ return;
1208
+ }
1209
+ setGroupDraft(groupOf(providerGroups, groupProvider) ?? seedGroupFromActive());
1210
+ };
1211
+ (0, react.useEffect)(() => {
1212
+ if (groupProvider !== "") return;
1213
+ const initial = controller.draft?.provider;
1214
+ if (initial !== void 0 && initial.trim() !== "") setGroupProvider(initial);
1215
+ }, [groupProvider, controller.draft]);
1216
+ (0, react.useEffect)(() => {
1217
+ resyncGroupDraft();
1218
+ }, [
1219
+ groupProvider,
1220
+ switchSettings.value,
1221
+ subagent.value,
1222
+ autoReview.value,
1223
+ compaction.value
1224
+ ]);
967
1225
  if ([
968
1226
  controller.main,
969
1227
  controller.subagent,
@@ -1182,6 +1440,83 @@ window.__ModuleLoader__.load({
1182
1440
  const capabilityDisabled = (route, snapshot, next) => snapshot.status !== "ready" || !snapshot.writable || next === void 0 || busy === route || (next.provider ?? "").trim() === "" || (next.model ?? "").trim() === "";
1183
1441
  const autoReviewDisabled = autoReview.status !== "ready" || !autoReview.writable || autoReviewDraft === void 0 || busy === "autoReview" || !autoReviewOn || (autoReviewDraft.provider ?? "").trim() === "" || (autoReviewDraft.model ?? "").trim() === "";
1184
1442
  const compactionDisabled = compaction.status !== "ready" || !compaction.writable || compactionDraft === void 0 || busy === "compaction" || !compactionOn || (compactionDraft.provider ?? "").trim() === "" || (compactionDraft.model ?? "").trim() === "";
1443
+ const configuredGroupCount = Object.keys(providerGroups).length;
1444
+ const groupControlsDisabled = busy === "providerGroups" || switchSettings.status !== "ready" || !switchSettings.writable;
1445
+ const groupProviderChoices = deriveRouteChoices(groups, groupProvider === "" ? void 0 : { provider: groupProvider }).providers.map((choice) => groupOf(providerGroups, choice.id) === void 0 ? choice : {
1446
+ ...choice,
1447
+ name: choice.name + " ✓"
1448
+ });
1449
+ const groupCompaction = groupDraft === void 0 ? void 0 : draftPair(groupDraft.compactProvider, groupDraft.compactModel);
1450
+ const groupReview = groupDraft === void 0 ? void 0 : draftPair(groupDraft.autoReviewProvider, groupDraft.autoReviewModel);
1451
+ const groupSubagent = groupDraft === void 0 ? void 0 : draftPair(groupDraft.subagentProvider, groupDraft.subagentModel);
1452
+ const groupCompactionChoices = deriveRouteChoices(groups, groupCompaction);
1453
+ const groupReviewChoices = deriveRouteChoices(groups, groupReview);
1454
+ const groupSubagentChoices = deriveRouteChoices(groups, groupSubagent);
1455
+ const groupSubMatched = matchCatalogModel(groups.find((group) => group.id === (groupDraft?.subagentProvider ?? ""))?.models ?? [], groupDraft?.subagentModel ?? "");
1456
+ const groupSubEfforts = (() => {
1457
+ const efforts = (groupSubMatched?.model.reasoning?.efforts ?? []).map((effort) => ({
1458
+ id: effort.id,
1459
+ name: effort.name
1460
+ }));
1461
+ const current = groupDraft?.subagentReasoningEffort;
1462
+ if (current !== void 0 && current !== "" && !efforts.some((option) => option.id === current)) efforts.push({
1463
+ id: current,
1464
+ name: current
1465
+ });
1466
+ return efforts;
1467
+ })();
1468
+ /** Enable a capability row by pinning it to the Main route, like the switches above seed from Main. */
1469
+ const enableGroupCapability = (kind) => {
1470
+ setGroupDraft((draft) => {
1471
+ const main = controller.draft;
1472
+ if (draft === void 0 || main === void 0) return draft;
1473
+ if (kind === "compaction") return {
1474
+ ...draft,
1475
+ compactProvider: main.provider,
1476
+ compactModel: main.model
1477
+ };
1478
+ if (kind === "autoReview") return {
1479
+ ...draft,
1480
+ autoReviewProvider: main.provider,
1481
+ autoReviewModel: main.model
1482
+ };
1483
+ return {
1484
+ ...draft,
1485
+ subagentProvider: main.provider,
1486
+ subagentModel: main.model,
1487
+ ...main.reasoningEffort === void 0 ? {} : { subagentReasoningEffort: main.reasoningEffort }
1488
+ };
1489
+ });
1490
+ };
1491
+ const disableGroupCapability = (kind) => {
1492
+ setGroupDraft((draft) => {
1493
+ if (draft === void 0) return draft;
1494
+ if (kind === "compaction") return {
1495
+ ...draft,
1496
+ compactProvider: "",
1497
+ compactModel: ""
1498
+ };
1499
+ if (kind === "autoReview") return {
1500
+ ...draft,
1501
+ autoReviewProvider: "",
1502
+ autoReviewModel: ""
1503
+ };
1504
+ return {
1505
+ ...draft,
1506
+ subagentProvider: "",
1507
+ subagentModel: "",
1508
+ subagentReasoningEffort: ""
1509
+ };
1510
+ });
1511
+ };
1512
+ const saveProviderGroupCard = () => {
1513
+ if (groupProvider === "" || groupDraft === void 0) return;
1514
+ run("providerGroups", () => props.saveProviderGroup(groupProvider, groupDraft));
1515
+ };
1516
+ const deleteProviderGroupCard = () => {
1517
+ if (groupProvider === "") return;
1518
+ run("providerGroups", () => props.saveProviderGroup(groupProvider, void 0));
1519
+ };
1185
1520
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("main", {
1186
1521
  className: ModelSwitchSettings_module_css_default.section,
1187
1522
  children: [
@@ -1681,6 +2016,229 @@ window.__ModuleLoader__.load({
1681
2016
  ] })
1682
2017
  })
1683
2018
  ]
2019
+ }),
2020
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2021
+ className: ModelSwitchSettings_module_css_default.group,
2022
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
2023
+ className: ModelSwitchSettings_module_css_default.groupLabel,
2024
+ children: props.t("providerGroups")
2025
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(RouteCard, {
2026
+ title: props.t("providerGroupsCard"),
2027
+ summary: props.t("providerGroupsSummary"),
2028
+ hint: props.t("providerGroupsHelp"),
2029
+ icon: "providerGroups",
2030
+ open: open === "providerGroups",
2031
+ onToggle: () => {
2032
+ toggle("providerGroups");
2033
+ },
2034
+ ...configuredGroupCount > 0 ? { badge: String(configuredGroupCount) } : {},
2035
+ children: [
2036
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2037
+ className: ModelSwitchSettings_module_css_default.hint,
2038
+ children: props.t("providerGroupsBodyHelp")
2039
+ }),
2040
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2041
+ className: ModelSwitchSettings_module_css_default.formGrid,
2042
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
2043
+ label: props.t("provider"),
2044
+ value: groupProvider,
2045
+ disabled: groupControlsDisabled,
2046
+ choices: groupProviderChoices,
2047
+ onChange: setGroupProvider
2048
+ })
2049
+ }),
2050
+ groupProvider === "" || groupDraft === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2051
+ className: ModelSwitchSettings_module_css_default.hint,
2052
+ children: props.t("providerGroupPick")
2053
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
2054
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2055
+ className: ModelSwitchSettings_module_css_default.rowCard,
2056
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2057
+ className: ModelSwitchSettings_module_css_default.rowCopy,
2058
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2059
+ className: ModelSwitchSettings_module_css_default.rowTitle,
2060
+ children: props.t("compaction")
2061
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2062
+ className: ModelSwitchSettings_module_css_default.hint,
2063
+ children: groupCompaction === void 0 ? props.t("compactionFollow") : routeName(groups, groupCompaction)
2064
+ })]
2065
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InstantSwitch, {
2066
+ checked: groupCompaction !== void 0,
2067
+ disabled: groupControlsDisabled,
2068
+ label: props.t("compaction"),
2069
+ onChange: (value) => {
2070
+ if (value) enableGroupCapability("compaction");
2071
+ else disableGroupCapability("compaction");
2072
+ }
2073
+ })]
2074
+ }),
2075
+ groupCompaction === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2076
+ className: ModelSwitchSettings_module_css_default.formGrid,
2077
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
2078
+ label: props.t("provider"),
2079
+ value: groupCompaction.provider,
2080
+ disabled: groupControlsDisabled,
2081
+ choices: groupCompactionChoices.providers,
2082
+ onChange: (provider) => {
2083
+ const first = groups.find((item) => item.id === provider)?.models[0];
2084
+ setGroupDraft((draft) => draft === void 0 ? draft : {
2085
+ ...draft,
2086
+ compactProvider: provider,
2087
+ compactModel: first?.id ?? ""
2088
+ });
2089
+ }
2090
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
2091
+ label: props.t("model"),
2092
+ value: groupCompaction.model,
2093
+ disabled: groupControlsDisabled,
2094
+ choices: groupCompactionChoices.models,
2095
+ onChange: (model) => {
2096
+ setGroupDraft((draft) => draft === void 0 ? draft : {
2097
+ ...draft,
2098
+ compactModel: model
2099
+ });
2100
+ }
2101
+ })]
2102
+ }),
2103
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2104
+ className: ModelSwitchSettings_module_css_default.rowCard,
2105
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2106
+ className: ModelSwitchSettings_module_css_default.rowCopy,
2107
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2108
+ className: ModelSwitchSettings_module_css_default.rowTitle,
2109
+ children: props.t("autoReview")
2110
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2111
+ className: ModelSwitchSettings_module_css_default.hint,
2112
+ children: groupReview === void 0 ? props.t("autoReviewFollow") : routeName(groups, groupReview)
2113
+ })]
2114
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InstantSwitch, {
2115
+ checked: groupReview !== void 0,
2116
+ disabled: groupControlsDisabled,
2117
+ label: props.t("autoReview"),
2118
+ onChange: (value) => {
2119
+ if (value) enableGroupCapability("autoReview");
2120
+ else disableGroupCapability("autoReview");
2121
+ }
2122
+ })]
2123
+ }),
2124
+ groupReview === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2125
+ className: ModelSwitchSettings_module_css_default.formGrid,
2126
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
2127
+ label: props.t("provider"),
2128
+ value: groupReview.provider,
2129
+ disabled: groupControlsDisabled,
2130
+ choices: groupReviewChoices.providers,
2131
+ onChange: (provider) => {
2132
+ const first = groups.find((item) => item.id === provider)?.models[0];
2133
+ setGroupDraft((draft) => draft === void 0 ? draft : {
2134
+ ...draft,
2135
+ autoReviewProvider: provider,
2136
+ autoReviewModel: first?.id ?? ""
2137
+ });
2138
+ }
2139
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
2140
+ label: props.t("model"),
2141
+ value: groupReview.model,
2142
+ disabled: groupControlsDisabled,
2143
+ choices: groupReviewChoices.models,
2144
+ onChange: (model) => {
2145
+ setGroupDraft((draft) => draft === void 0 ? draft : {
2146
+ ...draft,
2147
+ autoReviewModel: model
2148
+ });
2149
+ }
2150
+ })]
2151
+ }),
2152
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2153
+ className: ModelSwitchSettings_module_css_default.rowCard,
2154
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2155
+ className: ModelSwitchSettings_module_css_default.rowCopy,
2156
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2157
+ className: ModelSwitchSettings_module_css_default.rowTitle,
2158
+ children: props.t("subagent")
2159
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2160
+ className: ModelSwitchSettings_module_css_default.hint,
2161
+ children: groupSubagent === void 0 ? props.t("subagentOff") : routeName(groups, groupSubagent)
2162
+ })]
2163
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InstantSwitch, {
2164
+ checked: groupSubagent !== void 0,
2165
+ disabled: groupControlsDisabled,
2166
+ label: props.t("subagent"),
2167
+ onChange: (value) => {
2168
+ if (value) enableGroupCapability("subagent");
2169
+ else disableGroupCapability("subagent");
2170
+ }
2171
+ })]
2172
+ }),
2173
+ groupSubagent === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2174
+ className: ModelSwitchSettings_module_css_default.formGrid,
2175
+ children: [
2176
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
2177
+ label: props.t("provider"),
2178
+ value: groupSubagent.provider,
2179
+ disabled: groupControlsDisabled,
2180
+ choices: groupSubagentChoices.providers,
2181
+ onChange: (provider) => {
2182
+ const first = groups.find((item) => item.id === provider)?.models[0];
2183
+ const routed = selectRouteModel(groups, provider, first?.id ?? groupSubagent.model);
2184
+ setGroupDraft((draft) => draft === void 0 ? draft : {
2185
+ ...draft,
2186
+ subagentProvider: routed.provider,
2187
+ subagentModel: routed.model,
2188
+ subagentReasoningEffort: routed.reasoningEffort ?? ""
2189
+ });
2190
+ }
2191
+ }),
2192
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
2193
+ label: props.t("model"),
2194
+ value: groupSubagent.model,
2195
+ disabled: groupControlsDisabled,
2196
+ choices: groupSubagentChoices.models,
2197
+ onChange: (model) => {
2198
+ const routed = selectRouteModel(groups, groupSubagent.provider, model);
2199
+ setGroupDraft((draft) => draft === void 0 ? draft : {
2200
+ ...draft,
2201
+ subagentProvider: routed.provider,
2202
+ subagentModel: routed.model,
2203
+ subagentReasoningEffort: routed.reasoningEffort ?? ""
2204
+ });
2205
+ }
2206
+ }),
2207
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
2208
+ label: props.t("effort"),
2209
+ value: groupDraft.subagentReasoningEffort ?? "",
2210
+ disabled: groupControlsDisabled,
2211
+ choices: groupSubEfforts,
2212
+ onChange: (effort) => {
2213
+ setGroupDraft((draft) => draft === void 0 ? draft : {
2214
+ ...draft,
2215
+ subagentReasoningEffort: effort
2216
+ });
2217
+ }
2218
+ })
2219
+ ]
2220
+ }),
2221
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Actions, {
2222
+ t: props.t,
2223
+ busy: busy === "providerGroups",
2224
+ disabled: groupControlsDisabled || groupProvider === "",
2225
+ ...message?.route === "providerGroups" ? { message: message.text } : {},
2226
+ extra: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2227
+ type: "button",
2228
+ className: cx(ModelSwitchSettings_module_css_default.button, ModelSwitchSettings_module_css_default.secondaryButton),
2229
+ disabled: groupControlsDisabled || groupOf(providerGroups, groupProvider) === void 0,
2230
+ onClick: deleteProviderGroupCard,
2231
+ children: props.t("providerGroupDelete")
2232
+ }),
2233
+ onCancel: () => {
2234
+ resyncGroupDraft();
2235
+ setMessage(void 0);
2236
+ },
2237
+ onSave: saveProviderGroupCard
2238
+ })
2239
+ ] })
2240
+ ]
2241
+ })]
1684
2242
  })
1685
2243
  ]
1686
2244
  });
@@ -1731,6 +2289,14 @@ window.__ModuleLoader__.load({
1731
2289
  compactionFollow: "跟随会话模型",
1732
2290
  compactionHelp: "为官方压缩(/compact 与官方自动压缩)指定摘要模型;关闭(两个字段都留空)时使用官方默认,即当前会话的目标模型。",
1733
2291
  compactionRouteHelp: "摘要请求固定使用这里的 Provider/模型,与会话目标模型无关;保存后对下一次压缩立即生效。保存时若恰有压缩进行中,可能被打断并在下次重试。",
2292
+ providerGroups: "供应商配置组",
2293
+ providerGroupsCard: "按供应商预设路由",
2294
+ providerGroupsSummary: "切换主模型供应商时自动应用对应组",
2295
+ providerGroupsHelp: "为压缩、子代理、审查按供应商分别保存路由。",
2296
+ providerGroupsBodyHelp: "每个供应商保存一组压缩、子代理、审查路由;主模型切换到该供应商时,这三条路由自动写入上方对应卡片。切换到未配置的供应商时现有路由保持不变;应用会覆盖三张卡片的当前值(含手动修改)。",
2297
+ providerGroupPick: "选择供应商以编辑其配置组。",
2298
+ providerGroupDelete: "删除该组",
2299
+ providerGroupApplyFailed: "主模型已保存,但供应商组应用失败:",
1734
2300
  "reason.central-subagent-routing": "Alpha.4 没有全局子代理启动路由接口。",
1735
2301
  "reason.packaged-preset-roots": "Alpha.4 不支持插件提供额外 preset root。",
1736
2302
  "reason.tool-owner-suppression": "Alpha.4 没有工具所有者或来源抑制接口。",
@@ -1782,6 +2348,14 @@ window.__ModuleLoader__.load({
1782
2348
  compactionFollow: "Follows the conversation model",
1783
2349
  compactionHelp: "Pick the summarization model for official compaction (/compact and official automatic compaction). When off (both fields empty), the official default applies: the conversation target model.",
1784
2350
  compactionRouteHelp: "Summarization requests use exactly this provider/model regardless of the conversation target; saved changes apply to the very next compaction. Saving while a compaction is running may interrupt it; it retries next time.",
2351
+ providerGroups: "Provider groups",
2352
+ providerGroupsCard: "Per-provider route presets",
2353
+ providerGroupsSummary: "Applied automatically when the main model provider switches",
2354
+ providerGroupsHelp: "Save compaction, subagent, and review routes per provider.",
2355
+ providerGroupsBodyHelp: "Each provider stores one compaction, subagent, and review route group. Switching the main model to that provider writes these three routes into the cards above; switching to a provider without a group leaves the current routes untouched. Applying a group overwrites those three cards, including manual edits.",
2356
+ providerGroupPick: "Pick a provider to edit its group.",
2357
+ providerGroupDelete: "Delete group",
2358
+ providerGroupApplyFailed: "Main model saved, but applying the provider group failed: ",
1785
2359
  "reason.central-subagent-routing": "Alpha.4 exposes no global Subagent start-routing seam.",
1786
2360
  "reason.packaged-preset-roots": "Alpha.4 exposes no plugin-owned preset root.",
1787
2361
  "reason.tool-owner-suppression": "Alpha.4 exposes no tool owner or provenance suppression seam.",
@@ -4230,6 +4804,7 @@ window.__ModuleLoader__.load({
4230
4804
  const models = scope.modelDirectories;
4231
4805
  const sessions = scope.sessions;
4232
4806
  const mainDefaults = scope.configForms.get(MAIN_DEFAULT_CONFIG_ID);
4807
+ const ownedForm = scope.configForms.get(MODEL_SWITCH_CONFIG_ID);
4233
4808
  let directoryService;
4234
4809
  const orderStore = providerOrderStore(scope.configForms.get(PROVIDERS_CONFIG_ID));
4235
4810
  scope.inject(["providerDirectory"], (directoryScope) => {
@@ -4303,6 +4878,11 @@ window.__ModuleLoader__.load({
4303
4878
  return uncommitted;
4304
4879
  }
4305
4880
  if (!result.ok) return uncommitted;
4881
+ if (selection.provider !== currentProvider) try {
4882
+ await applyProviderGroupTo(ownedForm, normalizeProviderGroups(ownedForm.getSnapshot().value?.providerGroups), selection.provider);
4883
+ } catch (error) {
4884
+ console.warn("dsh-model-switch: provider group apply failed", error);
4885
+ }
4306
4886
  return {
4307
4887
  committed: true,
4308
4888
  mainDefaultRestored: (await restoreMainDefault(mainDefaults, defaultBeforeSwitch)).restored
@@ -4415,7 +4995,12 @@ window.__ModuleLoader__.load({
4415
4995
  const image = deriveConfigForm(owned, deriveImageSettings, IMAGE_SETTINGS_FIELDS);
4416
4996
  const autoReview = deriveConfigForm(owned, deriveAutoReviewSettings, AUTO_REVIEW_SETTINGS_FIELDS);
4417
4997
  const compaction = deriveConfigForm(owned, deriveCompactionSettings, COMPACTION_SETTINGS_FIELDS);
4998
+ /** Adopt the target provider's saved group; a provider without a group leaves the routes untouched. */
4999
+ const applyProviderGroupFor = async (provider) => {
5000
+ await applyProviderGroupTo(owned, normalizeProviderGroups(owned.getSnapshot().value?.providerGroups), provider);
5001
+ };
4418
5002
  const saveMain = async (next, expectedRevision) => {
5003
+ const previousProvider = mainForm.getSnapshot().value?.provider;
4419
5004
  if (!await mainForm.mutate([
4420
5005
  {
4421
5006
  op: "set",
@@ -4441,6 +5026,11 @@ window.__ModuleLoader__.load({
4441
5026
  }
4442
5027
  const revision = mainForm.getSnapshot().revision;
4443
5028
  if (revision === void 0) throw new Error("settings unavailable after save");
5029
+ if (next.provider.trim() !== "" && next.provider !== previousProvider) try {
5030
+ await applyProviderGroupFor(next.provider);
5031
+ } catch (error) {
5032
+ throw new Error(t("providerGroupApplyFailed") + (error instanceof Error ? error.message : t("requestFailed")));
5033
+ }
4444
5034
  return revision;
4445
5035
  };
4446
5036
  const connectionRpc = (() => {
@@ -4493,6 +5083,16 @@ window.__ModuleLoader__.load({
4493
5083
  setCompactOnSwitch: async (value) => {
4494
5084
  if (!await owned.set("compactOnSwitch", value)) throw new Error("settings-rejected");
4495
5085
  },
5086
+ saveProviderGroup: async (provider, group) => {
5087
+ const current = owned.getSnapshot().value?.providerGroups;
5088
+ const next = withProviderGroup(current, provider, group);
5089
+ if (next.length === 0) {
5090
+ if (current === void 0) return;
5091
+ if (!await owned.unset("providerGroups")) throw new Error("settings-rejected");
5092
+ return;
5093
+ }
5094
+ if (!await owned.set("providerGroups", next)) throw new Error("settings-rejected");
5095
+ },
4496
5096
  providerRoleOf,
4497
5097
  loadCatalog: async () => {
4498
5098
  const response = await remote.session.modelCatalog();
package/lib/index.js CHANGED
@@ -12,7 +12,83 @@ import { dirname, join } from "node:path";
12
12
  import { renderPrompt } from "@deepseek-ai/dsh-system-prompt";
13
13
  import { boundContextSummary, createMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
14
14
  import { canonicalHeader } from "@deepseek-ai/dsh-session";
15
+ //#region lib/types/provider-group.js
16
+ /**
17
+ * Provider configuration groups: per-provider presets for the compaction,
18
+ * subagent, and auto-review routes.
19
+ *
20
+ * A group stores the exact flat settings fields it will write, so applying a
21
+ * group is a plain settings-layer write: every capability consumer keeps
22
+ * reading the live values unchanged. All functions here are pure and never
23
+ * throw on malformed storage.
24
+ */
25
+ const ROUTE_FIELDS = Object.freeze([
26
+ "compactProvider",
27
+ "compactModel",
28
+ "autoReviewProvider",
29
+ "autoReviewModel",
30
+ "subagentProvider",
31
+ "subagentModel",
32
+ "subagentReasoningEffort"
33
+ ]);
34
+ /** Own data property, so a literal '__proto__' provider id can never touch a prototype. */
35
+ function setKey(target, key, value) {
36
+ Object.defineProperty(target, key, {
37
+ value,
38
+ enumerable: true,
39
+ writable: true,
40
+ configurable: true
41
+ });
42
+ }
43
+ /** A non-blank string. Blank values behave exactly like an absent field. */
44
+ function present(value) {
45
+ return typeof value === "string" && value.trim() !== "";
46
+ }
47
+ /** Normalize one raw entry to its owning provider and route view; undefined for non-object junk. */
48
+ function normalizeEntry(value) {
49
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
50
+ const source = value;
51
+ const group = {};
52
+ for (const field of ROUTE_FIELDS) {
53
+ const item = source[field];
54
+ if (present(item)) group[field] = item;
55
+ }
56
+ return {
57
+ provider: present(source.provider) ? source.provider : "",
58
+ group
59
+ };
60
+ }
61
+ /**
62
+ * Strip a raw stored value to well-formed groups; never throws. Both storage
63
+ * shapes are accepted: the stored entry array (keys live in each entry's
64
+ * `provider` field) and a hand-written provider → routes map (keys are the
65
+ * providers). All-empty entries stay: they pin a provider to every default.
66
+ */
67
+ function normalizeProviderGroups(raw) {
68
+ const groups = {};
69
+ if (Array.isArray(raw)) {
70
+ for (const entry of raw) {
71
+ const view = normalizeEntry(entry);
72
+ if (view === void 0 || view.provider === "") continue;
73
+ setKey(groups, view.provider, view.group);
74
+ }
75
+ return groups;
76
+ }
77
+ if (raw !== null && typeof raw === "object") for (const [key, value] of Object.entries(raw)) {
78
+ if (key.trim() === "") continue;
79
+ const view = normalizeEntry(value);
80
+ if (view === void 0) continue;
81
+ setKey(groups, key, view.group);
82
+ }
83
+ return groups;
84
+ }
85
+ //#endregion
15
86
  //#region lib/types/host-settings.js
87
+ /**
88
+ * Annotated rather than inferred: the nested entry schema's inferred type
89
+ * expands to cosmokit internals (`Dict`) that cannot be named in the emitted
90
+ * declaration, and the annotation pins the output shape to this interface.
91
+ */
16
92
  const Config = z.object({
17
93
  subagentMode: z.union(["follow-main", "fixed"]).default("follow-main").volatile(),
18
94
  subagentProvider: z.string().volatile(),
@@ -26,7 +102,17 @@ const Config = z.object({
26
102
  autoReviewProvider: z.string().volatile(),
27
103
  autoReviewModel: z.string().volatile(),
28
104
  compactProvider: z.string().volatile(),
29
- compactModel: z.string().volatile()
105
+ compactModel: z.string().volatile(),
106
+ providerGroups: z.array(z.object({
107
+ provider: z.string().default(""),
108
+ compactProvider: z.string().default(""),
109
+ compactModel: z.string().default(""),
110
+ autoReviewProvider: z.string().default(""),
111
+ autoReviewModel: z.string().default(""),
112
+ subagentProvider: z.string().default(""),
113
+ subagentModel: z.string().default(""),
114
+ subagentReasoningEffort: z.string().default("")
115
+ })).default([]).volatile()
30
116
  });
31
117
  function readConfig(config) {
32
118
  const subagentProvider = config.subagentProvider.get();
@@ -40,6 +126,7 @@ function readConfig(config) {
40
126
  const autoReviewModel = config.autoReviewModel.get();
41
127
  const compactProvider = config.compactProvider.get();
42
128
  const compactModel = config.compactModel.get();
129
+ const providerGroups = normalizeProviderGroups(config.providerGroups.get());
43
130
  return {
44
131
  subagentMode: config.subagentMode.get(),
45
132
  ...subagentProvider === void 0 ? {} : { subagentProvider },
@@ -53,7 +140,8 @@ function readConfig(config) {
53
140
  ...autoReviewProvider === void 0 || autoReviewProvider === "" ? {} : { autoReviewProvider },
54
141
  ...autoReviewModel === void 0 || autoReviewModel === "" ? {} : { autoReviewModel },
55
142
  ...compactProvider === void 0 || compactProvider === "" ? {} : { compactProvider },
56
- ...compactModel === void 0 || compactModel === "" ? {} : { compactModel }
143
+ ...compactModel === void 0 || compactModel === "" ? {} : { compactModel },
144
+ ...Object.keys(providerGroups).length === 0 ? {} : { providerGroups }
57
145
  };
58
146
  }
59
147
  //#endregion
@@ -3,6 +3,7 @@ import type { ModelProviderGroup } from '@deepseek-ai/dsh-api-session-controller
3
3
  import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client';
4
4
  import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
5
5
  import { type CapabilityRouteView, type MainSettingsView, type ModelSwitchSettingsView, type SubagentSettingsView } from '../client-contract.js';
6
+ import { type ProviderGroupConfig } from '../provider-group.js';
6
7
  import type { RuntimeCapabilities } from '../runtime-capabilities.js';
7
8
  import { type CapabilitiesSnapshot } from './search-capabilities.js';
8
9
  import type { ModelSwitchLocaleKey } from './locales.js';
@@ -23,6 +24,8 @@ export interface ModelSwitchSettingsFace {
23
24
  setSubagent: (field: 'mode' | 'provider' | 'model' | 'reasoningEffort', value: string | undefined) => Promise<void>;
24
25
  setCapability: (route: 'search' | 'image' | 'autoReview' | 'compaction', field: 'provider' | 'model', value: string | undefined) => Promise<void>;
25
26
  setCompactOnSwitch: (value: boolean) => Promise<void>;
27
+ /** Replace or remove (undefined) one provider's saved capability group. */
28
+ saveProviderGroup: (provider: string, group: ProviderGroupConfig | undefined) => Promise<void>;
26
29
  loadCatalog: () => Promise<readonly ModelProviderGroup[]>;
27
30
  /** Host capabilities long-poll; absent in legacy faces, which keep the static-capabilities path. */
28
31
  loadCapabilities?: (revision?: number, signal?: AbortSignal) => Promise<CapabilitiesSnapshot>;
@@ -42,6 +42,14 @@ export declare const zh: {
42
42
  readonly compactionFollow: "跟随会话模型";
43
43
  readonly compactionHelp: "为官方压缩(/compact 与官方自动压缩)指定摘要模型;关闭(两个字段都留空)时使用官方默认,即当前会话的目标模型。";
44
44
  readonly compactionRouteHelp: "摘要请求固定使用这里的 Provider/模型,与会话目标模型无关;保存后对下一次压缩立即生效。保存时若恰有压缩进行中,可能被打断并在下次重试。";
45
+ readonly providerGroups: "供应商配置组";
46
+ readonly providerGroupsCard: "按供应商预设路由";
47
+ readonly providerGroupsSummary: "切换主模型供应商时自动应用对应组";
48
+ readonly providerGroupsHelp: "为压缩、子代理、审查按供应商分别保存路由。";
49
+ readonly providerGroupsBodyHelp: "每个供应商保存一组压缩、子代理、审查路由;主模型切换到该供应商时,这三条路由自动写入上方对应卡片。切换到未配置的供应商时现有路由保持不变;应用会覆盖三张卡片的当前值(含手动修改)。";
50
+ readonly providerGroupPick: "选择供应商以编辑其配置组。";
51
+ readonly providerGroupDelete: "删除该组";
52
+ readonly providerGroupApplyFailed: "主模型已保存,但供应商组应用失败:";
45
53
  readonly 'reason.central-subagent-routing': "Alpha.4 没有全局子代理启动路由接口。";
46
54
  readonly 'reason.packaged-preset-roots': "Alpha.4 不支持插件提供额外 preset root。";
47
55
  readonly 'reason.tool-owner-suppression': "Alpha.4 没有工具所有者或来源抑制接口。";
@@ -1,3 +1,4 @@
1
+ import type { ProviderGroupsStorage } from './provider-group.js';
1
2
  export declare const MODEL_SWITCH_CONFIG_ID = "model-switch";
2
3
  export declare const MAIN_DEFAULT_CONFIG_ID = "agent-default-model";
3
4
  export declare const PROVIDERS_CONFIG_ID = "llm-providers-ui";
@@ -23,6 +24,8 @@ export interface ModelSwitchSettingsView {
23
24
  autoReviewModel?: string;
24
25
  compactProvider?: string;
25
26
  compactModel?: string;
27
+ /** Raw saved per-provider groups; edited whole through the provider-groups card. */
28
+ providerGroups?: ProviderGroupsStorage;
26
29
  }
27
30
  export interface SubagentSettingsView {
28
31
  mode: 'follow-main' | 'fixed';
@@ -1,7 +1,7 @@
1
1
  import { Context, Service } from '@deepseek-ai/cordis';
2
2
  import type { ModelSelection } from '@deepseek-ai/dsh-agent';
3
3
  import type { AgentDefaultModelConfig } from '@deepseek-ai/dsh-agent-default-model';
4
- import { type Config as ModelSwitchSettingsConfig, type ModelSwitchSettings } from './host-settings.js';
4
+ import { Config, type Config as ModelSwitchSettingsConfig, type ModelSwitchSettings } from './host-settings.js';
5
5
  import { ModelSwitchAdapterRegistry } from './adapter-registry.js';
6
6
  declare module '@deepseek-ai/cordis' {
7
7
  interface Context {
@@ -15,35 +15,7 @@ declare module '@deepseek-ai/cordis' {
15
15
  /** Host owner for Model Switch settings and the released Main-default adapter. */
16
16
  export declare class ModelSwitchRuntime extends Service {
17
17
  static inject: string[];
18
- static Config: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
19
- subagentMode: import("@deepseek-ai/schemastery").default<"follow-main" | "fixed", "follow-main" | "fixed", "volatile-defined">;
20
- subagentProvider: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
21
- subagentModel: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
22
- subagentReasoningEffort: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
23
- searchProvider: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
24
- searchModel: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
25
- imageProvider: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
26
- imageModel: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
27
- compactOnSwitch: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
28
- autoReviewProvider: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
29
- autoReviewModel: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
30
- compactProvider: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
31
- compactModel: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
32
- }>>, Schemastery.ObjectT<NoInfer<{
33
- subagentMode: import("@deepseek-ai/schemastery").default<"follow-main" | "fixed", "follow-main" | "fixed", "volatile-defined">;
34
- subagentProvider: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
35
- subagentModel: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
36
- subagentReasoningEffort: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
37
- searchProvider: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
38
- searchModel: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
39
- imageProvider: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
40
- imageModel: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
41
- compactOnSwitch: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
42
- autoReviewProvider: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
43
- autoReviewModel: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
44
- compactProvider: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
45
- compactModel: import("@deepseek-ai/schemastery").default<string, string, "volatile">;
46
- }>>, "plain">;
18
+ static Config: import("@deepseek-ai/schemastery").default<any, Config, "plain">;
47
19
  get capabilities(): {
48
20
  searchProviderAdapters: {
49
21
  available: boolean;
@@ -1,5 +1,6 @@
1
1
  import type { Volatile } from '@deepseek-ai/cordis';
2
2
  import z from '@deepseek-ai/schemastery';
3
+ import { type ProviderGroupEntry, type ProviderGroups } from './provider-group.js';
3
4
  export type SubagentMode = 'follow-main' | 'fixed';
4
5
  /** Resolved live values consumed by Model Switch. */
5
6
  export interface ModelSwitchSettings {
@@ -20,6 +21,8 @@ export interface ModelSwitchSettings {
20
21
  compactProvider?: string;
21
22
  /** Summarization model for `/compact` and automatic compaction; set together with `compactProvider`. */
22
23
  compactModel?: string;
24
+ /** Per-provider compaction/subagent/review presets, keyed by provider id; absent when no group is saved. */
25
+ providerGroups?: ProviderGroups;
23
26
  }
24
27
  /** Loader-owned references; every field is directly editable in the profile form. */
25
28
  export interface Config {
@@ -36,34 +39,13 @@ export interface Config {
36
39
  autoReviewModel: Volatile<string | undefined>;
37
40
  compactProvider: Volatile<string | undefined>;
38
41
  compactModel: Volatile<string | undefined>;
42
+ /** Saved per-provider capability groups as one entry per provider; entry route fields are '' when the capability stays at its default. */
43
+ providerGroups: Volatile<readonly ProviderGroupEntry[]>;
39
44
  }
40
- export declare const Config: z<Schemastery.ObjectS<NoInfer<{
41
- subagentMode: z<"follow-main" | "fixed", "follow-main" | "fixed", "volatile-defined">;
42
- subagentProvider: z<string, string, "volatile">;
43
- subagentModel: z<string, string, "volatile">;
44
- subagentReasoningEffort: z<string, string, "volatile">;
45
- searchProvider: z<string, string, "volatile">;
46
- searchModel: z<string, string, "volatile">;
47
- imageProvider: z<string, string, "volatile">;
48
- imageModel: z<string, string, "volatile">;
49
- compactOnSwitch: z<boolean, boolean, "volatile-defined">;
50
- autoReviewProvider: z<string, string, "volatile">;
51
- autoReviewModel: z<string, string, "volatile">;
52
- compactProvider: z<string, string, "volatile">;
53
- compactModel: z<string, string, "volatile">;
54
- }>>, Schemastery.ObjectT<NoInfer<{
55
- subagentMode: z<"follow-main" | "fixed", "follow-main" | "fixed", "volatile-defined">;
56
- subagentProvider: z<string, string, "volatile">;
57
- subagentModel: z<string, string, "volatile">;
58
- subagentReasoningEffort: z<string, string, "volatile">;
59
- searchProvider: z<string, string, "volatile">;
60
- searchModel: z<string, string, "volatile">;
61
- imageProvider: z<string, string, "volatile">;
62
- imageModel: z<string, string, "volatile">;
63
- compactOnSwitch: z<boolean, boolean, "volatile-defined">;
64
- autoReviewProvider: z<string, string, "volatile">;
65
- autoReviewModel: z<string, string, "volatile">;
66
- compactProvider: z<string, string, "volatile">;
67
- compactModel: z<string, string, "volatile">;
68
- }>>, "plain">;
45
+ /**
46
+ * Annotated rather than inferred: the nested entry schema's inferred type
47
+ * expands to cosmokit internals (`Dict`) that cannot be named in the emitted
48
+ * declaration, and the annotation pins the output shape to this interface.
49
+ */
50
+ export declare const Config: z<any, Config, 'plain'>;
69
51
  export declare function readConfig(config: Config): ModelSwitchSettings;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Provider configuration groups: per-provider presets for the compaction,
3
+ * subagent, and auto-review routes.
4
+ *
5
+ * A group stores the exact flat settings fields it will write, so applying a
6
+ * group is a plain settings-layer write: every capability consumer keeps
7
+ * reading the live values unchanged. All functions here are pure and never
8
+ * throw on malformed storage.
9
+ */
10
+ import type { SubagentMode } from './host-settings.js';
11
+ /** Flat settings fields a provider group can pin — exactly what an application writes. */
12
+ export type ProviderGroupRouteField = 'compactProvider' | 'compactModel' | 'autoReviewProvider' | 'autoReviewModel' | 'subagentProvider' | 'subagentModel' | 'subagentReasoningEffort';
13
+ /** Stored entry: the owning provider plus every route field; '' means the capability stays at its default. */
14
+ export interface ProviderGroupEntry {
15
+ provider: string;
16
+ compactProvider: string;
17
+ compactModel: string;
18
+ autoReviewProvider: string;
19
+ autoReviewModel: string;
20
+ subagentProvider: string;
21
+ subagentModel: string;
22
+ subagentReasoningEffort: string;
23
+ }
24
+ /** Normalized view of one provider's group; absent fields follow their default state. */
25
+ export type ProviderGroupConfig = Partial<Record<ProviderGroupRouteField, string>>;
26
+ /** Normalized provider → group map. */
27
+ export type ProviderGroups = Readonly<Record<string, ProviderGroupConfig>>;
28
+ /** Storage shape of the volatile `providerGroups` config field: one entry per provider. */
29
+ export type ProviderGroupsStorage = readonly ProviderGroupEntry[];
30
+ /**
31
+ * Strip a raw stored value to well-formed groups; never throws. Both storage
32
+ * shapes are accepted: the stored entry array (keys live in each entry's
33
+ * `provider` field) and a hand-written provider → routes map (keys are the
34
+ * providers). All-empty entries stay: they pin a provider to every default.
35
+ */
36
+ export declare function normalizeProviderGroups(raw: unknown): ProviderGroups;
37
+ /**
38
+ * The saved group for a provider.
39
+ * @returns the normalized group, or undefined when the provider has no group —
40
+ * which is what leaves the live routes untouched during a switch.
41
+ */
42
+ export declare function groupOf(groups: ProviderGroups | undefined, provider: string): ProviderGroupConfig | undefined;
43
+ /** One application write; `value` undefined means "unset this field". */
44
+ export type ProviderGroupWrite = {
45
+ readonly field: 'subagentMode';
46
+ readonly mode: SubagentMode;
47
+ } | {
48
+ readonly field: ProviderGroupRouteField;
49
+ readonly value: string | undefined;
50
+ };
51
+ /**
52
+ * Ordered writes applying one group with full-replacement semantics: pinned
53
+ * capabilities land as pairs, everything else reverts to its default state
54
+ * (compaction/review follow the session model; Subagent follows official
55
+ * inherit). The mode write comes last so a fixed route never publishes with
56
+ * the previous provider's fields still half-written.
57
+ */
58
+ export declare function providerGroupWrites(group: ProviderGroupConfig): ProviderGroupWrite[];
59
+ /** Storage entry for one provider's group; absent or blank route fields become ''. */
60
+ export declare function entryOf(provider: string, group: ProviderGroupConfig): ProviderGroupEntry;
61
+ /**
62
+ * The stored entry list with one provider's group replaced (an entry) or
63
+ * removed (undefined). Every entry is re-normalized and de-duplicated so a
64
+ * save can never write junk back; the input is never mutated. A blank
65
+ * provider only normalizes.
66
+ */
67
+ export declare function withProviderGroup(storage: unknown, provider: string, group: ProviderGroupConfig | undefined): ProviderGroupsStorage;
68
+ /** Minimal writable settings surface an application needs; satisfied by the owned ConfigForm. */
69
+ export interface ProviderGroupSink {
70
+ set(field: 'subagentMode' | ProviderGroupRouteField, value: string): Promise<unknown>;
71
+ unset(field: ProviderGroupRouteField): Promise<unknown>;
72
+ }
73
+ /**
74
+ * Apply a saved group's routes to the live flat settings.
75
+ * @returns true when a group existed and every write settled; false when the
76
+ * provider has no group (nothing was written). A rejected write propagates.
77
+ */
78
+ export declare function applyProviderGroupTo(sink: ProviderGroupSink, groups: ProviderGroups | undefined, provider: string): Promise<boolean>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-switch",
3
- "version": "0.4.21",
3
+ "version": "0.4.22",
4
4
  "description": "Explicit model routing plus composer/Plan picker for DeepSeek Harness",
5
5
  "repository": {
6
6
  "type": "git",