@gehennawu/dsh-service 2.0.6 → 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.
package/index.js CHANGED
@@ -20,6 +20,46 @@ import { createSessionsRoutes } from './session-routes.js'
20
20
  import { createQuotaRoutes } from './quota-routes.js'
21
21
  import { createSubagentRoutes } from './subagent-routes.js'
22
22
  import { createBackupRoutes } from './backup-routes.js'
23
+ import { createModelConfigRoutes } from './model-config-routes.js'
24
+ import { createProviderAvailability, installProviderAvailability } from './provider-availability.js'
25
+ import { createWorkbenchStore, loadPiAiCatalog } from './model-workbench.js'
26
+ import {
27
+ WORKBENCH_SIDECAR_VERSION,
28
+ applySuggestions,
29
+ applyUndo,
30
+ arbitrateModelSuggestions,
31
+ buildPiAiIndex,
32
+ abilityPatch,
33
+ consensusFields,
34
+ createFreshnessManager,
35
+ deriveKeyRefFor,
36
+ diffModelLists,
37
+ emptyWorkbenchSidecar,
38
+ fetchModelsProbe,
39
+ hostnameOf,
40
+ indexModelParamsDev,
41
+ isPrivateIpAddress,
42
+ loadModelParamsSnapshot,
43
+ loadWorkbenchSidecar,
44
+ modelParamsCandidates,
45
+ modelParamsDevEfforts,
46
+ modelPatchFromPiAi,
47
+ openRouterModelFacts,
48
+ openRouterVerifySource,
49
+ pickOpenRouterMatch,
50
+ modelsProbeLookup,
51
+ normalizeBaseURLForMatch,
52
+ parseModelListing,
53
+ probeBaseURLCheck,
54
+ profileFingerprint,
55
+ readLlmProfiles,
56
+ saveModelParamsSnapshot,
57
+ saveWorkbenchSidecar,
58
+ selfCheckModelsPatch,
59
+ suggestRouteId,
60
+ suggestionId,
61
+ undoPreviewModels,
62
+ } from './model-workbench.js'
23
63
  import { collectPluginHealth, pluginCheckItem, restartPluginEntry } from './plugin-health.js'
