@gehennawu/dsh-service 2.0.7 → 2.0.8

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.
@@ -0,0 +1,1740 @@
1
+ // Host routes: model-configuration workbench RPC surface (models2-*).
2
+ //
3
+ // 装配层只做三件事:**收窄输入**(客户端只能回传宿主签发的句柄与 sid)、**门禁与稳定错误码**、
4
+ // **把领域调用接到共享工具**。领域逻辑一律在 `model-workbench.js`(可独立单测的纯函数与深模块)。
5
+ //
6
+ // 与 `index.js` 其它 `*-routes.js` 同一形状:工厂返回 `{ endpoint: { feature, handle } }`,
7
+ // 由 `mergeRouteTables` 汇总;重复端点键立即抛错(拆分回归保护)。
8
+ //
9
+ // 端点清单(全部 feature 门 `modelCatalog`):
10
+ // 只读:models2-snapshot / models2-route
11
+ // 检查:models2-inspect
12
+ // 建议与写入:models2-prepare / models2-commit
13
+ // 撤销:models2-undo-preview / models2-undo-commit
14
+ // 新建(补偿式):models2-create-begin / models2-create-commit / models2-recovery-resolve
15
+ // 验证:models2-validate
16
+ // 编辑器与移除守卫:models2-edit-prepare / models2-remove-preview / models2-remove-plan / models2-remove-commit
17
+ //
18
+ // 句柄纪律:`inspectId` / `planId` / `draftId` 由宿主签发、内存保存、取出即消费(一次性)。
19
+ // 客户端无法伪造取值——`models2-prepare` 只回放服务端自己的建议清单,`applySuggestions`
20
+ // 对未知 sid 整体拒绝。计划携带 baseFingerprint,提交前复检,任何漂移返回 `conflict`。
21
+
22
+ import { modelSettingsFor } from './model-workbench-settings.js'
23
+ import {
24
+ MAX_MODEL_ID_LENGTH,
25
+ MAX_MODELS_KEY_LENGTH,
26
+ MODELS_KEY_REF_RE,
27
+ MODELS_PROBE_PROTOCOLS,
28
+ MODELS_PROTOCOLS,
29
+ MODELS_ROUTE_RE,
30
+ MODEL_ID_RE,
31
+ WORKBENCH_PLAN_TTL_MS,
32
+ applySuggestions,
33
+ applyUndo,
34
+ arbitrateModelSuggestions,
35
+ buildRouteCreateProfile,
36
+ consensusFields,
37
+ connectionPatch,
38
+ connectionSnapshotOf,
39
+ connectionUndoOps,
40
+ undoScopeOf,
41
+ routeRestoreValue,
42
+ createFreshnessManager,
43
+ deriveKeyRefFor,
44
+ diffModelLists,
45
+ fetchModelsProbe,
46
+ hostnameOf,
47
+ loadModelParamsSnapshot,
48
+ abilityPatch,
49
+ fetchDocText,
50
+ openRouterModelFacts,
51
+ openRouterVerifySource,
52
+ pickOpenRouterMatch,
53
+ piRecordsForRoute,
54
+ probeBaseURLCheck,
55
+ simpleModelObserved,
56
+ simpleModelOption,
57
+ modelMetaCandidates,
58
+ modelParamsCandidates,
59
+ profileFingerprint,
60
+ readLlmProfiles,
61
+ refreshModelParamsSnapshot,
62
+ rememberHandle,
63
+ resolveLlmProfileBaseURL,
64
+ selfCheckModelsPatch,
65
+ suggestRouteId,
66
+ takeHandle,
67
+ undoPreviewModels,
68
+ planIdFor,
69
+ } from './model-workbench.js'
70
+
71
+ /** 幂等目录缓存:目录随 DSH 升级才变,进程内一次足够。 */
72
+ function createPiCatalogCache(loadCatalog) {
73
+ let promise = null
74
+ return () => {
75
+ if (promise === null) {
76
+ promise = loadCatalog().catch(() => {
77
+ promise = null
78
+ throw new Error('catalog-unavailable')
79
+ })
80
+ }
81
+ return promise
82
+ }
83
+ }
84
+
85
+ export function createModelWorkbenchRoutes({
86
+ ctx,
87
+ randomUUID,
88
+ loadPiCatalog,
89
+ workbenchStore,
90
+ loadSubagentRoute,
91
+ loadAgentDefaultModelSelection,
92
+ writeSubagentRoute,
93
+ listSubagentModels,
94
+ dshHome,
95
+ pluginVersion = 'unknown',
96
+ }) {
97
+ const ensurePiCatalog = createPiCatalogCache(loadPiCatalog)
98
+
99
+ // 内存句柄表:检查 / 计划 / 新建草稿。上限与 TTL 在 model-workbench.js 定稿。
100
+ const inspections = new Map()
101
+ const plans = new Map()
102
+ const drafts = new Map()
103
+ // 简易添加流的句柄表:目录/在线候选、运行时检测、模型添加计划。
104
+ const optionHandles = new Map()
105
+ const detections = new Map()
106
+ const modelPlans = new Map()
107
+
108
+ // P3.5 补充源状态(modelparams.dev):快照式、动作驱动、零网络默认。
109
+ // 状态挂在工厂闭包(随 Fiber 销毁),退避标志由新鲜度管理器写入。
110
+ const modelParamsState = { snapshot: null, lastError: null, lastAttemptAt: 0 }
111
+ const modelParamsReady = dshHome === undefined
112
+ ? Promise.resolve(null)
113
+ : loadModelParamsSnapshot(dshHome).then((snapshot) => {
114
+ if (snapshot !== null) modelParamsState.snapshot = snapshot
115
+ return snapshot
116
+ })
117
+ const modelParamsFresh = createFreshnessManager(
118
+ () => modelParamsState,
119
+ () => refreshModelParamsSnapshot(dshHome, modelParamsState, { userAgent: `dsh-service/${pluginVersion} (DeepSeek Harness plugin)` }),
120
+ )
121
+
122
+ /**
123
+ * 客户端回传指纹的复检基线。
124
+ *
125
+ * 坑(v1.11 实测):客户端能拿到的 profile 是 `models2-route` 的**归一投影**
126
+ * (`readLlmProfiles` 补默认、裁类型),而 `profileFingerprint` 是对原始对象做的
127
+ * 规范化哈希——直接拿客户端算的指纹比对必然 mismatch(地址编辑永远 conflict)。
128
+ * 所以对外只下发**本函数算出的**指纹(`models2-route` / 各预览端点),客户端原样回传;
129
+ * 客户端绝不自算指纹,服务端也不接受自造值。
130
+ */
131
+ const routeFingerprint = (profile) => profileFingerprint(profile)
132
+
133
+ /**
134
+ * 目录候选(含 P3.5 补充源),按优先级排序:同渠道 pi-ai → 跨渠道 pi-ai → modelparams
135
+ * (补充源只表态 reasoningEfforts 与输出上限,绝不参与 contextWindow/input 仲裁)。
136
+ */
137
+ const orderedCandidates = (piIndex, route, modelId) => {
138
+ const candidates = modelMetaCandidates(piIndex, modelId)
139
+ return [
140
+ ...candidates.filter((candidate) => candidate.source === 'pi-ai' && candidate.providerId === route),
141
+ ...candidates.filter((candidate) => candidate.source === 'pi-ai' && candidate.providerId !== route),
142
+ ...modelParamsCandidates(modelParamsState.snapshot?.catalog, modelId),
143
+ ]
144
+ }
145
+
146
+ /** 稳定错误码 → RPC 失败(`details.detail` 承载补充数据,客户端 wrapper 归一回字符串)。 */
147
+ const fail = (message, detail) => ({ ok: false, error: detail === undefined ? message : `${message}: ${String(detail)}` })
148
+
149
+ const profileFor = (route, settings) => readLlmProfiles(settings).find((candidate) => candidate.route === route)
150
+
151
+ /**
152
+ * 读 `llm-pi-ai` 命名空间的当前 revision(settings 服务 `describe()` 的 descriptor 字段)。
153
+ * 0.1.6-alpha.2 的 `mutate(ns, ops, expectedRevision)` **真正实现**了该参数:不匹配即抛
154
+ * `SettingsConflictError`(`code: 'SETTINGS_CONFLICT'`)。
155
+ *
156
+ * 为什么不只用 profile 指纹:指纹只能证明「我读到的内容」没变,挡不住「检查之后、
157
+ * 写入之前」这段窗口里的并发写(TOCTOU)。expectedRevision 由服务端在**写队列内部、
158
+ * 落盘之前**比对,把这段窗口关掉;两者互补——指纹还能覆盖 revision 未变但结构漂移的
159
+ * 情形(如外部手改同值),revision 覆盖并发写。
160
+ *
161
+ * 服务缺席/未实现 describe/revision 时返回 undefined:调用方退回指纹单闸,
162
+ * 不因老宿主缺能力而拒绝写入。
163
+ */
164
+ const settingsRevisionOf = (settings, ns = 'llm-pi-ai') => {
165
+ try {
166
+ if (settings === undefined || typeof settings.describe !== 'function') return undefined
167
+ const descriptors = settings.describe()
168
+ if (!Array.isArray(descriptors)) return undefined
169
+ const found = descriptors.find((descriptor) => descriptor?.ns === ns)
170
+ return typeof found?.revision === 'number' && Number.isFinite(found.revision) ? found.revision : undefined
171
+ } catch (_) {
172
+ return undefined
173
+ }
174
+ }
175
+
176
+ /** 并发写被服务端拒绝(expectedRevision 不匹配)→ 统一映射为工作台的 conflict 语义。 */
177
+ const isSettingsConflict = (error) => error?.code === 'SETTINGS_CONFLICT' || error?.name === 'SettingsConflictError'
178
+
179
+ /**
180
+ * 写某路由的模型数组:优先带 expectedRevision(服务端在写队列内、落盘前比对,
181
+ * 关掉「检查 → 写入」之间的并发写窗口)。
182
+ *
183
+ * `expectedRevision` 为 undefined(老宿主无 describe/revision)时退化为不带该参数的
184
+ * 普通写——指纹闸仍在调用方生效,绝不因宿主缺能力而拒绝写入。
185
+ */
186
+ const mutateModels = (settings, route, models, expectedRevision) => settings.mutate(
187
+ 'llm-pi-ai',
188
+ [{ op: 'set', path: ['providers', route, 'models'], value: models }],
189
+ expectedRevision,
190
+ )
191
+
192
+ /** 凭据解析(仅本机凭据库,不发网);未配置/解析失败返回 undefined。 */
193
+ const resolveCredential = async (profile) => {
194
+ if (profile?.apiKeyEnv === '' || !MODELS_KEY_REF_RE.test(profile?.apiKeyEnv ?? '')) return undefined
195
+ const credentials = ctx.get('credentials')
196
+ if (credentials === undefined || typeof credentials.resolve !== 'function') return undefined
197
+ try {
198
+ const resolved = await credentials.resolve(profile.apiKeyEnv)
199
+ return typeof resolved?.value === 'string' && resolved.value !== '' ? resolved.value : undefined
200
+ } catch (_) {
201
+ return undefined
202
+ }
203
+ }
204
+
205
+ /** 凭据状态:none(未引用)/ invalid-ref(文法非法)/ ok / dangling(引用悬空)。 */
206
+ const credentialStateOf = async (profile) => {
207
+ if (profile.apiKeyEnv === '') return 'none'
208
+ if (!MODELS_KEY_REF_RE.test(profile.apiKeyEnv)) return 'invalid-ref'
209
+ const credentials = ctx.get('credentials')
210
+ if (credentials === undefined || typeof credentials.resolve !== 'function') return 'dangling'
211
+ try {
212
+ const resolved = await credentials.resolve(profile.apiKeyEnv)
213
+ return typeof resolved?.value === 'string' && resolved.value !== '' ? 'ok' : 'dangling'
214
+ } catch (_) {
215
+ return 'dangling'
216
+ }
217
+ }
218
+
219
+ /**
220
+ * 移除引用的共享探测(移除守卫):子代理路由(可迁移)与 agent-default-model(硬阻断)。
221
+ * 只读宿主侧配置与 llm 服务,不接受浏览器输入。
222
+ */
223
+ const inspectRemoveReferences = async (route) => {
224
+ const references = []
225
+ try {
226
+ const config = await loadSubagentRoute()
227
+ if (config?.mode === 'custom' && config.provider === route && typeof config.model === 'string') {
228
+ references.push({ kind: 'subagent-route', provider: config.provider, model: config.model })
229
+ }
230
+ } catch (_) {}
231
+ try {
232
+ const selection = loadAgentDefaultModelSelection?.()
233
+ if (typeof selection?.provider === 'string' && selection.provider === route && typeof selection.model === 'string') {
234
+ references.push({ kind: 'agent-default-model', provider: selection.provider, model: selection.model })
235
+ }
236
+ } catch (_) {}
237
+ return references
238
+ }
239
+
240
+ /**
241
+ * 子代理路由的模型选择面(移除守卫的迁移下拉与目标校验)。
242
+ * 权威清单走宿主既有的 `listSubagentModels(llm, agentDefaultModel)`(含内置目录渠道与
243
+ * reasoning 元数据),迁移目标必须落在该清单内——拒绝客户端自造路由。
244
+ */
245
+ const subagentModelOptions = async () => {
246
+ try {
247
+ const llm = ctx.get('llm')
248
+ if (llm === undefined) return []
249
+ const whitelist = await listSubagentModels(llm, ctx.get('agentDefaultModel'))
250
+ const byProvider = new Map()
251
+ for (const item of Array.isArray(whitelist?.models) ? whitelist.models : []) {
252
+ if (typeof item?.provider !== 'string' || item.provider === '' || typeof item?.id !== 'string' || item.id === '') continue
253
+ const group = byProvider.get(item.provider) ?? []
254
+ if (group.some((row) => row.id === item.id)) continue
255
+ group.push({ id: item.id, name: typeof item.name === 'string' && item.name !== '' ? item.name : item.id })
256
+ byProvider.set(item.provider, group)
257
+ }
258
+ return [...byProvider.entries()].map(([provider, models]) => ({ provider, models }))
259
+ } catch (_) {
260
+ return []
261
+ }
262
+ }
263
+
264
+ return {
265
+ // ── P3.5 补充源(modelparams.dev)手动刷新:force 绕过退避,但在途请求仍单飞合并 ──
266
+ 'models2-refresh-modelparams': { feature: 'modelCatalog', handle: async (payload) => {
267
+ try {
268
+ if (dshHome === undefined) return fail('catalog-unavailable')
269
+ await modelParamsReady
270
+ // freshKey 非法/缺失即给唯一键:绝不与其它会话共享一次条件请求。
271
+ const key = typeof payload?.freshKey === 'string' && payload.freshKey.length > 0 && payload.freshKey.length <= 64
272
+ ? payload.freshKey
273
+ : `anon-${randomUUID()}`
274
+ await modelParamsFresh.refreshed(key, payload?.force === true)
275
+ return {
276
+ ok: true,
277
+ value: {
278
+ haveSnapshot: modelParamsState.snapshot !== null,
279
+ ...(modelParamsState.snapshot !== null ? { fetchedAt: modelParamsState.snapshot.fetchedAt } : {}),
280
+ // 失败保留旧快照继续:staleError 让客户端显著标注陈旧,而非静默当真。
281
+ ...(modelParamsState.lastError !== null ? { staleError: modelParamsState.lastError } : {}),
282
+ },
283
+ }
284
+ } catch (error) {
285
+ return { ok: false, error: error?.message || String(error) }
286
+ }
287
+ } },
288
+
289
+ // ── 首页状态总览(零网络:settings + pi-ai 目录 + 侧车)─────────────────────
290
+ 'models2-snapshot': { feature: 'modelCatalog', handle: async () => {
291
+ try {
292
+ const piIndex = await ensurePiCatalog()
293
+ const sidecar = workbenchStore === undefined ? { providers: {}, validations: {}, recovery: {} } : await workbenchStore.load()
294
+ const builtinIds = piIndex.channelIds
295
+ const routes = await Promise.all(readLlmProfiles(modelSettingsFor(ctx)).map(async (profile) => {
296
+ const models = Array.isArray(profile.models) ? profile.models : []
297
+ // 空 models = 继承默认,不算非法;内置渠道的能力字段由目录继承 → looseCapabilities。
298
+ const structureOk = models.length === 0 ? true : selfCheckModelsPatch(models, { looseCapabilities: builtinIds.has(profile.route) }).ok
299
+ const rawValidation = sidecar.validations?.[profile.route]
300
+ const lastValidation = rawValidation !== null && typeof rawValidation === 'object' && !Array.isArray(rawValidation) ? rawValidation : undefined
301
+ const credentialState = await credentialStateOf(profile)
302
+ const attention = []
303
+ if (credentialState === 'dangling' || credentialState === 'invalid-ref') attention.push('dangling-credential')
304
+ if (lastValidation?.ok === false) attention.push('validation-failed')
305
+ if (sidecar.recovery?.[profile.route] !== undefined) attention.push('recovery-pending')
306
+ if (!structureOk) attention.push('invalid-profile')
307
+ const undoSnapshot = sidecar.providers?.[profile.route]
308
+ const undoScope = undoSnapshot === undefined ? 'none' : undoScopeOf(undoSnapshot)
309
+ const rawRemove = sidecar.removed?.[profile.route]
310
+ // 三种状态分开呈现(配置保存 / 运行时加载 / 真实调用测试),不用一个「已验证」笼统代替。
311
+ let runtimeLoaded = false
312
+ let runtimeError
313
+ const llmService = ctx.get('llm')
314
+ if (llmService !== undefined && typeof llmService.listProviders === 'function') {
315
+ try {
316
+ const providers = await llmService.listProviders()
317
+ runtimeLoaded = Array.isArray(providers) && providers.some((entry) => (entry !== null && typeof entry === 'object' ? entry.id === profile.route || entry.provider === profile.route : String(entry) === profile.route))
318
+ if (!runtimeLoaded) runtimeError = 'provider-missing'
319
+ } catch (_) {
320
+ runtimeError = 'list-providers-failed'
321
+ }
322
+ } else {
323
+ runtimeError = 'llm-service-missing'
324
+ }
325
+ // 内置渠道的能力计数按「显式值或目录已提供」的有效口径算(避免带思考等级却显示 0)。
326
+ const catalogRecordFor = (modelId) => {
327
+ for (const record of piIndex.byModelId.get(modelId) ?? []) {
328
+ if (record.channel === profile.route) return record
329
+ }
330
+ return undefined
331
+ }
332
+ const metaCounts = { contextWindow: 0, inputImage: 0, reasoning: 0 }
333
+ for (const model of models) {
334
+ const record = catalogRecordFor(model?.id)
335
+ if ((Number.isInteger(model?.contextWindow) && model.contextWindow > 0) || (record !== undefined && Number.isInteger(record.contextWindow) && record.contextWindow > 0)) metaCounts.contextWindow += 1
336
+ if ((Array.isArray(model?.input) && model.input.includes('image')) || (record !== undefined && Array.isArray(record.input) && record.input.includes('image'))) metaCounts.inputImage += 1
337
+ if (model?.reasoningEfforts !== undefined || (record !== undefined && (record.reasoningEfforts !== undefined || record.reasoning === true))) metaCounts.reasoning += 1
338
+ }
339
+ return {
340
+ route: profile.route,
341
+ displayName: profile.displayName,
342
+ source: builtinIds.has(profile.route) ? 'builtin' : 'custom',
343
+ baseURL: resolveLlmProfileBaseURL(profile, piIndex),
344
+ api: profile.api,
345
+ modelCount: models.length,
346
+ metaCounts,
347
+ credentialState,
348
+ lastValidation,
349
+ runtimeLoaded,
350
+ ...(runtimeError !== undefined ? { runtimeError } : {}),
351
+ undoable: undoSnapshot !== undefined,
352
+ undoScope,
353
+ restorableDeletion: rawRemove !== undefined && rawRemove !== null && typeof rawRemove === 'object',
354
+ ...(undoSnapshot !== undefined ? { undoAt: undoSnapshot.at } : {}),
355
+ attention,
356
+ needsAttention: attention.length > 0,
357
+ }
358
+ }))
359
+ // 需处理优先 → 最近验证时间倒序(从未验证殿后)→ 显示名。
360
+ routes.sort((a, b) => {
361
+ if (a.needsAttention !== b.needsAttention) return a.needsAttention ? -1 : 1
362
+ const atA = a.lastValidation?.at ?? 0
363
+ const atB = b.lastValidation?.at ?? 0
364
+ if (atA !== atB) return atB - atA
365
+ return a.displayName.localeCompare(b.displayName)
366
+ })
367
+ const removedRoutes = Object.entries(sidecar.removed ?? {})
368
+ .filter(([, entry]) => entry !== null && typeof entry === 'object')
369
+ .map(([route, entry]) => ({
370
+ route,
371
+ displayName: typeof entry.displayName === 'string' ? entry.displayName : route,
372
+ modelCount: Number.isInteger(entry.modelCount) ? entry.modelCount : 0,
373
+ at: entry.at,
374
+ ...(typeof entry.credentialRef === 'string' ? { credentialRef: entry.credentialRef } : {}),
375
+ // 同名路由被重新创建后不能再恢复(拒绝覆盖),前端据此禁用按钮并给原因。
376
+ taken: routes.some((row) => row.route === route),
377
+ }))
378
+ .sort((a, b) => (b.at ?? 0) - (a.at ?? 0))
379
+ .slice(0, 32)
380
+ return { ok: true, value: { routes, removedRoutes } }
381
+ } catch (error) {
382
+ return { ok: false, error: error?.message || String(error) }
383
+ }
384
+ } },
385
+
386
+ // ── 单路由完整 profile(手动编辑器基线;保真读取,含未知字段)──────────────
387
+ 'models2-route': { feature: 'modelCatalog', handle: async (payload) => {
388
+ try {
389
+ const route = typeof payload?.route === 'string' ? payload.route : ''
390
+ const profile = profileFor(route, modelSettingsFor(ctx))
391
+ if (profile === undefined) return fail('route-gone')
392
+ const piIndex = await ensurePiCatalog()
393
+ return {
394
+ ok: true,
395
+ value: {
396
+ route,
397
+ displayName: profile.displayName,
398
+ baseURL: resolveLlmProfileBaseURL(profile, piIndex),
399
+ api: profile.api,
400
+ apiKeyEnv: profile.apiKeyEnv,
401
+ models: profile.models,
402
+ apiProtocols: [...MODELS_PROTOCOLS],
403
+ // 服务端签发的写前基线:客户端原样回传,绝不自算。
404
+ fingerprint: routeFingerprint(profile),
405
+ },
406
+ }
407
+ } catch (error) {
408
+ return { ok: false, error: error?.message || String(error) }
409
+ }
410
+ } },
411
+
412
+ // ── 检查:目录级(零网络)或显式探测(probe: true 才发网)─────────────────
413
+ 'models2-inspect': { feature: 'modelCatalog', handle: async (payload) => {
414
+ try {
415
+ const route = typeof payload?.route === 'string' ? payload.route : ''
416
+ const probe = payload?.probe === true
417
+ const profile = profileFor(route, modelSettingsFor(ctx))
418
+ if (profile === undefined) return fail('route-gone')
419
+ const piIndex = await ensurePiCatalog()
420
+ const effectiveBaseURL = resolveLlmProfileBaseURL(profile, piIndex)
421
+ if (effectiveBaseURL === '') return fail('no-base-url')
422
+ const connection = { baseURL: effectiveBaseURL, probed: probe }
423
+ let gatewayRows = []
424
+ if (probe) {
425
+ // 显式启动检查即授权该路由使用已存凭据(仅发往该路由当前地址,不回显不落日志)。
426
+ const apiKey = await resolveCredential(profile)
427
+ connection.usedCredential = apiKey !== undefined
428
+ if (profile.api !== '' && !MODELS_PROBE_PROTOCOLS.has(profile.api)) {
429
+ // anthropic-messages 等无标准列表协议:在线发现不受支持,不判失败,降级继续。
430
+ connection.ok = false
431
+ connection.error = 'probe-protocol-unsupported'
432
+ } else {
433
+ try {
434
+ gatewayRows = await fetchModelsProbe(effectiveBaseURL, apiKey)
435
+ connection.ok = true
436
+ connection.modelCount = gatewayRows.length
437
+ } catch (error) {
438
+ connection.ok = false
439
+ connection.error = error?.message || String(error)
440
+ }
441
+ }
442
+ }
443
+ const bindings = workbenchStore === undefined ? {} : (await workbenchStore.load()).bindings?.[route] ?? {}
444
+ let suggestions = arbitrateModelSuggestions({
445
+ route,
446
+ routeBaseURL: effectiveBaseURL,
447
+ currentModels: profile.models,
448
+ gatewayModels: gatewayRows,
449
+ resolveModelMeta: (modelId) => orderedCandidates(piIndex, route, modelId),
450
+ bindings,
451
+ })
452
+ // 未探测或无在线清单:unknown(已配置但网关未返回)无从判定,过滤掉。
453
+ if (connection.ok !== true) suggestions = suggestions.filter((row) => row.kind !== 'unknown')
454
+ const inspectId = randomUUID()
455
+ rememberHandle(inspections, inspectId, { route, suggestions, expires: Date.now() + WORKBENCH_PLAN_TTL_MS })
456
+ const summary = {}
457
+ for (const row of suggestions) summary[row.kind] = (summary[row.kind] ?? 0) + 1
458
+ return { ok: true, value: { inspectId, route, connection, summary, suggestions } }
459
+ } catch (error) {
460
+ return { ok: false, error: error?.message || String(error) }
461
+ }
462
+ } },
463
+
464
+ // ── 准备:把选中的 sid 重放成新模型表 + 字段级 diff(不写盘)────────────────
465
+ 'models2-prepare': { feature: 'modelCatalog', handle: async (payload) => {
466
+ try {
467
+ const inspectId = typeof payload?.inspectId === 'string' ? payload.inspectId : ''
468
+ const inspection = takeHandle(inspections, inspectId)
469
+ if (inspection === undefined) return fail('unknown-inspection')
470
+ if (Date.now() > inspection.expires) return fail('plan-expired')
471
+ const profile = profileFor(inspection.route, modelSettingsFor(ctx))
472
+ if (profile === undefined) return fail('route-gone')
473
+ const sids = Array.isArray(payload?.sids) ? payload.sids.filter((sid) => typeof sid === 'string') : []
474
+ // 零输入拼接:客户端只能回传 sid,取值全部来自服务端仲裁清单重放。
475
+ const nextModels = applySuggestions(profile.models, inspection.suggestions, sids)
476
+ const checked = selfCheckModelsPatch(nextModels)
477
+ if (!checked.ok) return fail('schema-invalid', checked.errors.join(';'))
478
+ const planId = planIdFor(`${inspection.route}:${randomUUID()}`)
479
+ const expires = Date.now() + WORKBENCH_PLAN_TTL_MS
480
+ rememberHandle(plans, planId, { route: inspection.route, nextModels, sids, baseFingerprint: profileFingerprint(profile), baseRevision: settingsRevisionOf(modelSettingsFor(ctx)), expires })
481
+ return { ok: true, value: { planId, route: inspection.route, expires, diff: diffModelLists(profile.models, nextModels), selected: sids.length } }
482
+ } catch (error) {
483
+ return { ok: false, error: error?.message || String(error) }
484
+ }
485
+ } },
486
+
487
+ // ── 提交:重读比对(漂移即 conflict)→ 写入 → 持久化写前快照 ────────────────
488
+ 'models2-commit': { feature: 'modelCatalog', handle: async (payload) => {
489
+ try {
490
+ const planId = typeof payload?.planId === 'string' ? payload.planId : ''
491
+ const plan = takeHandle(plans, planId)
492
+ if (plan === undefined) return fail('unknown-plan')
493
+ if (Date.now() > plan.expires) return fail('plan-expired')
494
+ const settingsService = modelSettingsFor(ctx)
495
+ const profile = profileFor(plan.route, settingsService)
496
+ if (profile === undefined) return fail('route-gone')
497
+ // 补偿未恢复的阻断态:恢复前禁止对该路由的任何自动写入。
498
+ if (workbenchStore !== undefined && (await workbenchStore.load()).recovery?.[plan.route] !== undefined) return fail('recovery-pending')
499
+ // 冲突检测:计划生成后配置有任何漂移(含 baseURL/凭据引用/未知字段)→ 拒绝并要求重算。
500
+ if (profileFingerprint(profile) !== plan.baseFingerprint) return fail('conflict')
501
+ const undoSnapshot = { models: profile.models, at: Date.now() }
502
+ try {
503
+ await mutateModels(settingsService, plan.route, plan.nextModels, plan.baseRevision)
504
+ } catch (error) {
505
+ if (isSettingsConflict(error)) return fail('conflict')
506
+ throw error
507
+ }
508
+ // 写后持久化撤销快照(只存模型数组,不含密钥值);落盘失败仅降级撤销能力。
509
+ let undoable = true
510
+ if (workbenchStore !== undefined) {
511
+ try {
512
+ await workbenchStore.mutate((sidecar) => {
513
+ if (sidecar.providers === undefined) sidecar.providers = {}
514
+ sidecar.providers[plan.route] = undoSnapshot
515
+ })
516
+ } catch (_) {
517
+ undoable = false
518
+ }
519
+ } else {
520
+ undoable = false
521
+ }
522
+ return { ok: true, value: { route: plan.route, applied: plan.sids.length, undoable } }
523
+ } catch (error) {
524
+ return { ok: false, error: error?.message || String(error) }
525
+ }
526
+ } },
527
+
528
+ // ── 单模型行写入(独立编辑面板):只改这一个模型的字段,其余行原字节保留 ─────────
529
+ // 与 models2-edit-prepare 的区别:那条要求客户端回传**整张**模型表(长列表底部统一保存的
530
+ // 旧形状);本端点只收一行补丁,宿主在读到的 profile 上原位替换该行。零输入拼接:`id`
531
+ // 必须已存在于服务端读到的清单;未列出的字段不参与写入,未知高级键原样保留。
532
+ 'models2-model-patch': { feature: 'modelCatalog', handle: async (payload) => {
533
+ try {
534
+ const route = typeof payload?.route === 'string' ? payload.route : ''
535
+ const modelId = typeof payload?.modelId === 'string' ? payload.modelId.trim() : ''
536
+ if (modelId === '' || modelId.length > MAX_MODEL_ID_LENGTH || !MODEL_ID_RE.test(modelId)) return fail('bad-model-id')
537
+ const settingsService = modelSettingsFor(ctx)
538
+ const profile = profileFor(route, settingsService)
539
+ if (profile === undefined) return fail('route-gone')
540
+ if (workbenchStore !== undefined && (await workbenchStore.load()).recovery?.[route] !== undefined) return fail('recovery-pending')
541
+ const index = profile.models.findIndex((model) => model?.id === modelId)
542
+ if (index === -1) return fail('unknown-model', modelId.slice(0, 64))
543
+ const patch = payload?.patch
544
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return fail('bad-models')
545
+ // 只接受能力/元数据字段;id 不可改写(改 id 是增删,不是编辑)。
546
+ const nextRow = { ...profile.models[index] }
547
+ for (const field of ['name', 'contextWindow', 'maxTokens', 'input', 'reasoningEfforts']) {
548
+ if (!(field in patch)) continue
549
+ if (patch[field] === undefined) delete nextRow[field]
550
+ else nextRow[field] = patch[field]
551
+ }
552
+ if (payload?.clearUnknownField !== undefined) return fail('bad-models')
553
+ const nextModels = profile.models.map((model, i) => (i === index ? nextRow : model))
554
+ const checked = selfCheckModelsPatch(nextModels)
555
+ if (!checked.ok) return fail('schema-invalid', checked.errors.join(';'))
556
+ const diff = diffModelLists(profile.models, nextModels)
557
+ if (diff.changes.length === 0) return { ok: true, value: { noop: true } }
558
+ try {
559
+ await mutateModels(settingsService, route, nextModels, settingsRevisionOf(settingsService))
560
+ } catch (error) {
561
+ if (isSettingsConflict(error)) return fail('conflict')
562
+ throw error
563
+ }
564
+ // 写前快照(模型数组):单模型编辑同样可撤销。
565
+ let undoable = true
566
+ if (workbenchStore !== undefined) {
567
+ try {
568
+ await workbenchStore.mutate((sidecar) => {
569
+ if (sidecar.providers === undefined) sidecar.providers = {}
570
+ sidecar.providers[route] = { models: profile.models, at: Date.now() }
571
+ })
572
+ } catch (_) { undoable = false }
573
+ } else { undoable = false }
574
+ return { ok: true, value: { route, modelId, diff, undoable } }
575
+ } catch (error) {
576
+ return { ok: false, error: error?.message || String(error) }
577
+ }
578
+ } },
579
+
580
+ // ── 连接信息编辑(地址/协议/显示名)与换密钥:字段级写,未知键与模型数组不动 ──────
581
+ 'models2-connection-preview': { feature: 'modelCatalog', handle: async (payload) => {
582
+ try {
583
+ const route = typeof payload?.route === 'string' ? payload.route : ''
584
+ const profile = profileFor(route, modelSettingsFor(ctx))
585
+ if (profile === undefined) return fail('route-gone')
586
+ const piIndex = await ensurePiCatalog()
587
+ const patch = connectionPatch(profile, payload?.next ?? {})
588
+ if (patch.badBaseURL === true) return { ok: true, value: { route, patch, clearBaseURL: true, fingerprint: routeFingerprint(profile) } }
589
+ if (patch.badApi === true) return fail('bad-api')
590
+ if (patch.badDisplayName === true) return fail('bad-display-name')
591
+ const effectiveBaseURL = typeof patch.baseURL === 'string' ? patch.baseURL : resolveLlmProfileBaseURL(profile, piIndex)
592
+ const probe = probeBaseURLCheck(effectiveBaseURL)
593
+ // 换密钥:只看宿主侧真实契约(describe),不回显旧值、不落草稿。
594
+ const credentials = ctx.get('credentials')
595
+ const keyRef = MODELS_KEY_REF_RE.test(payload?.keyRef ?? '') ? payload.keyRef : (MODELS_KEY_REF_RE.test(profile.apiKeyEnv) ? profile.apiKeyEnv : deriveKeyRefFor(route))
596
+ let credential = { ref: keyRef, configured: false, writable: true, shadowed: false }
597
+ if (credentials !== undefined && typeof credentials.describe === 'function') {
598
+ try {
599
+ const info = await credentials.describe(keyRef)
600
+ credential = {
601
+ ref: keyRef,
602
+ configured: info?.configured === true,
603
+ writable: info?.writable !== false,
604
+ shadowed: info?.source === 'env',
605
+ ...(typeof info?.source === 'string' ? { source: info.source } : {}),
606
+ }
607
+ } catch (_) {
608
+ credential = { ref: keyRef, configured: false, writable: true, shadowed: false, state: 'describe-failed' }
609
+ }
610
+ } else {
611
+ credential = { ref: keyRef, configured: false, writable: true, shadowed: false, state: 'service-missing' }
612
+ }
613
+ return {
614
+ ok: true,
615
+ value: {
616
+ route,
617
+ patch,
618
+ fingerprint: routeFingerprint(profile),
619
+ effectiveBaseURL,
620
+ // 允许保存但禁在线发现:非公网/HTTP/非 443 地址照常写入 settings,只是不给探测。
621
+ probeAllowed: probe.ok === true,
622
+ ...(probe.ok === true ? {} : { probeBlocked: probe.code }),
623
+ credential,
624
+ references: await inspectRemoveReferences(route),
625
+ },
626
+ }
627
+ } catch (error) {
628
+ return { ok: false, error: error?.message || String(error) }
629
+ }
630
+ } },
631
+
632
+ 'models2-connection-commit': { feature: 'modelCatalog', handle: async (payload) => {
633
+ try {
634
+ const route = typeof payload?.route === 'string' ? payload.route : ''
635
+ const settingsService = modelSettingsFor(ctx)
636
+ const profile = profileFor(route, settingsService)
637
+ if (profile === undefined) return fail('route-gone')
638
+ if (workbenchStore !== undefined && (await workbenchStore.load()).recovery?.[route] !== undefined) return fail('recovery-pending')
639
+ const fingerprint = typeof payload?.baseFingerprint === 'string' ? payload.baseFingerprint : ''
640
+ if (fingerprint === '' || routeFingerprint(profile) !== fingerprint) return fail('conflict')
641
+ const patch = connectionPatch(profile, payload?.next ?? {})
642
+ if (patch.badApi === true) return fail('bad-api')
643
+ if (patch.badDisplayName === true) return fail('bad-display-name')
644
+ const ops = []
645
+ for (const field of ['displayName', 'api']) {
646
+ if (patch[field] !== undefined) ops.push({ op: 'set', path: ['providers', route, field], value: patch[field] })
647
+ }
648
+ // 地址:空值 = 清除键(回落目录 baseUrl),与「写空串」语义不同,故走 unset。
649
+ if (patch.badBaseURL === true) ops.push({ op: 'unset', path: ['providers', route, 'baseURL'] })
650
+ else if (patch.baseURL !== undefined) ops.push({ op: 'set', path: ['providers', route, 'baseURL'], value: patch.baseURL })
651
+ const key = typeof payload?.key === 'string' ? payload.key : ''
652
+ if (key.length > MAX_MODELS_KEY_LENGTH) return fail('bad-key')
653
+ const credentials = ctx.get('credentials')
654
+ const keyRef = MODELS_KEY_REF_RE.test(profile.apiKeyEnv) ? profile.apiKeyEnv : deriveKeyRefFor(route)
655
+ const snapshot = connectionSnapshotOf(profile)
656
+ let credentialReplaced = false
657
+ if (key !== '') {
658
+ if (credentials === undefined || typeof credentials.set !== 'function' || !MODELS_KEY_REF_RE.test(keyRef)) return fail('credential-rejected')
659
+ // 换密钥先行:写失败时 settings 一字未动,天然无半提交(与新建同一条纪律)。
660
+ try {
661
+ await credentials.set(keyRef, key)
662
+ } catch (error) {
663
+ // 环境变量遮蔽等真实拒绝:照实上报,不假装成功。
664
+ return fail('credential-rejected', error?.message || String(error))
665
+ }
666
+ credentialReplaced = true
667
+ // 首次落引用:只有目标路由自己的 apiKeyEnv 会被写,其他路由不触碰。
668
+ if (profile.apiKeyEnv !== keyRef) ops.push({ op: 'set', path: ['providers', route, 'apiKeyEnv'], value: keyRef })
669
+ }
670
+ if (ops.length > 0) {
671
+ try {
672
+ await settingsService.mutate('llm-pi-ai', ops, settingsRevisionOf(settingsService))
673
+ } catch (error) {
674
+ if (isSettingsConflict(error)) return fail('conflict')
675
+ return fail('connection-failed', error?.message || String(error))
676
+ }
677
+ }
678
+ // 写前快照:连接 + 模型一起进撤销槽(撤销可回滚连接字段与模型数组)。
679
+ let undoable = true
680
+ if (workbenchStore !== undefined) {
681
+ try {
682
+ await workbenchStore.mutate((sidecar) => {
683
+ if (sidecar.providers === undefined) sidecar.providers = {}
684
+ sidecar.providers[route] = { kind: 'full', ...snapshot, models: profile.models, at: Date.now() }
685
+ })
686
+ } catch (_) { undoable = false }
687
+ } else { undoable = false }
688
+ return { ok: true, value: { route, changed: ops.length, credentialReplaced, undoable, keyRef: credentialReplaced ? keyRef : undefined } }
689
+ } catch (error) {
690
+ return { ok: false, error: error?.message || String(error) }
691
+ }
692
+ } },
693
+
694
+ // ── 渠道删除预览:引用清单 + 凭据保留说明(两段式确认的第一段)────────────────
695
+ 'models2-route-remove-preview': { feature: 'modelCatalog', handle: async (payload) => {
696
+ try {
697
+ const route = typeof payload?.route === 'string' ? payload.route : ''
698
+ const profile = profileFor(route, modelSettingsFor(ctx))
699
+ if (profile === undefined) return fail('route-gone')
700
+ const references = await inspectRemoveReferences(route)
701
+ const credentials = ctx.get('credentials')
702
+ let credential
703
+ if (profile.apiKeyEnv === '' || !MODELS_KEY_REF_RE.test(profile.apiKeyEnv)) credential = { state: 'none' }
704
+ // 删除一律不 unset 凭据:服务缺席时同样承诺保留(只报引用名,不读值)。
705
+ else if (credentials === undefined || typeof credentials.describe !== 'function') credential = { ref: profile.apiKeyEnv, state: 'service-missing', retained: true }
706
+ else {
707
+ try {
708
+ const info = await credentials.describe(profile.apiKeyEnv)
709
+ credential = { ref: profile.apiKeyEnv, state: info?.configured === true ? 'kept' : 'unconfigured', retained: true }
710
+ } catch (_) {
711
+ credential = { ref: profile.apiKeyEnv, state: 'kept', retained: true }
712
+ }
713
+ }
714
+ return {
715
+ ok: true,
716
+ value: {
717
+ route,
718
+ displayName: profile.displayName,
719
+ modelCount: profile.models.length,
720
+ fingerprint: routeFingerprint(profile),
721
+ references,
722
+ // 删除渠道保留凭据(避免影响共享使用者):只列出引用名,绝不回显、不删除。
723
+ credential,
724
+ blocked: references.length > 0 || modelSettingsFor(ctx).inheritedRoute(route),
725
+ inheritedRoute: modelSettingsFor(ctx).inheritedRoute(route),
726
+ },
727
+ }
728
+ } catch (error) {
729
+ return { ok: false, error: error?.message || String(error) }
730
+ }
731
+ } },
732
+
733
+ // ── 渠道删除:整份 profile 进撤销槽(删除入口不随行消失),凭据一律保留 ──────────
734
+ 'models2-route-remove-commit': { feature: 'modelCatalog', handle: async (payload) => {
735
+ try {
736
+ const route = typeof payload?.route === 'string' ? payload.route : ''
737
+ const settingsService = modelSettingsFor(ctx)
738
+ const profile = profileFor(route, settingsService)
739
+ if (profile === undefined) return fail('route-gone')
740
+ const fingerprint = typeof payload?.baseFingerprint === 'string' ? payload.baseFingerprint : ''
741
+ if (fingerprint === '' || routeFingerprint(profile) !== fingerprint) return fail('conflict')
742
+ const references = await inspectRemoveReferences(route)
743
+ if (references.length > 0) return fail('removal-blocked')
744
+ if (settingsService.inheritedRoute(route)) return fail('model-config-inherited-route')
745
+ // 读取宿主原始 profile(保真,含未知高级键)——恢复时按原样写回。
746
+ const rawProviders = settingsService.get('llm-pi-ai')?.providers ?? {}
747
+ const rawProfile = rawProviders[route]
748
+ if (rawProfile === undefined || typeof rawProfile !== 'object') return fail('route-gone')
749
+ if (workbenchStore !== undefined) {
750
+ // 删除前先落快照:快照写失败即拒绝删除(没有可回滚状态就不做破坏性动作)。
751
+ await workbenchStore.mutate((sidecar) => {
752
+ if (sidecar.removed === undefined) sidecar.removed = {}
753
+ sidecar.removed[route] = {
754
+ kind: 'removed',
755
+ profile: rawProfile,
756
+ displayName: profile.displayName,
757
+ modelCount: profile.models.length,
758
+ credentialRef: MODELS_KEY_REF_RE.test(profile.apiKeyEnv) ? profile.apiKeyEnv : undefined,
759
+ at: Date.now(),
760
+ }
761
+ if (sidecar.providers !== undefined) delete sidecar.providers[route]
762
+ })
763
+ }
764
+ try {
765
+ await settingsService.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', route] }], settingsRevisionOf(settingsService))
766
+ } catch (error) {
767
+ // 写失败 → 清掉刚落的删除快照,避免留下指向不存在删除的幽灵撤销项。
768
+ if (workbenchStore !== undefined) {
769
+ try { await workbenchStore.mutate((sidecar) => { if (sidecar.removed !== undefined) delete sidecar.removed[route] }) } catch (_) {}
770
+ }
771
+ if (isSettingsConflict(error)) return fail('conflict')
772
+ return fail('remove-failed', error?.message || String(error))
773
+ }
774
+ // 异步写者(llm-pi-ai 适配器)可能把并发写入带回已删路由 → 二次清扫由用户重试覆盖。
775
+ return { ok: true, value: { route, removed: true, credentialRetained: MODELS_KEY_REF_RE.test(profile.apiKeyEnv) } }
776
+ } catch (error) {
777
+ return { ok: false, error: error?.message || String(error) }
778
+ }
779
+ } },
780
+
781
+ // ── 删除恢复清单:即使路由行已消失,恢复入口仍从侧车读得到 ────────────────────
782
+ 'models2-route-removed-list': { feature: 'modelCatalog', handle: async () => {
783
+ try {
784
+ if (workbenchStore === undefined) return { ok: true, value: { removed: [] } }
785
+ const sidecar = await workbenchStore.load()
786
+ const current = new Set(readLlmProfiles(modelSettingsFor(ctx)).map((profile) => profile.route))
787
+ const removed = Object.entries(sidecar.removed ?? {})
788
+ .filter(([, entry]) => entry !== null && typeof entry === 'object')
789
+ .map(([route, entry]) => ({
790
+ route,
791
+ displayName: typeof entry.displayName === 'string' ? entry.displayName : route,
792
+ modelCount: Number.isInteger(entry.modelCount) ? entry.modelCount : 0,
793
+ at: entry.at,
794
+ ...(typeof entry.credentialRef === 'string' ? { credentialRef: entry.credentialRef } : {}),
795
+ taken: current.has(route),
796
+ }))
797
+ .sort((a, b) => (b.at ?? 0) - (a.at ?? 0))
798
+ .slice(0, 32)
799
+ return { ok: true, value: { removed } }
800
+ } catch (error) {
801
+ return { ok: false, error: error?.message || String(error) }
802
+ }
803
+ } },
804
+
805
+ // ── 删除恢复:整份 profile 写回;同名路由已被重新创建时拒绝覆盖 ──────────────────
806
+ 'models2-route-restore': { feature: 'modelCatalog', handle: async (payload) => {
807
+ try {
808
+ const route = typeof payload?.route === 'string' ? payload.route : ''
809
+ if (workbenchStore === undefined) return fail('nothing-to-restore')
810
+ const settingsService = modelSettingsFor(ctx)
811
+ const sidecar = await workbenchStore.load()
812
+ const entry = sidecar.removed?.[route]
813
+ const restoreValue = routeRestoreValue(entry)
814
+ if (entry === undefined || restoreValue === undefined) {
815
+ return fail('nothing-to-restore')
816
+ }
817
+ if (profileFor(route, settingsService) !== undefined) return fail('route-taken')
818
+ try {
819
+ await settingsService.mutate('llm-pi-ai', [{ op: 'set', path: ['providers', route], value: restoreValue }], settingsRevisionOf(settingsService))
820
+ } catch (error) {
821
+ if (isSettingsConflict(error)) return fail('conflict')
822
+ // 校验失败(schema 拒收)→ 保留快照,回报真实原因,绝不「清标记后声称已恢复」。
823
+ return fail('restore-failed', error?.message || String(error))
824
+ }
825
+ await workbenchStore.mutate((current) => { if (current.removed !== undefined) delete current.removed[route] })
826
+ return {
827
+ ok: true,
828
+ value: {
829
+ route,
830
+ restored: true,
831
+ // 凭据引用随 profile 恢复;旧密钥值不在快照内,需重新输入才能调用。
832
+ credentialRef: typeof entry.credentialRef === 'string' ? entry.credentialRef : undefined,
833
+ credentialValueRestored: false,
834
+ },
835
+ }
836
+ } catch (error) {
837
+ return { ok: false, error: error?.message || String(error) }
838
+ }
839
+ } },
840
+
841
+ // ── 撤销预览:快照 vs 当前,逐字段列出可恢复项(含连接字段)─────────────────
842
+ 'models2-undo-preview': { feature: 'modelCatalog', handle: async (payload) => {
843
+ try {
844
+ const route = typeof payload?.route === 'string' ? payload.route : ''
845
+ if (workbenchStore === undefined) return fail('nothing-to-undo')
846
+ const snapshot = (await workbenchStore.load()).providers?.[route]
847
+ if (snapshot === undefined) return fail('nothing-to-undo')
848
+ const profile = profileFor(route, modelSettingsFor(ctx))
849
+ if (profile === undefined) return fail('route-gone')
850
+ const scope = undoScopeOf(snapshot)
851
+ // 连接字段差异:复用 connectionUndoOps 的判定(它决定真正会写哪些键),
852
+ // 预览展示的是同一份依据——不另立一套「会恢复什么」的口径。
853
+ const connection = scope === 'full'
854
+ ? connectionUndoOps(snapshot, profile).map((op) => ({
855
+ field: op.path[0],
856
+ current: profile[op.path[0]],
857
+ ...(op.op === 'unset' ? { remove: true } : { restoreTo: op.value }),
858
+ }))
859
+ : []
860
+ return {
861
+ ok: true,
862
+ value: {
863
+ route,
864
+ at: snapshot.at,
865
+ scope,
866
+ connection,
867
+ // 旧快照(无 models 数组的「连接编辑」槽)不该展示成模型撤销。
868
+ ...(scope === 'models' || scope === 'full'
869
+ ? undoPreviewModels(snapshot.models ?? profile.models, profile.models)
870
+ : { restorable: [], addedSince: [], removedSince: [] }),
871
+ // 密钥值不在快照内:撤销恢复的是引用名,不是旧密钥。
872
+ ...(scope === 'full' && connection.some((row) => row.field === 'apiKeyEnv')
873
+ ? { credentialNote: 'reference-only' }
874
+ : {}),
875
+ },
876
+ }
877
+ } catch (error) {
878
+ return { ok: false, error: error?.message || String(error) }
879
+ }
880
+ } },
881
+
882
+ // ── 撤销提交:只回滚快照能证明的字段差异;成功才清槽(失败可重试)───────────
883
+ 'models2-undo-commit': { feature: 'modelCatalog', handle: async (payload) => {
884
+ try {
885
+ const route = typeof payload?.route === 'string' ? payload.route : ''
886
+ if (workbenchStore === undefined) return fail('nothing-to-undo')
887
+ if ((await workbenchStore.load()).recovery?.[route] !== undefined) return fail('recovery-pending')
888
+ const snapshot = (await workbenchStore.load()).providers?.[route]
889
+ if (snapshot === undefined) return fail('nothing-to-undo')
890
+ const settingsService = modelSettingsFor(ctx)
891
+ const profile = profileFor(route, settingsService)
892
+ if (profile === undefined) return fail('route-gone')
893
+ const scope = undoScopeOf(snapshot)
894
+ if (scope === 'none') return fail('nothing-to-undo')
895
+ // 撤销没有前置计划句柄可携带 revision:这里刚读过 profile,直接取当前 revision
896
+ // (撤销写入同样是 read-modify-write,仍需关掉并发窗口)。
897
+ const undoRevision = settingsRevisionOf(settingsService)
898
+ const ops = []
899
+ if (scope === 'full') {
900
+ // 连接字段与模型数组同处一次写入:撤销可回滚连接配置与模型编辑,未知键不动。
901
+ for (const op of connectionUndoOps(snapshot, profile)) {
902
+ ops.push({ op: op.op, path: ['providers', route, ...op.path], ...(op.op === 'set' ? { value: op.value } : {}) })
903
+ }
904
+ }
905
+ const nextModels = applyUndo(snapshot.models ?? profile.models, profile.models, {
906
+ dropAdded: payload?.dropAdded === true,
907
+ restoreRemoved: payload?.restoreRemoved === true,
908
+ })
909
+ const checked = selfCheckModelsPatch(nextModels)
910
+ if (!checked.ok) return fail('schema-invalid', checked.errors.join(';'))
911
+ if (ops.length > 0 || scope === 'full') {
912
+ ops.push({ op: 'set', path: ['providers', route, 'models'], value: nextModels })
913
+ }
914
+ try {
915
+ if (ops.length === 0) await mutateModels(settingsService, route, nextModels, undoRevision)
916
+ else await settingsService.mutate('llm-pi-ai', ops, undoRevision)
917
+ } catch (error) {
918
+ if (isSettingsConflict(error)) return fail('conflict')
919
+ throw error
920
+ }
921
+ await workbenchStore.mutate((sidecar) => { if (sidecar.providers !== undefined) delete sidecar.providers[route] })
922
+ return { ok: true, value: { route, restored: true, scope } }
923
+ } catch (error) {
924
+ return { ok: false, error: error?.message || String(error) }
925
+ }
926
+ } },
927
+
928
+ // ── 新建:连接信息 → 检查 → 候选(临时密钥只进任务内存)─────────────────────
929
+ 'models2-create-begin': { feature: 'modelCatalog', handle: async (payload) => {
930
+ try {
931
+ const displayName = typeof payload?.displayName === 'string' ? payload.displayName.trim().slice(0, 128) : ''
932
+ const baseURL = typeof payload?.baseURL === 'string' ? payload.baseURL.trim().slice(0, 2048) : ''
933
+ const api = typeof payload?.api === 'string' ? payload.api : ''
934
+ const key = typeof payload?.key === 'string' ? payload.key.trim() : ''
935
+ if (displayName === '') return fail('bad-display-name')
936
+ if (!MODELS_PROTOCOLS.includes(api)) return fail('bad-api')
937
+ if (hostnameOf(baseURL) === undefined) return fail('bad-base-url')
938
+ if (key.length > MAX_MODELS_KEY_LENGTH) return fail('bad-key')
939
+ const taken = readLlmProfiles(modelSettingsFor(ctx)).map((profile) => profile.route)
940
+ const explicitRoute = typeof payload?.route === 'string' ? payload.route.trim().slice(0, 64) : ''
941
+ const routeId = explicitRoute !== '' && MODELS_ROUTE_RE.test(explicitRoute) ? explicitRoute : suggestRouteId(displayName, taken)
942
+ if (taken.includes(routeId)) return fail('route-taken')
943
+ const piIndex = await ensurePiCatalog()
944
+ const expires = Date.now() + WORKBENCH_PLAN_TTL_MS
945
+ const connection = { usedTempKey: key !== '' }
946
+ let gatewayRows = []
947
+ if (!MODELS_PROBE_PROTOCOLS.has(api)) {
948
+ connection.ok = false
949
+ connection.error = 'probe-protocol-unsupported'
950
+ } else {
951
+ try {
952
+ gatewayRows = await fetchModelsProbe(baseURL, key !== '' ? key : undefined)
953
+ connection.ok = true
954
+ connection.modelCount = gatewayRows.length
955
+ } catch (error) {
956
+ connection.ok = false
957
+ connection.error = error?.message || String(error)
958
+ }
959
+ }
960
+ const candidates = arbitrateModelSuggestions({
961
+ route: routeId,
962
+ routeBaseURL: baseURL,
963
+ currentModels: [],
964
+ gatewayModels: gatewayRows,
965
+ resolveModelMeta: (modelId) => orderedCandidates(piIndex, routeId, modelId),
966
+ bindings: {},
967
+ }).filter((row) => row.kind === 'addition')
968
+ const draftId = randomUUID()
969
+ rememberHandle(drafts, draftId, { routeId, displayName, baseURL, api, key, candidates, expires }, 8)
970
+ const summary = {}
971
+ for (const row of candidates) summary[row.kind] = (summary[row.kind] ?? 0) + 1
972
+ return { ok: true, value: { draftId, routeId, connection, summary, candidates, expires } }
973
+ } catch (error) {
974
+ return { ok: false, error: error?.message || String(error) }
975
+ }
976
+ } },
977
+
978
+ // ── 新建提交:凭据先行(可恢复)→ settings profile;失败逐级补偿 ─────────────
979
+ 'models2-create-commit': { feature: 'modelCatalog', handle: async (payload) => {
980
+ try {
981
+ const draftId = typeof payload?.draftId === 'string' ? payload.draftId : ''
982
+ const draft = takeHandle(drafts, draftId)
983
+ if (draft === undefined) return fail('unknown-draft')
984
+ if (Date.now() > draft.expires) return fail('plan-expired')
985
+ const sids = Array.isArray(payload?.sids) ? payload.sids.filter((sid) => typeof sid === 'string') : []
986
+ const bySid = new Map(draft.candidates.map((row) => [row.sid, row]))
987
+ const unknown = sids.filter((sid) => !bySid.has(sid))
988
+ if (unknown.length > 0) return fail('unknown-suggestion', unknown.join(','))
989
+ const models = sids.map((sid) => {
990
+ const clean = {}
991
+ for (const [field, value] of Object.entries(bySid.get(sid).draft ?? {})) {
992
+ if (field !== 'inexpressible' && field !== 'selected' && field !== 'source' && value !== undefined) clean[field] = value
993
+ }
994
+ return clean
995
+ }).filter((row) => typeof row.id === 'string' && row.id !== '')
996
+ // 手填模型:仅允许 ID,wire-id 契约校验 + 去重。
997
+ const manualIds = Array.isArray(payload?.manualIds) ? payload.manualIds.filter((id) => typeof id === 'string') : []
998
+ const seen = new Set(models.map((row) => row.id))
999
+ for (const rawId of manualIds) {
1000
+ const id = rawId.trim().slice(0, MAX_MODEL_ID_LENGTH)
1001
+ if (id === '' || !MODEL_ID_RE.test(id)) return fail('schema-invalid', `${id.slice(0, 64)}|bad-id`)
1002
+ if (!seen.has(id)) { seen.add(id); models.push({ id }) }
1003
+ }
1004
+ if (models.length === 0) return fail('no-models')
1005
+ const checked = selfCheckModelsPatch(models)
1006
+ if (!checked.ok) return fail('schema-invalid', checked.errors.join(';'))
1007
+ const settingsService = modelSettingsFor(ctx)
1008
+ if (readLlmProfiles(settingsService).some((profile) => profile.route === draft.routeId)) return fail('route-taken')
1009
+ const keyRef = deriveKeyRefFor(draft.routeId)
1010
+ let credentialStored = false
1011
+ if (draft.key !== '') {
1012
+ const credentials = ctx.get('credentials')
1013
+ // 凭据先行:此刻失败时 settings 尚未写,天然无半提交。
1014
+ if (credentials === undefined || typeof credentials.set !== 'function' || !MODELS_KEY_REF_RE.test(keyRef)) return fail('credential-rejected')
1015
+ await credentials.set(keyRef, draft.key)
1016
+ credentialStored = true
1017
+ }
1018
+ const profile = buildRouteCreateProfile({
1019
+ route: draft.routeId,
1020
+ displayName: draft.displayName,
1021
+ api: draft.api,
1022
+ baseURL: draft.baseURL,
1023
+ models,
1024
+ storesKey: credentialStored,
1025
+ })
1026
+ try {
1027
+ // 新建不带 expectedRevision:revision 是**命名空间级**的,用草稿生成时的值会在
1028
+ // 用户于其它路由上做了任何改动后误拒;新建的并发冲突由上面的 route-taken 复检覆盖。
1029
+ await settingsService.mutate('llm-pi-ai', [{ op: 'set', path: ['providers', draft.routeId], value: profile }])
1030
+ } catch (error) {
1031
+ // settings 失败 → 逐级补偿:删新凭据;连回滚都失败 → 人工恢复阻断态。
1032
+ if (credentialStored) {
1033
+ const credentials = ctx.get('credentials')
1034
+ try {
1035
+ if (credentials !== undefined && typeof credentials.unset === 'function') await credentials.unset(keyRef)
1036
+ } catch (_) {
1037
+ if (workbenchStore !== undefined) {
1038
+ try {
1039
+ await workbenchStore.mutate((sidecar) => {
1040
+ if (sidecar.recovery === undefined) sidecar.recovery = {}
1041
+ sidecar.recovery[draft.routeId] = { reason: 'credential-rollback-failed', keyRef, at: Date.now() }
1042
+ })
1043
+ } catch (_) {}
1044
+ }
1045
+ }
1046
+ }
1047
+ return fail('create-failed', error?.message || String(error))
1048
+ }
1049
+ // 临时密钥用完即弃(任务内存不保留)。
1050
+ draft.key = ''
1051
+ return { ok: true, value: { route: draft.routeId, models: models.length, credentialStored } }
1052
+ } catch (error) {
1053
+ return { ok: false, error: error?.message || String(error) }
1054
+ }
1055
+ } },
1056
+
1057
+ // ── 人工恢复解除(补偿失败的阻断态)───────────────────────────────────────
1058
+ 'models2-recovery-resolve': { feature: 'modelCatalog', handle: async (payload) => {
1059
+ try {
1060
+ const route = typeof payload?.route === 'string' ? payload.route : ''
1061
+ if (workbenchStore === undefined) return fail('nothing-to-resolve')
1062
+ const sidecar = await workbenchStore.load()
1063
+ if (sidecar.recovery?.[route] === undefined) return fail('nothing-to-resolve')
1064
+ await workbenchStore.mutate((current) => { if (current.recovery !== undefined) delete current.recovery[route] })
1065
+ return { ok: true, value: { route, resolved: true } }
1066
+ } catch (error) {
1067
+ return { ok: false, error: error?.message || String(error) }
1068
+ }
1069
+ } },
1070
+
1071
+ // ── 验证:默认四项无推理检查 + 可选深度最小真实调用(显式确认由客户端承载)────
1072
+ 'models2-validate': { feature: 'modelCatalog', handle: async (payload) => {
1073
+ try {
1074
+ const route = typeof payload?.route === 'string' ? payload.route : ''
1075
+ const profile = profileFor(route, modelSettingsFor(ctx))
1076
+ if (profile === undefined) return fail('route-gone')
1077
+ const deep = payload?.deep === true
1078
+ const models = (Array.isArray(profile.models) ? profile.models : []).map((row) => row?.id).filter((id) => typeof id === 'string' && id !== '')
1079
+ if (deep && models.length === 0) return fail('no-models')
1080
+ const testModelId = models.length > 0 ? models[0] : undefined
1081
+ const llm = ctx.get('llm')
1082
+ const checks = [{ type: 'settings', ok: true }]
1083
+ if (llm === undefined) {
1084
+ // 服务缺席标跳过,不判失败。
1085
+ checks.push({ type: 'providers', ok: true, skipped: true })
1086
+ checks.push({ type: 'models', ok: true, skipped: true })
1087
+ } else {
1088
+ let providersOk = false
1089
+ try {
1090
+ // 0.1.6-alpha.2 契约:listProviders() 同步返回 LlmProviderInfo[] = {id, name}——
1091
+ // 路由键是 `id`(旧分支写的 `entry.provider` 在该版本恒为 undefined,会误报 provider-missing)。
1092
+ const providers = await llm.listProviders()
1093
+ providersOk = Array.isArray(providers) && providers.some((entry) => {
1094
+ if (entry !== null && typeof entry === 'object') return entry.id === route || entry.provider === route
1095
+ return String(entry) === route
1096
+ })
1097
+ } catch (_) {}
1098
+ checks.push({ type: 'providers', ok: providersOk, ...(providersOk ? {} : { error: 'provider-missing' }) })
1099
+ if (models.length === 0) {
1100
+ checks.push({ type: 'models', ok: true, skipped: true })
1101
+ } else {
1102
+ let resolved = true
1103
+ let failId = null
1104
+ try {
1105
+ const listed = await llm.listModels(route)
1106
+ const listedIds = new Set(Array.isArray(listed) ? listed.map((entry) => (
1107
+ entry !== null && typeof entry === 'object' && typeof entry.id === 'string' ? entry.id : String(entry)
1108
+ )) : [])
1109
+ failId = models.find((id) => !listedIds.has(id))
1110
+ if (failId === undefined || failId === null) {
1111
+ for (const id of models) {
1112
+ try {
1113
+ await llm.resolveModelInfo(route, id)
1114
+ } catch (_) {
1115
+ resolved = false
1116
+ failId = id
1117
+ break
1118
+ }
1119
+ }
1120
+ } else {
1121
+ resolved = false
1122
+ }
1123
+ } catch (_) {
1124
+ resolved = false
1125
+ failId = models[0]
1126
+ }
1127
+ checks.push({ type: 'models', ok: resolved, ...(resolved ? { modelCount: models.length } : { error: 'resolve-model-missing', modelId: failId }) })
1128
+ }
1129
+ }
1130
+ const credentials = ctx.get('credentials')
1131
+ if (profile.apiKeyEnv === '' || !MODELS_KEY_REF_RE.test(profile.apiKeyEnv)) {
1132
+ checks.push({ type: 'credential', ok: true })
1133
+ } else if (credentials === undefined || typeof credentials.resolve !== 'function') {
1134
+ checks.push({ type: 'credential', ok: true, skipped: true })
1135
+ } else {
1136
+ try {
1137
+ const resolved = await credentials.resolve(profile.apiKeyEnv)
1138
+ const hasValue = typeof resolved?.value === 'string' && resolved.value !== ''
1139
+ checks.push(hasValue ? { type: 'credential', ok: true } : { type: 'credential', ok: false, error: 'credential-unresolvable' })
1140
+ } catch (_) {
1141
+ checks.push({ type: 'credential', ok: false, error: 'credential-unresolvable' })
1142
+ }
1143
+ }
1144
+ if (deep) {
1145
+ if (llm === undefined || typeof llm.stream !== 'function') return fail('llm-service-missing')
1146
+ // 有界最小真实调用:固定提示词 + maxTokens 16 + 15s 超时;正文不展示不持久化,
1147
+ // 只记成功/耗时/首块。「可能计费」的再次确认由客户端承载。
1148
+ const started = Date.now()
1149
+ try {
1150
+ const signal = AbortSignal.timeout(15000)
1151
+ const stream = await llm.stream({
1152
+ provider: route,
1153
+ model: testModelId,
1154
+ system: 'Reply with OK.',
1155
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }],
1156
+ maxTokens: 16,
1157
+ signal,
1158
+ })
1159
+ let completed = false
1160
+ let firstChunk = false
1161
+ for await (const chunk of stream) {
1162
+ firstChunk = true
1163
+ if (chunk?.type !== 'finish') continue
1164
+ if (chunk.reason?.kind === 'error' || chunk.reason?.kind === 'aborted') {
1165
+ const error = new Error('stream-failed')
1166
+ error.code = chunk.reason.failure?.code || 'stream-failed'
1167
+ throw error
1168
+ }
1169
+ completed = chunk.reason?.kind === 'stop' || chunk.reason?.kind === 'max-tokens'
1170
+ }
1171
+ if (!completed) throw new Error('stream-incomplete')
1172
+ checks.push({ type: 'stream', ok: true, durationMs: Date.now() - started, firstChunk })
1173
+ } catch (error) {
1174
+ const code = typeof error?.code === 'string' && error.code !== '' ? error.code : 'stream-failed'
1175
+ checks.push({ type: 'stream', ok: false, durationMs: Date.now() - started, error: code })
1176
+ }
1177
+ }
1178
+ const ok = checks.every((check) => check.ok === true)
1179
+ const summary = {
1180
+ at: Date.now(),
1181
+ ok,
1182
+ checks,
1183
+ ...(testModelId !== undefined ? { modelId: testModelId } : {}),
1184
+ ...(deep ? { deep: true } : {}),
1185
+ }
1186
+ if (workbenchStore !== undefined) {
1187
+ // 摘要持久化失败仅降级为「下次不再可见」,不阻断结果展示。
1188
+ try {
1189
+ await workbenchStore.mutate((sidecar) => {
1190
+ if (sidecar.validations === undefined) sidecar.validations = {}
1191
+ sidecar.validations[route] = summary
1192
+ })
1193
+ } catch (_) {}
1194
+ }
1195
+ return { ok: true, value: summary }
1196
+ } catch (error) {
1197
+ return { ok: false, error: error?.message || String(error) }
1198
+ }
1199
+ } },
1200
+
1201
+ // ── 手动编辑器:整表提交,宿主侧同一道 schema 自检兜底 ───────────────────────
1202
+ 'models2-edit-prepare': { feature: 'modelCatalog', handle: async (payload) => {
1203
+ try {
1204
+ const route = typeof payload?.route === 'string' ? payload.route : ''
1205
+ const profile = profileFor(route, modelSettingsFor(ctx))
1206
+ if (profile === undefined) return fail('route-gone')
1207
+ if (!Array.isArray(payload?.models)) return fail('bad-models')
1208
+ const nextModels = payload.models.map((model) => (model !== null && typeof model === 'object' ? { ...model } : model))
1209
+ const checked = selfCheckModelsPatch(nextModels)
1210
+ if (!checked.ok) return fail('schema-invalid', checked.errors.join(';'))
1211
+ const diff = diffModelLists(profile.models, nextModels)
1212
+ if (diff.changes.length + diff.added.length + diff.removed.length === 0) return { ok: true, value: { noop: true } }
1213
+ const planId = planIdFor(`${route}:edit:${randomUUID()}`)
1214
+ const expires = Date.now() + WORKBENCH_PLAN_TTL_MS
1215
+ const appliedCount = diff.changes.length + diff.added.length + diff.removed.length
1216
+ rememberHandle(plans, planId, { route, nextModels, sids: [], baseFingerprint: profileFingerprint(profile), baseRevision: settingsRevisionOf(modelSettingsFor(ctx)), appliedCount, expires })
1217
+ return { ok: true, value: { planId, route, expires, diff, appliedCount } }
1218
+ } catch (error) {
1219
+ return { ok: false, error: error?.message || String(error) }
1220
+ }
1221
+ } },
1222
+ // ── 简易添加流:候选来源(内置目录 / 在线探测)────────────────────────────────
1223
+ 'models2-model-options': { feature: 'modelCatalog', handle: async (payload) => {
1224
+ try {
1225
+ const route = typeof payload?.route === 'string' ? payload.route : ''
1226
+ const source = payload?.source === 'builtin' || payload?.source === 'online' ? payload.source : ''
1227
+ const profile = profileFor(route, modelSettingsFor(ctx))
1228
+ if (profile === undefined) return fail('route-gone')
1229
+ const piIndex = await ensurePiCatalog()
1230
+ const builtins = piIndex.channelIds
1231
+ if (source === 'builtin') {
1232
+ if (!builtins.has(route)) return fail('builtin-only')
1233
+ const candidates = []
1234
+ const seen = new Set()
1235
+ // 内置目录记录 + 已配置模型(后者可能已被用户改名/补能力,仍要出现在列表里以免重复添加)。
1236
+ for (const record of [...piRecordsForRoute(piIndex, route), ...profile.models.map((model) => ({ id: model?.id })).filter((model) => typeof model.id === 'string')]) {
1237
+ if (typeof record.id !== 'string' || record.id === '' || seen.has(record.id)) continue
1238
+ seen.add(record.id)
1239
+ const candidate = simpleModelOption(record, 'builtin', route)
1240
+ const handle = randomUUID()
1241
+ rememberHandle(optionHandles, handle, {
1242
+ route,
1243
+ source,
1244
+ candidate: { ...candidate, observed: { input: record.input, reasoningEfforts: record.reasoningEfforts } },
1245
+ expires: Date.now() + WORKBENCH_PLAN_TTL_MS,
1246
+ })
1247
+ candidates.push({ ...candidate, optionHandle: handle })
1248
+ }
1249
+ return { ok: true, value: { route, source, candidates, expires: Date.now() + WORKBENCH_PLAN_TTL_MS } }
1250
+ }
1251
+ if (source !== 'online') return fail('bad-source')
1252
+ // 在线发现只对自定义路由有意义:内置渠道的清单已在本地注册表里。
1253
+ if (builtins.has(route)) return fail('online-custom-only')
1254
+ if (!MODELS_PROBE_PROTOCOLS.has(profile.api)) return fail('probe-protocol-unsupported')
1255
+ const baseURL = resolveLlmProfileBaseURL(profile, piIndex)
1256
+ if (baseURL === '') return fail('no-base-url')
1257
+ // 显式点击在线获取即授权使用已存凭据(仅发往该路由当前地址)。
1258
+ const apiKey = await resolveCredential(profile)
1259
+ let gatewayRows
1260
+ try {
1261
+ gatewayRows = await fetchModelsProbe(baseURL, apiKey)
1262
+ } catch (error) {
1263
+ return fail(error?.message || String(error))
1264
+ }
1265
+ const candidates = []
1266
+ const seen = new Set()
1267
+ for (const row of gatewayRows) {
1268
+ if (seen.has(row.id)) continue
1269
+ seen.add(row.id)
1270
+ // 元数据补充仅取同渠道 pi-ai;探测行自带字段优先。
1271
+ const meta = modelMetaCandidates(piIndex, row.id).find((candidate) => candidate.source === 'pi-ai' && candidate.providerId === route)
1272
+ const merged = {
1273
+ id: row.id,
1274
+ ...(row.name ? { name: row.name } : meta?.name ? { name: meta.name } : {}),
1275
+ ...(Array.isArray(row.input) ? { input: row.input } : meta?.input !== undefined ? { input: meta.input } : {}),
1276
+ ...(row.reasoningEfforts !== undefined ? { reasoningEfforts: row.reasoningEfforts } : meta?.reasoningEfforts !== undefined ? { reasoningEfforts: meta.reasoningEfforts } : {}),
1277
+ }
1278
+ const candidate = simpleModelOption(merged, 'online', meta?.providerId)
1279
+ const handle = randomUUID()
1280
+ rememberHandle(optionHandles, handle, {
1281
+ route,
1282
+ source,
1283
+ candidate: { ...candidate, observed: { input: merged.input, reasoningEfforts: merged.reasoningEfforts } },
1284
+ expires: Date.now() + WORKBENCH_PLAN_TTL_MS,
1285
+ })
1286
+ candidates.push({ ...candidate, optionHandle: handle })
1287
+ }
1288
+ return { ok: true, value: { route, source, candidates, connection: { ok: true, modelCount: candidates.length }, expires: Date.now() + WORKBENCH_PLAN_TTL_MS } }
1289
+ } catch (error) {
1290
+ return { ok: false, error: error?.message || String(error) }
1291
+ }
1292
+ } },
1293
+
1294
+ // ── 简易添加流:单模型运行时检测(读 adapter 的权威元数据,零猜测)─────────────
1295
+ 'models2-model-detect': { feature: 'modelCatalog', handle: async (payload) => {
1296
+ try {
1297
+ const route = typeof payload?.route === 'string' ? payload.route : ''
1298
+ const modelId = typeof payload?.modelId === 'string' ? payload.modelId.trim() : ''
1299
+ if (modelId === '' || modelId.length > MAX_MODEL_ID_LENGTH || !MODEL_ID_RE.test(modelId)) return fail('bad-model-id')
1300
+ const profile = profileFor(route, modelSettingsFor(ctx))
1301
+ if (profile === undefined) return fail('route-gone')
1302
+ const llm = ctx.get('llm')
1303
+ let verified = false
1304
+ let observed = {}
1305
+ let errorCode
1306
+ if (llm !== undefined && typeof llm.resolveModelInfo === 'function') {
1307
+ try {
1308
+ const info = await llm.resolveModelInfo(route, modelId)
1309
+ verified = true
1310
+ observed = simpleModelObserved(info)
1311
+ } catch (error) {
1312
+ errorCode = error?.code || 'model-detection-failed'
1313
+ }
1314
+ } else {
1315
+ errorCode = 'llm-service-missing'
1316
+ }
1317
+ const piIndex = await ensurePiCatalog()
1318
+ // 多来源共识:目录候选(同渠道 pi-ai → 跨渠道 pi-ai → modelparams)+ 运行时实测(P3)。
1319
+ const ordered = [
1320
+ ...orderedCandidates(piIndex, route, modelId).filter((candidate) => candidate.source === 'pi-ai' && candidate.providerId === route),
1321
+ ...orderedCandidates(piIndex, route, modelId).filter((candidate) => candidate.source === 'pi-ai' && candidate.providerId !== route),
1322
+ ...modelParamsCandidates(modelParamsState.snapshot?.catalog, modelId),
1323
+ ...(Object.keys(observed).length > 0 ? [{ source: 'runtime', providerId: route, ...observed }] : []),
1324
+ ]
1325
+ const fields = consensusFields(ordered)
1326
+ const detectionId = randomUUID()
1327
+ rememberHandle(detections, detectionId, { route, modelId, verified, observed, expires: Date.now() + WORKBENCH_PLAN_TTL_MS })
1328
+ return {
1329
+ ok: true,
1330
+ value: {
1331
+ detectionId,
1332
+ route,
1333
+ modelId,
1334
+ verified,
1335
+ ...(verified ? {} : { error: errorCode || 'model-detection-failed' }),
1336
+ ...(observed.input?.includes('image') ? { observedImage: true } : {}),
1337
+ ...(observed.reasoningEfforts !== undefined ? { reasoningEfforts: observed.reasoningEfforts } : {}),
1338
+ ...(fields.contextWindow?.status === 'agreed' ? { suggestedContextWindow: fields.contextWindow.value } : {}),
1339
+ ...(fields.maxTokens?.status === 'agreed' ? { suggestedMaxTokens: fields.maxTokens.value } : {}),
1340
+ ...(fields.name?.status === 'agreed' ? { suggestedName: fields.name.value } : {}),
1341
+ fields,
1342
+ },
1343
+ }
1344
+ } catch (error) {
1345
+ return { ok: false, error: error?.message || String(error) }
1346
+ }
1347
+ } },
1348
+
1349
+ // ── 简易添加流:已配置模型的逐字段建议(零网络:纯目录多源共识)────────────────
1350
+ 'models2-model-suggestions': { feature: 'modelCatalog', handle: async (payload) => {
1351
+ try {
1352
+ const route = typeof payload?.route === 'string' ? payload.route : ''
1353
+ const modelIds = Array.isArray(payload?.modelIds) ? payload.modelIds.filter((id) => typeof id === 'string' && id !== '') : []
1354
+ if (modelIds.length === 0 || modelIds.length > 64) return fail('bad-model-ids')
1355
+ const profile = profileFor(route, modelSettingsFor(ctx))
1356
+ if (profile === undefined) return fail('route-gone')
1357
+ const piIndex = await ensurePiCatalog()
1358
+ const suggestions = {}
1359
+ for (const modelId of modelIds) {
1360
+ const ordered = orderedCandidates(piIndex, route, modelId)
1361
+ if (ordered.length === 0) continue
1362
+ suggestions[modelId] = { fields: consensusFields(ordered) }
1363
+ }
1364
+ return { ok: true, value: { route, suggestions } }
1365
+ } catch (error) {
1366
+ return { ok: false, error: error?.message || String(error) }
1367
+ }
1368
+ } },
1369
+
1370
+ // ── 简易添加流:联网查证(OpenRouter 模型档案,固定域名)────────────────────────
1371
+ 'models2-model-verify-online': { feature: 'modelCatalog', handle: async (payload) => {
1372
+ try {
1373
+ const route = typeof payload?.route === 'string' ? payload.route : ''
1374
+ const modelId = typeof payload?.modelId === 'string' ? payload.modelId.trim() : ''
1375
+ if (modelId === '' || modelId.length > MAX_MODEL_ID_LENGTH || !MODEL_ID_RE.test(modelId)) return fail('bad-model-id')
1376
+ const profile = profileFor(route, modelSettingsFor(ctx))
1377
+ if (profile === undefined) return fail('route-gone')
1378
+ // ID 自带作者(author/slug)直查档案端点;否则目录搜索 + 精确匹配定位完整条目。
1379
+ const source = openRouterVerifySource(modelId)
1380
+ let result
1381
+ try {
1382
+ const text = await fetchDocText(source.url, { accept: 'application/json', userAgent: `dsh-service/${pluginVersion} (DeepSeek Harness plugin)` })
1383
+ let parsed
1384
+ try {
1385
+ parsed = JSON.parse(text.trim())
1386
+ } catch (_) {
1387
+ result = { name: source.name, url: source.url, status: 'error', error: 'bad-json' }
1388
+ }
1389
+ if (result === undefined) {
1390
+ let model
1391
+ if (source.kind === 'search') {
1392
+ const match = pickOpenRouterMatch(modelId, parsed?.data)
1393
+ if (match === undefined) result = { name: source.name, url: source.url, status: 'not-found' }
1394
+ else {
1395
+ model = match
1396
+ result = { name: source.name, url: source.url, href: `https://openrouter.ai/models/${encodeURIComponent(match.id)}` }
1397
+ }
1398
+ } else {
1399
+ model = parsed
1400
+ result = { name: source.name, url: source.url, href: source.href }
1401
+ }
1402
+ if (result.status !== 'not-found') {
1403
+ const facts = openRouterModelFacts(model)
1404
+ if (facts.length === 0) result = { name: source.name, url: source.url, status: 'no-match' }
1405
+ else {
1406
+ result.snippets = facts
1407
+ result.status = 'ok'
1408
+ }
1409
+ }
1410
+ }
1411
+ } catch (error) {
1412
+ const message = error?.message || String(error)
1413
+ // HTTP 404 = 目录未收录(确定性否定);其余错误照实上报,不伪装成 not-found。
1414
+ result = message === 'http-status:404'
1415
+ ? { name: source.name, url: source.url, status: 'not-found' }
1416
+ : { name: source.name, url: source.url, status: 'error', error: message }
1417
+ }
1418
+ return { ok: true, value: { route, modelId, sources: [result] } }
1419
+ } catch (error) {
1420
+ return { ok: false, error: error?.message || String(error) }
1421
+ }
1422
+ } },
1423
+
1424
+ // ── 简易添加流:准备(候选手柄/检测句柄 → 追加清洗后的模型行)───────────────────
1425
+ 'models2-model-prepare': { feature: 'modelCatalog', handle: async (payload) => {
1426
+ try {
1427
+ const route = typeof payload?.route === 'string' ? payload.route : ''
1428
+ const profile = profileFor(route, modelSettingsFor(ctx))
1429
+ if (profile === undefined) return fail('route-gone')
1430
+ const additions = Array.isArray(payload?.additions) ? payload.additions : []
1431
+ // 手填 ID 也必须算作「有东西要加」:只看 additions 会让「纯手填」提交被 no-models 挡死,
1432
+ // 而手填是发现失败/协议不支持时的唯一出路(规格 §3「始终保留手动输入 ID 的路径」)。
1433
+ const manualIds = Array.isArray(payload?.manualIds) ? payload.manualIds.filter((id) => typeof id === 'string') : []
1434
+ if (additions.length + manualIds.length === 0) return fail('no-models')
1435
+ if (additions.length > 200 || manualIds.length > 200) return fail('no-models')
1436
+ // ── 内置渠道的继承清单保护(规格 §4)──────────────────────────────────────
1437
+ // `dsh-llm-pi-ai/lib/index.js:652` 是 `configured.length > 0 ? configured : defaults`:
1438
+ // 非空 models[] **替换**继承清单。所以对「未显式声明 models、正在继承目录」的内置渠道追加
1439
+ // 一个模型,写下去的 `models: [新模型]` 会把整份内置清单挤掉——这正是 §4 禁止的默认路径,
1440
+ // 也是 §8「不把未配置映射为空清单再写回」要挡的操作。这里明确拒绝并给出可执行的两条路,
1441
+ // 而不是裸报错或偷偷复制整份目录(§4 同时否掉了「复制全部内置模型后追加」)。
1442
+ const piIndexForGuard = await ensurePiCatalog()
1443
+ if (piIndexForGuard.channelIds.has(route) && profile.models.length === 0) {
1444
+ return fail('builtin-inherited-list')
1445
+ }
1446
+ const nextModels = profile.models.map((model) => (model !== null && typeof model === 'object' ? { ...model } : model))
1447
+ const sources = []
1448
+ const seen = new Set(profile.models.map((model) => model?.id).filter((id) => typeof id === 'string'))
1449
+ for (const item of additions) {
1450
+ if (item === null || typeof item !== 'object') return fail('bad-models')
1451
+ let source = 'manual'
1452
+ let candidate
1453
+ if (typeof item.optionHandle === 'string') {
1454
+ // 句柄是一次性的:取出即作废,防重放与跨路由挪用。
1455
+ const handle = takeHandle(optionHandles, item.optionHandle)
1456
+ if (handle === undefined || Date.now() > handle.expires || handle.route !== route) return fail('unknown-option')
1457
+ candidate = handle.candidate
1458
+ source = handle.source
1459
+ } else if (typeof item.detectionId === 'string') {
1460
+ const detection = takeHandle(detections, item.detectionId)
1461
+ if (detection === undefined || Date.now() > detection.expires || detection.route !== route) return fail('unknown-detection')
1462
+ if (!detection.verified && item.allowUnverified !== true) return fail('model-unverified')
1463
+ candidate = { id: detection.modelId, observed: detection.observed, verified: detection.verified }
1464
+ } else {
1465
+ return fail('model-detection-required')
1466
+ }
1467
+ const modelId = typeof candidate?.id === 'string' ? candidate.id : ''
1468
+ if (modelId === '' || modelId.length > MAX_MODEL_ID_LENGTH || !MODEL_ID_RE.test(modelId)) return fail('bad-model-id')
1469
+ if (seen.has(modelId)) return fail('duplicate-model')
1470
+ seen.add(modelId)
1471
+ const abilities = item.abilities !== null && typeof item.abilities === 'object' ? item.abilities : item
1472
+ const explicit = abilityPatch({ image: abilities?.image ?? 'observed', reasoning: abilities?.reasoning ?? 'observed', observed: candidate.observed })
1473
+ if (Number.isInteger(abilities?.contextWindow) && abilities.contextWindow > 0) explicit.contextWindow = abilities.contextWindow
1474
+ if (Number.isInteger(abilities?.maxTokens) && abilities.maxTokens > 0) explicit.maxTokens = abilities.maxTokens
1475
+ const explicitName = typeof abilities?.name === 'string' && abilities.name.trim() !== '' && abilities.name.trim().length <= 128
1476
+ ? abilities.name.trim()
1477
+ : (typeof candidate?.name === 'string' && candidate.name !== '' ? candidate.name : '')
1478
+ nextModels.push({ id: modelId, ...(explicitName !== '' ? { name: explicitName } : {}), ...explicit })
1479
+ sources.push({ modelId, source, ...(candidate.providerId ? { providerId: candidate.providerId } : {}) })
1480
+ }
1481
+ // 手填 ID:只接受 ID 文本,wire-id 契约校验 + 去重;字段值一律由宿主从目录补,
1482
+ // 客户端既不能自造字段值,也不能凭空塞入未知键。
1483
+ for (const rawId of manualIds) {
1484
+ const modelId = rawId.trim().slice(0, MAX_MODEL_ID_LENGTH)
1485
+ if (modelId === '' || !MODEL_ID_RE.test(modelId)) return fail('bad-model-id')
1486
+ if (seen.has(modelId)) return fail('duplicate-model')
1487
+ seen.add(modelId)
1488
+ const meta = modelMetaCandidates(await ensurePiCatalog(), modelId)
1489
+ .find((candidate) => candidate.source === 'pi-ai' && candidate.providerId === route)
1490
+ ?? modelMetaCandidates(await ensurePiCatalog(), modelId)[0]
1491
+ if (meta === undefined) {
1492
+ // 目录无此模型:只写 ID(后续可在单模型面板补参数),不猜任何能力字段。
1493
+ nextModels.push({ id: modelId })
1494
+ } else {
1495
+ nextModels.push({ id: modelId, ...(typeof meta.name === 'string' && meta.name !== '' ? { name: meta.name } : {}), ...abilityPatch({ image: 'observed', reasoning: 'observed', observed: { input: meta.input, reasoningEfforts: meta.reasoningEfforts } }) })
1496
+ }
1497
+ sources.push({ modelId, source: 'manual' })
1498
+ }
1499
+ const checked = selfCheckModelsPatch(nextModels)
1500
+ if (!checked.ok) return fail('schema-invalid', checked.errors.join(';'))
1501
+ const planId = planIdFor(`simple:${route}:${randomUUID()}`)
1502
+ const expires = Date.now() + WORKBENCH_PLAN_TTL_MS
1503
+ rememberHandle(modelPlans, planId, { route, nextModels, sources, baseFingerprint: profileFingerprint(profile), baseRevision: settingsRevisionOf(modelSettingsFor(ctx)), expires })
1504
+ return {
1505
+ ok: true,
1506
+ value: {
1507
+ planId,
1508
+ route,
1509
+ expires,
1510
+ diff: diffModelLists(profile.models, nextModels),
1511
+ warnings: additions.some((item) => item.allowUnverified === true) ? ['unverified-model'] : [],
1512
+ },
1513
+ }
1514
+ } catch (error) {
1515
+ return { ok: false, error: error?.message || String(error) }
1516
+ }
1517
+ } },
1518
+
1519
+ // ── 简易添加流:提交(复检后有界写入;内置来源的绑定一并持久化)──────────────────
1520
+ 'models2-model-commit': { feature: 'modelCatalog', handle: async (payload) => {
1521
+ try {
1522
+ const planId = typeof payload?.planId === 'string' ? payload.planId : ''
1523
+ const plan = takeHandle(modelPlans, planId)
1524
+ if (plan === undefined) return fail('unknown-plan')
1525
+ if (Date.now() > plan.expires) return fail('plan-expired')
1526
+ const settingsService = modelSettingsFor(ctx)
1527
+ const profile = profileFor(plan.route, settingsService)
1528
+ if (profile === undefined) return fail('route-gone')
1529
+ if (workbenchStore !== undefined && (await workbenchStore.load()).recovery?.[plan.route] !== undefined) return fail('recovery-pending')
1530
+ if (profileFingerprint(profile) !== plan.baseFingerprint) return fail('conflict')
1531
+ // 最终确认后的有效性复检:拒绝写入已失效的计划。
1532
+ const recheck = selfCheckModelsPatch(plan.nextModels)
1533
+ if (!recheck.ok) return fail('schema-invalid', recheck.errors.join(';'))
1534
+ const undoSnapshot = { models: profile.models, at: Date.now() }
1535
+ try {
1536
+ await mutateModels(settingsService, plan.route, plan.nextModels, plan.baseRevision)
1537
+ } catch (error) {
1538
+ if (isSettingsConflict(error)) return fail('conflict')
1539
+ throw error
1540
+ }
1541
+ if (workbenchStore !== undefined) {
1542
+ try {
1543
+ await workbenchStore.mutate((sidecar) => {
1544
+ if (sidecar.providers === undefined) sidecar.providers = {}
1545
+ sidecar.providers[plan.route] = undoSnapshot
1546
+ // 内置目录来源的模型记绑定:下次仲裁可高置信定位到该渠道。
1547
+ for (const entry of plan.sources) {
1548
+ if (entry.source !== 'builtin') continue
1549
+ if (sidecar.bindings === undefined) sidecar.bindings = {}
1550
+ const bound = sidecar.bindings[plan.route]
1551
+ if (bound === null || typeof bound !== 'object' || Array.isArray(bound)) sidecar.bindings[plan.route] = {}
1552
+ sidecar.bindings[plan.route][entry.modelId] = { source: 'pi-ai', providerId: entry.providerId ?? plan.route }
1553
+ }
1554
+ })
1555
+ } catch (_) {}
1556
+ }
1557
+ return { ok: true, value: { route: plan.route, changed: true, modelsChanged: plan.sources.length, undoable: true } }
1558
+ } catch (error) {
1559
+ return { ok: false, error: error?.message || String(error) }
1560
+ }
1561
+ } },
1562
+
1563
+ // ── 移除预览(守卫):静态引用检测 + 可迁移目标清单 ──────────────────────────
1564
+ 'models2-remove-preview': { feature: 'modelCatalog', handle: async (payload) => {
1565
+ try {
1566
+ const route = typeof payload?.route === 'string' ? payload.route : ''
1567
+ const profile = profileFor(route, modelSettingsFor(ctx))
1568
+ if (profile === undefined) return fail('route-gone')
1569
+ const removeIds = Array.isArray(payload?.removeIds)
1570
+ ? payload.removeIds.filter((id) => typeof id === 'string').map((id) => id.slice(0, MAX_MODEL_ID_LENGTH))
1571
+ : []
1572
+ const currentIds = new Set(profile.models.map((model) => model?.id))
1573
+ for (const id of removeIds) {
1574
+ if (!currentIds.has(id)) return fail('unknown-model', id.slice(0, 64))
1575
+ }
1576
+ if (removeIds.length === 0) return fail('no-models')
1577
+ const remaining = profile.models.filter((model) => !removeIds.includes(model?.id))
1578
+ // 最后一个模型的语义(0.1.6-alpha.2 核实:`resolveRouteModels` 的空数组回落内置目录,
1579
+ // 删字段与空数组都不等于禁用渠道)。这里不裸报 no-models,而是给出宿主侧可执行的两条路。
1580
+ if (remaining.length === 0) return fail('last-model-undeletable')
1581
+ const references = await inspectRemoveReferences(route)
1582
+ const hardBlocked = references.some((ref) => ref.kind === 'agent-default-model')
1583
+ // 子代理引用只在「被移除的模型正是引用模型」时才需要迁移。
1584
+ const subagentRef = references.find((ref) => ref.kind === 'subagent-route' && removeIds.includes(ref.model))
1585
+ const migrateOptions = hardBlocked || subagentRef === undefined ? [] : await subagentModelOptions()
1586
+ return {
1587
+ ok: true,
1588
+ value: {
1589
+ route,
1590
+ models: profile.models.map((model) => ({ id: model?.id ?? '', name: typeof model?.name === 'string' ? model.name : undefined })),
1591
+ removeIds,
1592
+ remainingCount: remaining.length,
1593
+ references,
1594
+ blocked: hardBlocked,
1595
+ ...(migrateOptions.length > 0 ? { migrateOptions } : {}),
1596
+ },
1597
+ }
1598
+ } catch (error) {
1599
+ return { ok: false, error: error?.message || String(error) }
1600
+ }
1601
+ } },
1602
+
1603
+ // ── 移除计划:hard block / 必须迁移 / 迁移目标必须在可用清单内 ─────────────────
1604
+ 'models2-remove-plan': { feature: 'modelCatalog', handle: async (payload) => {
1605
+ try {
1606
+ const route = typeof payload?.route === 'string' ? payload.route : ''
1607
+ const profile = profileFor(route, modelSettingsFor(ctx))
1608
+ if (profile === undefined) return fail('route-gone')
1609
+ const removeIds = Array.isArray(payload?.removeIds)
1610
+ ? payload.removeIds.filter((id) => typeof id === 'string').map((id) => id.slice(0, MAX_MODEL_ID_LENGTH))
1611
+ : []
1612
+ const currentIds = new Set(profile.models.map((model) => model?.id))
1613
+ for (const id of removeIds) {
1614
+ if (!currentIds.has(id)) return fail('unknown-model', id.slice(0, 64))
1615
+ }
1616
+ if (removeIds.length === 0) return fail('no-models')
1617
+ if (profile.models.filter((model) => !removeIds.includes(model?.id)).length === 0) return fail('last-model-undeletable')
1618
+ const references = await inspectRemoveReferences(route)
1619
+ if (references.some((ref) => ref.kind === 'agent-default-model')) return fail('removal-blocked')
1620
+ const hasSubagentRef = references.some((ref) => ref.kind === 'subagent-route' && removeIds.includes(ref.model))
1621
+ const migrateTo = payload?.migrateTo !== null && typeof payload?.migrateTo === 'object' ? payload.migrateTo : undefined
1622
+ if (hasSubagentRef && migrateTo === undefined) return fail('migration-required')
1623
+ if (!hasSubagentRef && migrateTo !== undefined) return fail('no-migration-needed')
1624
+ let normalizedMigration
1625
+ if (hasSubagentRef) {
1626
+ const provider = typeof migrateTo?.provider === 'string' ? migrateTo.provider.trim() : ''
1627
+ const model = typeof migrateTo?.model === 'string' ? migrateTo.model.trim() : ''
1628
+ if (provider === '' || model === '') return fail('invalid-model-route')
1629
+ // 迁移目标必须仍在宿主可用清单内(拒绝客户端自造路由)。
1630
+ const options = await subagentModelOptions()
1631
+ if (!options.some((group) => group.provider === provider && group.models.some((row) => row.id === model))) return fail('invalid-model-route')
1632
+ normalizedMigration = { provider: provider.slice(0, 256), model: model.slice(0, 256) }
1633
+ }
1634
+ const planId = planIdFor(`${route}:remove:${randomUUID()}`)
1635
+ const expires = Date.now() + WORKBENCH_PLAN_TTL_MS
1636
+ rememberHandle(plans, planId, {
1637
+ route,
1638
+ kind: 'remove',
1639
+ removeIds,
1640
+ originalModels: profile.models.map((model) => (model !== null && typeof model === 'object' ? { ...model } : model)),
1641
+ baseFingerprint: profileFingerprint(profile),
1642
+ baseRevision: settingsRevisionOf(modelSettingsFor(ctx)),
1643
+ appliedCount: removeIds.length,
1644
+ ...(normalizedMigration !== undefined ? { migrateTo: normalizedMigration } : {}),
1645
+ expires,
1646
+ })
1647
+ return {
1648
+ ok: true,
1649
+ value: {
1650
+ planId,
1651
+ route,
1652
+ expires,
1653
+ removed: removeIds.length,
1654
+ ...(normalizedMigration !== undefined ? { migrateTo: normalizedMigration } : {}),
1655
+ },
1656
+ }
1657
+ } catch (error) {
1658
+ return { ok: false, error: error?.message || String(error) }
1659
+ }
1660
+ } },
1661
+
1662
+ // ── 移除提交:模型移除与子代理迁移同处一个跨侧车事务,任一步失败立即补偿 ───────
1663
+ 'models2-remove-commit': { feature: 'modelCatalog', handle: async (payload) => {
1664
+ try {
1665
+ const planId = typeof payload?.planId === 'string' ? payload.planId : ''
1666
+ const plan = takeHandle(plans, planId)
1667
+ if (plan === undefined || plan.kind !== 'remove') return fail('unknown-plan')
1668
+ if (Date.now() > plan.expires) return fail('plan-expired')
1669
+ const settingsService = modelSettingsFor(ctx)
1670
+ const profile = profileFor(plan.route, settingsService)
1671
+ if (profile === undefined) return fail('route-gone')
1672
+ if (workbenchStore !== undefined && (await workbenchStore.load()).recovery?.[plan.route] !== undefined) return fail('recovery-pending')
1673
+ if (profileFingerprint(profile) !== plan.baseFingerprint) return fail('conflict')
1674
+ const nextModels = profile.models.filter((model) => !plan.removeIds.includes(model?.id))
1675
+ try {
1676
+ await mutateModels(settingsService, plan.route, nextModels, plan.baseRevision)
1677
+ } catch (error) {
1678
+ // 并发漂移与「写失败」是两种后果:前者要客户端重算计划,不该混进 remove-failed。
1679
+ if (isSettingsConflict(error)) return fail('conflict')
1680
+ return fail('remove-failed', error?.message || String(error))
1681
+ }
1682
+ if (plan.migrateTo !== undefined) {
1683
+ // 子代理迁移与模型移除同处一个跨侧车事务:任一步失败立即补偿(写回原模型)。
1684
+ try {
1685
+ await writeSubagentRoute(async (config) => {
1686
+ config.mode = 'custom'
1687
+ config.provider = plan.migrateTo.provider
1688
+ config.model = plan.migrateTo.model
1689
+ delete config.reasoningEffort
1690
+ return { save: true }
1691
+ })
1692
+ } catch (error) {
1693
+ try {
1694
+ // 补偿写回**不带 expectedRevision**:此刻 revision 已被我们自己的主写推进,
1695
+ // 带上旧值必然失败;回滚是尽力而为,不能因版本比对而放弃。
1696
+ await settingsService.mutate('llm-pi-ai', [{ op: 'set', path: ['providers', plan.route, 'models'], value: plan.originalModels }])
1697
+ } catch (compError) {
1698
+ // 补偿也失败 → 人工恢复阻断态,恢复前禁止对该路由的自动写入。
1699
+ if (workbenchStore !== undefined) {
1700
+ try {
1701
+ await workbenchStore.mutate((sidecar) => {
1702
+ if (sidecar.recovery === undefined) sidecar.recovery = {}
1703
+ sidecar.recovery[plan.route] = { reason: 'remove-rollback-failed', at: Date.now() }
1704
+ })
1705
+ } catch (_) {}
1706
+ }
1707
+ return fail('remove-failed', compError?.message || String(compError))
1708
+ }
1709
+ return fail('remove-failed', error?.message || String(error))
1710
+ }
1711
+ }
1712
+ // 写后撤销快照:移除可撤销(恢复被移除模型)。
1713
+ let undoable = true
1714
+ if (workbenchStore !== undefined) {
1715
+ try {
1716
+ await workbenchStore.mutate((sidecar) => {
1717
+ if (sidecar.providers === undefined) sidecar.providers = {}
1718
+ sidecar.providers[plan.route] = { models: plan.originalModels, at: Date.now() }
1719
+ })
1720
+ } catch (_) {
1721
+ undoable = false
1722
+ }
1723
+ } else {
1724
+ undoable = false
1725
+ }
1726
+ return {
1727
+ ok: true,
1728
+ value: {
1729
+ route: plan.route,
1730
+ removed: plan.removeIds.length,
1731
+ ...(plan.migrateTo !== undefined ? { migrated: true } : {}),
1732
+ undoable,
1733
+ },
1734
+ }
1735
+ } catch (error) {
1736
+ return { ok: false, error: error?.message || String(error) }
1737
+ }
1738
+ } },
1739
+ }
1740
+ }