24
64
  import {
25
65
  buildCliproxyAccountPlan as buildCliproxyAccountPlanAdapter,
@@ -88,6 +128,8 @@ const DEFAULT_FEATURE_SETTINGS = Object.freeze({
88
128
  // v1.8 新增:composer 模型按钮显示厂家/渠道图标(宽态加在模型名前,
89
129
  // 窄态替换官方默认图标;未适配渠道保留官方默认图标)。
90
130
  modelProviderIcons: true,
131
+ // v1.10 新增:模型配置工作台(供应商与模型 / 子代理路由),写路径经两段式确认。
132
+ modelCatalog: true,
91
133
  })
92
134
  /** 标记为「可热更新」的 Config 字段(0.1.7-alpha.1 的表单与原地热更新以此为准)。 */
93
135
  const volatileField = (schema) => (typeof schema?.extra === 'function' ? schema.extra('volatile', true) : schema)
@@ -238,6 +280,11 @@ const QUOTA_MIN_INTERVAL_MS = 15000
238
280
  const QUOTA_MANUAL_COOLDOWN_MS = 5000
239
281
  const QUOTA_BACKOFF_BASE_MS = 30000
240
282
  const QUOTA_BACKOFF_MAX_MS = 15 * 60 * 1000
283
+ // 确定性失败矩阵:自动重试不会改变结果,只会白打上游(CPA 还会累计 fail2ban 失败次数、把 IP 打进 30 分钟封禁)。
284
+ // 命中即把该 provider 转入「手动模式」——此后只认手动重试这一条路,直到一次成功落定或用户改配置/凭据。
285
+ // 表外(network / network-transient / timeout / 5xx / upstream-error / cancelled)是可能自愈的瞬时失败,
286
+ // 保留指数退避自动重试。与 QUOTA_UNUSABLE_ERROR_RE 无关:这里只决定「还要不要自动打上游」。
287
+ const QUOTA_MANUAL_ONLY_ERROR_RE = /^(credential-missing|credential-rejected|credentials-unavailable|no-base-url|no-subscription|host-not-pinned|mgmt-disabled|mgmt-banned|transport-unavailable|bad-payload|http-status:4\d\d|upstream-status:4\d\d)/i
241
288
  const QUOTA_CONFIG_STAT_TTL_MS = 5000
242
289
  const QUOTA_MAX_CONCURRENCY = 4
243
290
  const MAX_QUOTA_RESPONSE_BYTES = 1024 * 1024
@@ -601,9 +648,42 @@ function backupMetadataPayload(version, createdAt) {
601
648
  dshVersion: typeof version === 'string' ? version : '',
602
649
  pluginVersion: typeof pluginVersion === 'string' ? pluginVersion : '',
603
650
  createdAt: createdAt instanceof Date ? createdAt.toISOString() : new Date().toISOString(),
651
+ // 来源平台/架构(v0.47):跨设备恢复时用来提示「这份快照出自哪个系统」。
652
+ // 与其它字段一样全部来自宿主常量,浏览器不参与;旧版插件读新归档时忽略这两个键。
653
+ platform: process.platform,
654
+ arch: process.arch,
604
655
  }, null, 2)}\n`, 'utf8')
605
656
  }
606
657
 
658
+ // 目标平台的事实来源:执行恢复的那台机器的进程平台。声明式覆盖只服务测试与验证
659
+ // (与 DSH_SERVICE_RUNTIME_ENV 同规),它只能影响只读提示与错误措辞,**不放宽**任何
660
+ // 归档校验——否则「声明成 Linux」就成了把 Windows 非法文件名塞进写入路径的后门。
661
+ function readBackupPlatform() {
662
+ const declared = process.env.DSH_SERVICE_BACKUP_PLATFORM
663
+ return typeof declared === 'string' && /^[a-z][a-z0-9_-]{0,15}$/.test(declared) ? declared : process.platform
664
+ }
665
+
666
+ // 目标机可用于承载恢复内容的 profile 名清单(宿主侧 readdir 事实,浏览器只在该清单内
667
+ // 选名字)。名字口径与备份侧白名单同规:单一路径段、长度封顶。
668
+ async function listLocalProfiles(dshHome) {
669
+ const root = join(dshHome, 'profiles')
670
+ let entries
671
+ try { entries = await readdir(root, { withFileTypes: true }) } catch (error) {
672
+ if (error?.code === 'ENOENT') return []
673
+ throw error
674
+ }
675
+ const profiles = []
676
+ for (const entry of entries) {
677
+ if (!entry.isDirectory() || !/^[A-Za-z0-9._-]{1,64}$/.test(entry.name) || entry.name === '.' || entry.name === '..') continue
678
+ profiles.push({
679
+ name: entry.name,
680
+ hasManifest: await pathExists(join(root, entry.name, 'package.json')),
681
+ hasPatch: await pathExists(join(root, entry.name, 'cordis.patch.yml')),
682
+ })
683
+ }
684
+ return profiles.sort((a, b) => a.name.localeCompare(b.name))
685
+ }
686
+
607
687
  // 从归档名解析版本段(列表展示用,零 I/O)。只有 Host 自己签发的名字会走到这里,
608
688
  // 但正则本身也保证不会把任意文本当版本号回给浏览器。
609
689
  function backupVersionFromName(name) {
@@ -2241,8 +2321,9 @@ async function requestQuotaJson(endpoint, options = {}) {
2241
2321
  }
2242
2322
 
2243
2323
  // 每 provider 节流状态机(内存态,重启清零)。一切来源共用同一判定,优先序:
2244
- // 单飞去重 > 失败指数退避(30s×2 封顶 15min)> 成功 TTL 60s > 最小上游间隔 15s(与 attempt()
2245
- // 判定序一致);now 由调用方注入,测试可推进假时钟。
2324
+ // 单飞去重 > 确定性失败后的手动模式 > 失败指数退避(30s×2 封顶 15min)> 成功 TTL 60s >
2325
+ // 最小上游间隔 15s(与 attempt() 判定序一致);手动模式只由「一次成功落定」或用户写操作
2326
+ // (resetGates)解除,中途不会因时间流逝自动恢复。now 由调用方注入,测试可推进假时钟。
2246
2327
  function createQuotaThrottle(options = {}) {
2247
2328
  const successTtlMs = options.successTtlMs ?? QUOTA_SUCCESS_TTL_MS
2248
2329
  const minIntervalMs = options.minIntervalMs ?? QUOTA_MIN_INTERVAL_MS
@@ -2253,7 +2334,7 @@ function createQuotaThrottle(options = {}) {
2253
2334
  const entryOf = (provider) => {
2254
2335
  let entry = entries.get(provider)
2255
2336
  if (entry === undefined) {
2256
- entry = { lastSuccessAt: 0, lastUpstreamAt: 0, lastManualAt: 0, backoffUntil: 0, failures: 0, inflight: false, windows: undefined, fetchedAt: 0, lastError: undefined, lastErrorDetail: undefined, lastErrorEndpoint: undefined, lastErrorAccount: undefined }
2337
+ entry = { lastSuccessAt: 0, lastUpstreamAt: 0, lastManualAt: 0, backoffUntil: 0, failures: 0, inflight: false, manualOnly: false, windows: undefined, fetchedAt: 0, lastError: undefined, lastErrorDetail: undefined, lastErrorEndpoint: undefined, lastErrorAccount: undefined }
2257
2338
  entries.set(provider, entry)
2258
2339
  }
2259
2340
  return entry
@@ -2273,7 +2354,7 @@ function createQuotaThrottle(options = {}) {
2273
2354
  lastErrorAccount: entry.lastErrorAccount,
2274
2355
  }
2275
2356
  },
2276
- /** 只读快照:缓存窗口、是否刷新中、下次允许发起上游的时间(null=进行中未知)。 */
2357
+ /** 只读快照:缓存窗口、是否刷新中、是否已转手动、下次允许自动发起上游的时间(null=进行中/手动模式)。 */
2277
2358
  view(provider, now = Date.now()) {
2278
2359
  const entry = entryOf(provider)
2279
2360
  return {
@@ -2284,21 +2365,28 @@ function createQuotaThrottle(options = {}) {
2284
2365
  lastErrorDetail: entry.lastErrorDetail,
2285
2366
  lastErrorEndpoint: entry.lastErrorEndpoint,
2286
2367
  lastErrorAccount: entry.lastErrorAccount,
2287
- nextAllowedAt: entry.inflight
2368
+ // 手动模式:自动路径一律拒发,故没有「下次自动重试时刻」可报(客户端据此换文案)。
2369
+ manualOnly: entry.manualOnly === true,
2370
+ nextAllowedAt: entry.inflight || entry.manualOnly
2288
2371
  ? null
2289
2372
  : Math.max(entry.backoffUntil, entry.lastUpstreamAt + minIntervalMs, entry.lastSuccessAt + successTtlMs),
2290
2373
  }
2291
2374
  },
2292
- /** 申请一次上游调用。允许则置单飞并返回 ok;拒绝时给出稳定原因与 nextAllowedAt(优先序:单飞 > 退避 > 成功 TTL > 最小间隔)。 */
2293
- attempt(provider, now = Date.now()) {
2375
+ /** 申请一次上游调用。允许则置单飞并返回 ok;拒绝时给出稳定原因与 nextAllowedAt
2376
+ * (优先序:单飞 > 手动模式 > 退避 > 成功 TTL > 最小间隔)。
2377
+ * options.manual=true 是「用户手动连接」:跳过手动模式与三道时间闸,只受 force() 的冷却约束。 */
2378
+ attempt(provider, now = Date.now(), options = {}) {
2294
2379
  const entry = entryOf(provider)
2295
2380
  if (entry.inflight) return { ok: false, reason: 'inflight', nextAllowedAt: null }
2296
- if (now < entry.backoffUntil) return { ok: false, reason: 'backoff', nextAllowedAt: entry.backoffUntil }
2297
- if (now - entry.lastSuccessAt < successTtlMs) {
2298
- return { ok: false, reason: 'fresh', nextAllowedAt: entry.lastSuccessAt + successTtlMs }
2299
- }
2300
- if (entry.lastUpstreamAt > 0 && now - entry.lastUpstreamAt < minIntervalMs) {
2301
- return { ok: false, reason: 'interval', nextAllowedAt: entry.lastUpstreamAt + minIntervalMs }
2381
+ if (options.manual !== true) {
2382
+ if (entry.manualOnly) return { ok: false, reason: 'manual-only', nextAllowedAt: null }
2383
+ if (now < entry.backoffUntil) return { ok: false, reason: 'backoff', nextAllowedAt: entry.backoffUntil }
2384
+ if (now - entry.lastSuccessAt < successTtlMs) {
2385
+ return { ok: false, reason: 'fresh', nextAllowedAt: entry.lastSuccessAt + successTtlMs }
2386
+ }
2387
+ if (entry.lastUpstreamAt > 0 && now - entry.lastUpstreamAt < minIntervalMs) {
2388
+ return { ok: false, reason: 'interval', nextAllowedAt: entry.lastUpstreamAt + minIntervalMs }
2389
+ }
2302
2390
  }
2303
2391
  entry.inflight = true
2304
2392
  entry.lastUpstreamAt = now
@@ -2312,6 +2400,8 @@ function createQuotaThrottle(options = {}) {
2312
2400
  entry.lastSuccessAt = now
2313
2401
  entry.failures = 0
2314
2402
  entry.backoffUntil = 0
2403
+ // 唯一解锁点(除 resetGates 的用户写操作外):一次成功落定即回归自动模式。
2404
+ entry.manualOnly = false
2315
2405
  entry.windows = outcome.windows ?? []
2316
2406
  entry.fetchedAt = now
2317
2407
  entry.lastError = undefined
@@ -2324,27 +2414,29 @@ function createQuotaThrottle(options = {}) {
2324
2414
  const delay = Math.min(backoffBaseMs * 2 ** (entry.failures - 1), backoffMaxMs)
2325
2415
  entry.backoffUntil = now + delay
2326
2416
  entry.lastError = typeof outcome.code === 'string' ? outcome.code : 'unknown'
2417
+ // 确定性失败(换凭据/改配置才可能恢复)→ 转手动模式:自动快照不再重打上游。
2418
+ // 瞬时失败保持自动退避,等下一轮快照接续。
2419
+ if (QUOTA_MANUAL_ONLY_ERROR_RE.test(entry.lastError)) entry.manualOnly = true
2327
2420
  entry.lastErrorDetail = typeof outcome.detail === 'string' && outcome.detail !== '' ? outcome.detail : undefined
2328
2421
  // 渠道特有事实(多候选链的失败端点 / CPA 的失败账号):只作附注,与错误码、上游原话分开存。
2329
2422
  entry.lastErrorEndpoint = sanitizeQuotaErrorDetail(outcome.endpoint)
2330
2423
  entry.lastErrorAccount = sanitizeQuotaErrorDetail(outcome.account)
2331
2424
  },
2332
- /** 手动刷新:允许绕过成功 TTL,但保留失败退避,并有不可绕过的硬冷却;单飞仍优先。 */
2425
+ /** 手动重试的准入判定:只受单飞与不可绕过的硬冷却约束——手动模式与失败退避都由调用方的
2426
+ * attempt({ manual: true }) 跳过(用户明确要求「仅可手动连接报错的渠道」,手动不该比自动更受限)。 */
2333
2427
  force(provider, now = Date.now()) {
2334
2428
  const entry = entryOf(provider)
2335
2429
  if (entry.inflight) return { ok: false, reason: 'inflight', nextAllowedAt: null }
2336
- if (now < entry.backoffUntil) return { ok: false, reason: 'backoff', nextAllowedAt: entry.backoffUntil }
2337
2430
  if (entry.lastManualAt > 0 && now - entry.lastManualAt < manualCooldownMs) {
2338
2431
  return { ok: false, reason: 'cooldown', nextAllowedAt: entry.lastManualAt + manualCooldownMs }
2339
2432
  }
2340
2433
  entry.lastManualAt = now
2341
- entry.lastSuccessAt = 0
2342
- entry.lastUpstreamAt = 0
2343
2434
  return { ok: true }
2344
2435
  },
2345
2436
  // 凭据/适配变更后的闸门重置(保存 key/Cookie、改适配类型时调用):旧失败是旧配置造成的,
2346
2437
  // 填完就该立刻重试,不傻等最长 15min 的指数退避(GUI 反馈点名);单飞在途不抢占。
2347
- // 与 force() 的区别:本方法只由宿主写入口触发,可以连硬冷却一并清掉。
2438
+ // 这也是手动模式唯一的「非成功解锁」路径——保存凭据/改适配本身就是用户的手动动作,
2439
+ // 且凭据类错误的「手动连接」正是保存后立刻重试。与 force() 的区别:连硬冷却一并清掉。
2348
2440
  resetGates(provider) {
2349
2441
  const entry = entryOf(provider)
2350
2442
  if (entry.inflight) return { ok: false, reason: 'inflight', nextAllowedAt: null }
@@ -2353,6 +2445,7 @@ function createQuotaThrottle(options = {}) {
2353
2445
  entry.lastManualAt = 0
2354
2446
  entry.lastUpstreamAt = 0
2355
2447
  entry.lastSuccessAt = 0
2448
+ entry.manualOnly = false
2356
2449
  return { ok: true }
2357
2450
  },
2358
2451
  prune(activeProviders) {
@@ -4185,7 +4278,7 @@ function subagentRouteCandidates(request, config, options = {}) {
4185
4278
 
4186
4279
  function resolveSubagentInjection(request, config, options = {}) {
4187
4280
  for (const candidate of subagentRouteCandidates(request, config, options)) {
4188
- if (options.isRoutable !== undefined && !options.isRoutable(candidate.provider)) {
4281
+ if (options.isRoutable !== undefined && !options.isRoutable(candidate.provider, candidate.model)) {
4189
4282
  options.note?.(`${candidate.provider}/${candidate.model} skipped (not routable)`)
4190
4283
  continue
4191
4284
  }
@@ -5270,6 +5363,11 @@ function apply(ctx, featureConfig) {
5270
5363
  runtimeEnv,
5271
5364
  previousInstanceId: instanceId,
5272
5365
  scheduleRestart: () => scheduleRestart(ctx, runtimeEnv),
5366
+ // 目标平台与目标 profile 清单都由宿主侧事实提供:恢复发生在哪台机器上,
5367
+ // 就以哪台机器的进程平台与 profiles 目录为准,浏览器不参与判定。
5368
+ platform: readBackupPlatform(),
5369
+ // 工厂只要名字清单(映射校验用);端点另取带 hasManifest/hasPatch 的完整形态。
5370
+ listProfiles: async () => (await listLocalProfiles(dshHome)).map((profile) => profile.name),
5273
5371
  })
5274
5372
  // 会话管理(v0.35):删除两段式计划(planId → {id, path, bytes}),TTL 过期自动驱逐。
5275
5373
  const sessionDeletePlans = new Map()
@@ -5292,6 +5390,18 @@ function apply(ctx, featureConfig) {
5292
5390
  // 版本一变即自然失效,不必手工清理。
5293
5391
  const releaseNotesCache = new Map()
5294
5392
  const quotaThrottle = createQuotaThrottle()
5393
+ // 模型配置工作台(v1.10):侧车仓库持有写前快照/绑定/验证摘要/人工恢复态。
5394
+ // 写串行化在仓库内部;目录随 DSH 升级才变,进程内缓存一次。
5395
+ const modelProfileDir = ctx.get('profileContext')?.dir ?? process.env.DSH_PROFILE_DIR
5396
+ const workbenchStore = createWorkbenchStore(typeof modelProfileDir === 'string' && modelProfileDir !== '' ? modelProfileDir : join(dshHome, 'dsh-service-model-config', 'legacy'))
5397
+ const providerAvailability = createProviderAvailability(workbenchStore)
5398
+ const notifyProviderAvailability = () => ctx.emit('llm/adapters-updated')
5399
+ ctx.inject(['llm'], (scope) => {
5400
+ const llm = scope.get('llm') ?? ctx.get('llm')
5401
+ if (!llm) return
5402
+ const dispose = installProviderAvailability(llm, providerAvailability, notifyProviderAvailability)
5403
+ scope.effect(() => dispose)
5404
+ })
5295
5405
  // 技能管理(v0.22):侧车索引缓存 + 批量补全状态。批量随 Fiber 销毁中止。
5296
5406
  // 可重赋值的 let 一律以 { current } 箱体跨模块传递:端点工厂解构是值快照,
5297
5407
  // let 重赋值不会穿过解构边界;箱体靠对象身份保持活绑定。
@@ -5369,13 +5479,15 @@ function apply(ctx, featureConfig) {
5369
5479
  const subagentSeamRef = { current: false }
5370
5480
  // 子代理派发记录环(v1.2):apply 级持有,RPC 端点与 seam 共同读写(seam 写入、端点只读)。
5371
5481
  const dispatchRing = { order: [], byChild: new Map() }
5372
- // 只包装宿主 subagents 注册表的两个入口(start / startContinuable,spawn/fork/acp 全走这
5373
- // 两个口):未显式指定模型的派生按配置注入 agentOptions,其余原样透传。Fiber 销毁还原
5374
- // 原方法——包装挂在服务实例上,disposer 期间新派生恢复原生行为。
5482
+ // 按宿主能力包装子代理启动入口:新版 startActivation,旧版 start/startContinuable。
5483
+ // 未显式指定模型的派生按配置注入 agentOptions,其余原样透传;Fiber 销毁时还原原方法。
5375
5484
  ctx.inject(['subagents'], (scope) => {
5376
5485
  const subagents = scope.subagents
5377
- if (subagents === undefined || typeof subagents.start !== 'function' || typeof subagents.startContinuable !== 'function') return
5378
- const isRoutable = (provider) => {
5486
+ const hasActivation = typeof subagents?.startActivation === 'function'
5487
+ const hasLegacyStarts = typeof subagents?.start === 'function' && typeof subagents?.startContinuable === 'function'
5488
+ if (!hasActivation && !hasLegacyStarts) return
5489
+ const isRoutable = (provider, model) => {
5490
+ if (model !== undefined && !providerAvailability.modelEnabled(provider, model)) return false
5379
5491
  const llm = ctx.get('llm')
5380
5492
  if (llm === undefined || typeof llm.listProviders !== 'function') return false
5381
5493
  try {
@@ -5426,6 +5538,8 @@ function apply(ctx, featureConfig) {
5426
5538
  return quotaProviderUnusable(view) === false
5427
5539
  }
5428
5540
  const applyInjection = (request) => {
5541
+ const explicit = request?.agentOptions?.provider
5542
+ if (typeof explicit === 'string' && explicit) providerAvailability.assertEnabled(explicit, request?.agentOptions?.model)
5429
5543
  // 功能关闭:零记录(完全静默,与原生行为一致)——记录是 subagentRoute 功能的一部分。
5430
5544
  if (!featureEnabled('subagentRoute')) return { request, dispatch: undefined }
5431
5545
  const routeOptions = {
@@ -5436,7 +5550,7 @@ function apply(ctx, featureConfig) {
5436
5550
  }
5437
5551
  const injected = resolveSubagentInjection(request, subagentRouteRef.current, routeOptions)
5438
5552
  const routeCandidates = injected === undefined ? [] : subagentRouteCandidates(request, subagentRouteRef.current, routeOptions)
5439
- .filter((candidate) => isRoutable(candidate.provider) && isQuotaHealthy(candidate.provider) !== false)
5553
+ .filter((candidate) => isRoutable(candidate.provider, candidate.model) && isQuotaHealthy(candidate.provider) !== false)
5440
5554
  .filter((candidate, index, candidates) => candidates.findIndex((item) => item.provider === candidate.provider && item.model === candidate.model) === index)
5441
5555
  const injectedIndex = routeCandidates.findIndex((candidate) => candidate.provider === injected?.provider && candidate.model === injected?.model)
5442
5556
  const eligibleRoutes = injectedIndex < 0 ? [] : routeCandidates.slice(injectedIndex)
@@ -5464,15 +5578,27 @@ function apply(ctx, featureConfig) {
5464
5578
  if (dispatch === undefined) return work()
5465
5579
  return pendingDispatchStorage.run(dispatch, work)
5466
5580
  }
5581
+ const originalStartActivation = subagents.startActivation
5467
5582
  const originalStart = subagents.start
5468
5583
  const originalStartContinuable = subagents.startContinuable
5469
- subagents.start = (name, request) => {
5470
- const { request: decorated, dispatch } = applyInjection(request)
5471
- return runWithDispatch(dispatch, () => originalStart.call(subagents, name, decorated))
5472
- }
5473
- subagents.startContinuable = (spec) => {
5474
- const { request: decorated, dispatch } = applyInjection(spec.request)
5475
- return runWithDispatch(dispatch, () => originalStartContinuable.call(subagents, { ...spec, request: decorated }))
5584
+ if (hasActivation) {
5585
+ subagents.startActivation = async (spec) => {
5586
+ await providerAvailability.loading
5587
+ const { request: decorated, dispatch } = applyInjection(spec.request)
5588
+ if (dispatch === undefined) return originalStartActivation.call(subagents, spec)
5589
+ return runWithDispatch(dispatch, () => originalStartActivation.call(subagents, { ...spec, request: decorated }))
5590
+ }
5591
+ } else {
5592
+ subagents.start = async (name, request) => {
5593
+ await providerAvailability.loading
5594
+ const { request: decorated, dispatch } = applyInjection(request)
5595
+ return runWithDispatch(dispatch, () => originalStart.call(subagents, name, decorated))
5596
+ }
5597
+ subagents.startContinuable = async (spec) => {
5598
+ await providerAvailability.loading
5599
+ const { request: decorated, dispatch } = applyInjection(spec.request)
5600
+ return runWithDispatch(dispatch, () => originalStartContinuable.call(subagents, { ...spec, request: decorated }))
5601
+ }
5476
5602
  }
5477
5603
  // agent/created(0.1.6 起由 announce await ctx.serial 异步串行派发;0.1.5 及之前同步派发在创建栈内):
5478
5604
  // 记录派发路由 + 绑定等级(仅当确有非空等级)。监听器内部全 try/catch 严格保证绝不抛错,
@@ -5552,8 +5678,11 @@ function apply(ctx, featureConfig) {
5552
5678
  }, { prepend: true }) : null
5553
5679
  subagentSeamRef.current = true
5554
5680
  scope.effect(() => () => {
5555
- subagents.start = originalStart
5556
- subagents.startContinuable = originalStartContinuable
5681
+ if (hasActivation) subagents.startActivation = originalStartActivation
5682
+ else {
5683
+ subagents.start = originalStart
5684
+ subagents.startContinuable = originalStartContinuable
5685
+ }
5557
5686
  if (typeof disposeCreated === 'function') disposeCreated()
5558
5687
  if (typeof disposeRequest === 'function') disposeRequest()
5559
5688
  if (typeof disposeRequestError === 'function') disposeRequestError()
@@ -5665,8 +5794,10 @@ function apply(ctx, featureConfig) {
5665
5794
  }
5666
5795
  // 远端额度:后台补拉一次。index.js 只提供节流/生命周期/受控传输 context;所有方言、
5667
5796
  // 端点、凭据策略与编排都通过同一 Adapter interface 执行,不再区分 parser/fetcher 形状。
5668
- const kickQuotaRefresh = (profile, adapter, config) => {
5669
- const decision = quotaThrottle.attempt(profile.name)
5797
+ // options.manual=true 只由 quota-refresh(手动重试/连接)传入:跳过手动模式与三道时间闸,
5798
+ // 是「确定性失败转手动」之后唯一还能打上游的路径。
5799
+ const kickQuotaRefresh = (profile, adapter, config, options = {}) => {
5800
+ const decision = quotaThrottle.attempt(profile.name, Date.now(), { manual: options.manual === true })
5670
5801
  if (!decision.ok) return
5671
5802
  const queued = enqueueQuotaWork(async () => {
5672
5803
  const controller = new AbortController()
@@ -6130,8 +6261,29 @@ function apply(ctx, featureConfig) {
6130
6261
  createSkillsRoutes({ ctx, describeJobs, dshHome, makeDescribeJobLogger, registerSkillCall, serializeSkillsIndexWrite, skillFileCache, skillsActiveControllers, skillsBatchRef, skillsIndexRef, SKILL_DESCRIPTION_MAX_CHARS, SKILL_USAGE_MAX_CHARS, bodyHashOf, describeSkillDraft, evaluateSkillFile, fixLegacySkillInvocationKeys, listSkillModels, locateSkillFrontmatter, mutateSkillEntryById, name, normalizeSkillDescribeLang, publicSkillEntry, rpcTechnicalFailure, sanitizeSkillDraftText, scanSkillEntries, selectSkillBatchCandidates, setSkillInvocationKey }),
6131
6262
  createSessionsRoutes({ ctx, dshHome, sessionBytesCache, sessionDeletePlans, sessionTitleCache, sessionTitlesReady, sessionViewCache, SESSIONS_BYTES_MAX_IDS, SESSIONS_DELETE_PLAN_TTL_MS, SESSIONS_VIEW_PAGE_SIZE, listSessionsForManage, loadDeletedSessions, name, resolveSessionBytesForIds, resolveSessionForDelete, rpcFailure, rpcTechnicalFailure, saveDeletedSessions, searchSessionsContent, sessionExists, sessionIsLive, viewSessionPage }),
6132
6263
  createQuotaRoutes({ ctx, kickQuotaRefresh, quotaThrottle, refreshQuotaConfigCache, serializeQuotaConfigWrite, MAX_QUOTA_PROVIDER_NAME, MAX_QUOTA_RESET_CARDS, MAX_QUOTA_RESET_CARDS_PER_PROVIDER, MAX_QUOTA_RESET_CARD_ACCOUNT, canonicalResetCardExpiresAt, QUOTA_ADAPTER_BY_KIND, name, quotaCredentialConfigured, quotaCredentialEndpoint, quotaCredentialHintNames, readQuotaProfiles, resolveQuotaKind, rpcTechnicalFailure }),
6133
- createSubagentRoutes({ ctx, dispatchRing, serializeSubagentRouteWrite, subagentRouteRef, subagentRouteLoadPromise, subagentSeamRef, MAX_SUBAGENT_ROUTE_FIELD, SUBAGENT_ROUTE_FALLBACK_MAX, SUBAGENT_ROUTE_MODES, listSubagentDispatches, listSubagentModels, rpcTechnicalFailure }),
6134
- createBackupRoutes({ ctx, backupIntegrity, backupProgress, clearBackupProgress, downloadTokens, dshHome, dshVersion, setBackupProgress, withBackupLock, backupNameVersionSuffix, createBackup, deleteBackup, exportBackup, formatBackupTimestamp, importBackup, listBackups, name, rpcFailure }),
6264
+ createSubagentRoutes({ ctx, providerAvailability, dispatchRing, serializeSubagentRouteWrite, subagentRouteRef, subagentRouteLoadPromise, subagentSeamRef, MAX_SUBAGENT_ROUTE_FIELD, SUBAGENT_ROUTE_FALLBACK_MAX, SUBAGENT_ROUTE_MODES, listSubagentDispatches, listSubagentModels, rpcTechnicalFailure }),
6265
+ createBackupRoutes({ ctx, backupIntegrity, backupProgress, clearBackupProgress, downloadTokens, dshHome, dshVersion, setBackupProgress, withBackupLock, backupNameVersionSuffix, createBackup, deleteBackup, exportBackup, formatBackupTimestamp, importBackup, listBackups, listLocalProfiles, name, rpcFailure }),
6266
+ createModelConfigRoutes({
6267
+ ctx,
6268
+ loadPiCatalog: loadPiAiCatalog,
6269
+ workbenchStore,
6270
+ providerAvailability,
6271
+ notifyProviderAvailability,
6272
+ // 移除守卫的只读引用面:子代理路由配置 + 官方默认模型选择(都是宿主侧事实,浏览器不参与)。
6273
+ loadSubagentRoute: async () => {
6274
+ await subagentRouteLoadPromise
6275
+ return subagentRouteRef.current
6276
+ },
6277
+ loadAgentDefaultModelSelection: () => {
6278
+ const service = ctx.get('agentDefaultModel')
6279
+ if (service === undefined || typeof service.currentSelection !== 'function') return undefined
6280
+ return service.currentSelection()
6281
+ },
6282
+ writeSubagentRoute: serializeSubagentRouteWrite,
6283
+ listSubagentModels,
6284
+ dshHome,
6285
+ pluginVersion,
6286
+ }),
6135
6287
  ])
6136
6288
 
6137
6289
  const dispatchEndpoint = createRpcDispatcher({ endpoints: rpcEndpoints, featureEnabled, logger: ctx.logger })
@@ -6241,6 +6393,45 @@ export {
6241
6393
  saveUnifiedConfig,
6242
6394
  updateUnifiedConfigSection,
6243
6395
  sanitizeSettingsNavConfig,
6396
+ // 模型配置工作台(v1.10):领域纯函数经 index.js 再导出,测试从 '../index.js' 导入的
6397
+ // 既有约定保持不变(旧分支 test/workbench.test.js 即此形状)。
6398
+ WORKBENCH_SIDECAR_VERSION,
6399
+ applySuggestions,
6400
+ applyUndo,
6401
+ arbitrateModelSuggestions,
6402
+ buildPiAiIndex,
6403
+ createWorkbenchStore,
6404
+ deriveKeyRefFor,
6405
+ diffModelLists,
6406
+ emptyWorkbenchSidecar,
6407
+ fetchModelsProbe,
6408
+ hostnameOf,
6409
+ isPrivateIpAddress,
6410
+ loadWorkbenchSidecar,
6411
+ modelPatchFromPiAi,
6412
+ modelsProbeLookup,
6413
+ normalizeBaseURLForMatch,
6414
+ parseModelListing,
6415
+ probeBaseURLCheck,
6416
+ profileFingerprint,
6417
+ readLlmProfiles,
6418
+ saveWorkbenchSidecar,
6419
+ selfCheckModelsPatch,
6420
+ suggestRouteId,
6421
+ suggestionId,
6422
+ undoPreviewModels,
6423
+ // P3.5 补充源(modelparams.dev)与多源共识:同样经 index.js 再导出供测试直达。
6424
+ abilityPatch,
6425
+ consensusFields,
6426
+ createFreshnessManager,
6427
+ indexModelParamsDev,
6428
+ loadModelParamsSnapshot,
6429
+ modelParamsCandidates,
6430
+ modelParamsDevEfforts,
6431
+ openRouterModelFacts,
6432
+ openRouterVerifySource,
6433
+ pickOpenRouterMatch,
6434
+ saveModelParamsSnapshot,
6244
6435
  }
6245
6436
  // 插件 Config:0.1.7-alpha.1 起宿主配置面由 Profile 的插件 Config 托管;声明它即可让
6246
6437
  // 功能开关出现在插件管理页,并让宿主经 `loader/volatile-update` 热更新(无需重启)。