@bolloon/bolloon-agent 0.4.33 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/dist/agents/contacts/chain.js +27 -7
  2. package/dist/agents/execution-supervisor.js +580 -40
  3. package/dist/agents/external-events.js +35 -6
  4. package/dist/agents/gateway-group.js +100 -12
  5. package/dist/agents/goal-criteria.js +10 -3
  6. package/dist/agents/goal-flywheel/block-executor.js +346 -0
  7. package/dist/agents/goal-flywheel/continuation-decision.js +592 -0
  8. package/dist/agents/goal-flywheel/goal-change.js +754 -0
  9. package/dist/agents/goal-flywheel/index.js +26 -0
  10. package/dist/agents/goal-flywheel/memory-layers.js +227 -0
  11. package/dist/agents/goal-flywheel/run-closure.js +809 -0
  12. package/dist/agents/goal-flywheel/skill-candidate.js +628 -0
  13. package/dist/agents/goal-flywheel/types.js +329 -0
  14. package/dist/agents/goal-flywheel/wiring/change.js +288 -0
  15. package/dist/agents/goal-flywheel/wiring/closure.js +697 -0
  16. package/dist/agents/goal-flywheel/wiring/continuation.js +432 -0
  17. package/dist/agents/goal-flywheel/wiring/contract.js +270 -0
  18. package/dist/agents/goal-flywheel/wiring/index.js +26 -0
  19. package/dist/agents/goal-flywheel/wiring/monitor.js +254 -0
  20. package/dist/agents/goal-flywheel/wiring/seams.js +592 -0
  21. package/dist/agents/goal-flywheel/work-contract.js +478 -0
  22. package/dist/agents/goal-flywheel/work-monitor.js +488 -0
  23. package/dist/agents/goal-flywheel-wiring.js +1870 -0
  24. package/dist/agents/goal-state-reducer.js +406 -0
  25. package/dist/agents/goal-store.js +35 -1
  26. package/dist/agents/mobile-flywheel-view.js +144 -0
  27. package/dist/agents/mobile-task-actions-verify.js +74 -0
  28. package/dist/agents/mobile-task-actions.js +178 -0
  29. package/dist/agents/mobile-task-views.js +199 -0
  30. package/dist/agents/model-policy.js +651 -0
  31. package/dist/agents/network-pulse.js +304 -18
  32. package/dist/agents/pi-sdk-tools.js +84 -33
  33. package/dist/agents/pi-sdk.js +157 -42
  34. package/dist/agents/run-store.js +71 -5
  35. package/dist/agents/skill-readiness.js +52 -10
  36. package/dist/agents/subagent-manager.js +209 -3
  37. package/dist/agents/task/task-runner.js +17 -4
  38. package/dist/agents/task-group.js +8 -51
  39. package/dist/agents/task-public-text.js +89 -0
  40. package/dist/agents/workflow-pivot-loop.js +19 -12
  41. package/dist/cli/clipboard.js +65 -0
  42. package/dist/cli/commands/index.js +1 -0
  43. package/dist/cli/commands/tasks.js +350 -2
  44. package/dist/cli/identity-command.js +177 -0
  45. package/dist/cli/ink-app.js +145 -17
  46. package/dist/cli/log-gate.js +386 -0
  47. package/dist/cli/mention-data.js +2 -0
  48. package/dist/cli/model-selector.js +538 -0
  49. package/dist/cli/reply-hygiene.js +54 -0
  50. package/dist/cli/setup-wizard.js +757 -69
  51. package/dist/cli/startup-notice.js +160 -0
  52. package/dist/cli/stores.js +22 -1
  53. package/dist/cli/theme.js +49 -0
  54. package/dist/cli/tui-select.js +970 -0
  55. package/dist/cli/update-commands.js +150 -24
  56. package/dist/cli-entry.js +137 -20
  57. package/dist/electron-build/agents/contacts/chain.js +26 -6
  58. package/dist/electron-build/agents/contacts/chain.js.map +1 -1
  59. package/dist/electron-build/agents/execution-supervisor.js +578 -38
  60. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  61. package/dist/electron-build/agents/external-events.js +34 -5
  62. package/dist/electron-build/agents/external-events.js.map +1 -1
  63. package/dist/electron-build/agents/goal-criteria.js +10 -3
  64. package/dist/electron-build/agents/goal-criteria.js.map +1 -1
  65. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
  66. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
  67. package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
  68. package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
  69. package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
  70. package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
  71. package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
  72. package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
  73. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
  74. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
  75. package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
  76. package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
  77. package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
  78. package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
  79. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
  80. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
  81. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
  82. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
  83. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
  84. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
  85. package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
  86. package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
  87. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
  88. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
  89. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
  90. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
  91. package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
  92. package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
  93. package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
  94. package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
  95. package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
  96. package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
  97. package/dist/electron-build/agents/goal-state-reducer.js +414 -0
  98. package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
  99. package/dist/electron-build/agents/goal-store.js +35 -1
  100. package/dist/electron-build/agents/goal-store.js.map +1 -1
  101. package/dist/electron-build/agents/model-policy.js +706 -0
  102. package/dist/electron-build/agents/model-policy.js.map +1 -0
  103. package/dist/electron-build/agents/network-pulse.js +307 -19
  104. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  105. package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
  106. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  107. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  108. package/dist/electron-build/agents/pi-sdk.js +156 -41
  109. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  110. package/dist/electron-build/agents/run-store.js +75 -6
  111. package/dist/electron-build/agents/run-store.js.map +1 -1
  112. package/dist/electron-build/agents/skill-readiness.js +52 -9
  113. package/dist/electron-build/agents/skill-readiness.js.map +1 -1
  114. package/dist/electron-build/agents/task/task-runner.js +16 -3
  115. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  116. package/dist/electron-build/agents/workflow-pivot-loop.js +19 -12
  117. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  118. package/dist/electron-build/llm/config-store.js +146 -6
  119. package/dist/electron-build/llm/config-store.js.map +1 -1
  120. package/dist/electron-build/llm/connection-probe.js +764 -0
  121. package/dist/electron-build/llm/connection-probe.js.map +1 -0
  122. package/dist/electron-build/llm/custom-provider-store.js +272 -0
  123. package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
  124. package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
  125. package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
  126. package/dist/electron-build/llm/model-catalog.js +778 -0
  127. package/dist/electron-build/llm/model-catalog.js.map +1 -0
  128. package/dist/electron-build/llm/model-discovery.js +1202 -0
  129. package/dist/electron-build/llm/model-discovery.js.map +1 -0
  130. package/dist/electron-build/llm/model-selection.js +1315 -0
  131. package/dist/electron-build/llm/model-selection.js.map +1 -0
  132. package/dist/electron-build/llm/pi-ai.js +70 -11
  133. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  134. package/dist/electron-build/llm/provider-catalog.js +969 -0
  135. package/dist/electron-build/llm/provider-catalog.js.map +1 -0
  136. package/dist/electron-build/llm/provider-registry.js +950 -0
  137. package/dist/electron-build/llm/provider-registry.js.map +1 -0
  138. package/dist/electron-build/llm/tool-name.js +266 -0
  139. package/dist/electron-build/llm/tool-name.js.map +1 -0
  140. package/dist/electron-build/orbitdb/cid-database.js +83 -17
  141. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  142. package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
  143. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  144. package/dist/electron-build/utils/dual-source-facts.js +236 -0
  145. package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
  146. package/dist/electron-build/utils/dual-source.js +347 -0
  147. package/dist/electron-build/utils/dual-source.js.map +1 -0
  148. package/dist/electron-build/utils/update-manager.js +328 -104
  149. package/dist/electron-build/utils/update-manager.js.map +1 -1
  150. package/dist/electron-build/utils/update-state.js +9 -11
  151. package/dist/electron-build/utils/update-state.js.map +1 -1
  152. package/dist/electron-build/utils/version-identity.js +193 -0
  153. package/dist/electron-build/utils/version-identity.js.map +1 -0
  154. package/dist/electron-build/utils/version-info.js +57 -31
  155. package/dist/electron-build/utils/version-info.js.map +1 -1
  156. package/dist/index.js +385 -108
  157. package/dist/ios/bolloon-web.json +10 -0
  158. package/dist/ios/chain-explorer.js +617 -0
  159. package/dist/ios/explorer.html +163 -0
  160. package/dist/ios/index.html +96 -0
  161. package/dist/ios/mobile-agent.js +2 -2
  162. package/dist/ios/mobile-contacts.js +362 -0
  163. package/dist/ios/mobile-core.js +3102 -310
  164. package/dist/ios/mobile-privacy.js +185 -0
  165. package/dist/ios/mobile-tasks.js +339 -0
  166. package/dist/ios/mobile-update.js +1442 -0
  167. package/dist/ios/mobile.css +19 -0
  168. package/dist/ios/mobile.html +96 -0
  169. package/dist/ios/mobile.js +1013 -41
  170. package/dist/ios/routes-chain-index.js +108 -0
  171. package/dist/ios/routes-contacts.js +420 -0
  172. package/dist/ios/routes-llm-config.js +344 -52
  173. package/dist/ios/routes-mobile-tasks.js +353 -0
  174. package/dist/ios/routes-onchain-trade.js +99 -0
  175. package/dist/ios/routes-x402-info.js +1 -0
  176. package/dist/ios/server.js +921 -0
  177. package/dist/ios/sw.js +1 -1
  178. package/dist/ios/task-frame-server.js +211 -0
  179. package/dist/llm/config-store.js +146 -6
  180. package/dist/llm/connection-probe.js +744 -0
  181. package/dist/llm/custom-provider-store.js +224 -0
  182. package/dist/llm/data/provider-catalog-baked.js +19 -0
  183. package/dist/llm/model-catalog.js +717 -0
  184. package/dist/llm/model-discovery.js +1137 -0
  185. package/dist/llm/model-selection.js +1241 -0
  186. package/dist/llm/pi-ai.js +70 -11
  187. package/dist/llm/provider-catalog.js +898 -0
  188. package/dist/llm/provider-registry.js +917 -0
  189. package/dist/llm/tool-name.js +220 -0
  190. package/dist/orbitdb/cid-database.js +81 -16
  191. package/dist/orbitdb/ipfs-node.js +31 -2
  192. package/dist/setup/onboard.js +34 -8
  193. package/dist/utils/dual-source-facts.js +223 -0
  194. package/dist/utils/dual-source.js +297 -0
  195. package/dist/utils/update-cli.js +16 -21
  196. package/dist/utils/update-health.js +35 -5
  197. package/dist/utils/update-manager.js +325 -96
  198. package/dist/utils/update-state.js +13 -10
  199. package/dist/utils/version-identity.js +176 -0
  200. package/dist/utils/version-info.js +27 -22
  201. package/dist/web/bolloon-web.json +10 -0
  202. package/dist/web/mobile-contacts.js +44 -11
  203. package/dist/web/mobile-core.js +2901 -580
  204. package/dist/web/mobile-tasks.js +339 -0
  205. package/dist/web/mobile-update.js +1442 -0
  206. package/dist/web/mobile.css +19 -0
  207. package/dist/web/mobile.html +64 -0
  208. package/dist/web/mobile.js +838 -35
  209. package/dist/web/routes-llm-config.js +344 -52
  210. package/dist/web/routes-mobile-tasks.js +353 -0
  211. package/dist/web/server.js +163 -4
  212. package/dist/web/sw.js +1 -1
  213. package/package.json +3 -1
  214. package/scripts/build-web.ts +36 -0
@@ -0,0 +1,1315 @@
1
+ "use strict";
2
+ /**
3
+ * model-selection.ts — 「有效模型配置」的唯一入口 (2026-09-26)
4
+ *
5
+ * ## 修的是什么
6
+ *
7
+ * 此前切换模型有三条各走各的路 (CLI `/model`、Web `/api/llm-config` + `/api/llm-provider`、
8
+ * 初始化向导), 每条路都只做其中一部分: CLI 那条只改配置文件、**不重建模型运行时** ——
9
+ * 于是出现"配置文件已经改了, 内存里的实例还是旧的"这种半成功状态: 用户以为切了, 下一次
10
+ * 请求还是打到旧供应商。Web 那条重建了运行时, 但不支持切 API URL, 也不记录"这次切换属于
11
+ * 哪个作用域"。
12
+ *
13
+ * 这里把七步收敛成**一个函数** (`selectModel`), CLI / Web / 向导 / 工具调用都只能调它:
14
+ *
15
+ * ```
16
+ * validateSelection → 轻量连通探测 → 写配置 → 更新 active scope → 重建模型运行时
17
+ * → 更新当前 session → 返回 effective config
18
+ * ```
19
+ *
20
+ * 三条硬规则 (写在实现里, 不是文档里):
21
+ * ① **进行中的 HTTP 请求继续用旧配置** —— 运行时实例在构造时就把 provider/model/baseUrl
22
+ * 固化下来 (`PiAIModel` 的 `this.config`), 重建只是换掉单例引用; 已经在飞的那次请求
23
+ * 握着自己的旧实例, 不会中途改口径。
24
+ * ② **下一次模型调用必须用新配置** —— 调用点每次都 `getMinimax()` 现取单例, 不做本地缓存。
25
+ * ③ **切换失败时配置与运行时都保持原样** —— 校验与探测都在**写盘之前**; 写盘之后任何一步
26
+ * 出错 (含运行时重建抛错) 都会把配置文件字节级还原成切换前的样子, 并用旧配置重新初始化
27
+ * 运行时, 然后如实返回失败。没有"文件已改但实例仍旧"的中间态。
28
+ *
29
+ * ## 「有效模型配置」
30
+ *
31
+ * 不能只存 `activeProvider + providers[provider].model`。一次解析要同时说清
32
+ * `provider · model · baseUrl · protocol · authRef · reasoning · scope · source · updatedAt`,
33
+ * 并按**固定优先级**从五层里挑:
34
+ *
35
+ * ```
36
+ * Run/Goal 显式绑定 > 当前 Session 绑定 > 用户 Global 默认 > provider 默认 > 环境变量
37
+ * ```
38
+ *
39
+ * 纯函数 `resolveSelection()` 负责这条优先级 (可单测, 不碰磁盘); `effectiveModelConfig()`
40
+ * 负责从磁盘/环境把五层读出来喂给它。
41
+ *
42
+ * `authRef` **只记凭证来源的引用名** (如 `provider:deepseek` / `env:DEEPSEEK_API_KEY` / `none`),
43
+ * 永远不记 key 本身 —— 有效配置是要被打印、被写进 Run 记录的。
44
+ */
45
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
46
+ if (k2 === undefined) k2 = k;
47
+ var desc = Object.getOwnPropertyDescriptor(m, k);
48
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
49
+ desc = { enumerable: true, get: function() { return m[k]; } };
50
+ }
51
+ Object.defineProperty(o, k2, desc);
52
+ }) : (function(o, m, k, k2) {
53
+ if (k2 === undefined) k2 = k;
54
+ o[k2] = m[k];
55
+ }));
56
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
57
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
58
+ }) : function(o, v) {
59
+ o["default"] = v;
60
+ });
61
+ var __importStar = (this && this.__importStar) || (function () {
62
+ var ownKeys = function(o) {
63
+ ownKeys = Object.getOwnPropertyNames || function (o) {
64
+ var ar = [];
65
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
66
+ return ar;
67
+ };
68
+ return ownKeys(o);
69
+ };
70
+ return function (mod) {
71
+ if (mod && mod.__esModule) return mod;
72
+ var result = {};
73
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
74
+ __setModuleDefault(result, mod);
75
+ return result;
76
+ };
77
+ })();
78
+ Object.defineProperty(exports, "__esModule", { value: true });
79
+ exports.PROBE_TIMEOUT_MS = exports.CONFIG_LOCK_FILE = exports.SESSION_BINDINGS_FILE = exports.SELECTION_FAILURE_ZH = exports.UNMAPPED_PROBE_CLASS = exports.PROBE_TO_SELECTION = exports.SELECTION_FAILURE_CLASS_ORIGIN = exports.SELECTION_FAILURE_CLASSES = exports.SELECTION_PRIORITY = void 0;
80
+ exports.mapProbeFailureClass = mapProbeFailureClass;
81
+ exports.selectionFailureClassTable = selectionFailureClassTable;
82
+ exports.protocolOf = protocolOf;
83
+ exports.supportsReasoning = supportsReasoning;
84
+ exports.envKeyNamesOf = envKeyNamesOf;
85
+ exports.credentialFingerprintOf = credentialFingerprintOf;
86
+ exports.providerFactsOf = providerFactsOf;
87
+ exports.registryEntryOf = registryEntryOf;
88
+ exports.listRegisteredProviderIds = listRegisteredProviderIds;
89
+ exports.envApiKeyOf = envApiKeyOf;
90
+ exports.normalizeBaseUrl = normalizeBaseUrl;
91
+ exports.validateBaseUrlShape = validateBaseUrlShape;
92
+ exports.configHashOf = configHashOf;
93
+ exports.materialize = materialize;
94
+ exports.runModelConfigOf = runModelConfigOf;
95
+ exports.resolveSelection = resolveSelection;
96
+ exports.currentSessionKey = currentSessionKey;
97
+ exports.readSessionSelection = readSessionSelection;
98
+ exports.writeSessionSelection = writeSessionSelection;
99
+ exports.clearSessionSelection = clearSessionSelection;
100
+ exports.effectiveModelConfig = effectiveModelConfig;
101
+ exports.captureRunModelConfig = captureRunModelConfig;
102
+ exports.applyEffectiveToRuntime = applyEffectiveToRuntime;
103
+ exports.applyRunModelConfigToRuntime = applyRunModelConfigToRuntime;
104
+ exports.usableFallbackCandidates = usableFallbackCandidates;
105
+ exports.withConfigLock = withConfigLock;
106
+ exports.baseDefaultsOf = baseDefaultsOf;
107
+ exports.validateSelection = validateSelection;
108
+ exports.runConnectionProbe = runConnectionProbe;
109
+ exports.probeSelection = probeSelection;
110
+ exports.selectModel = selectModel;
111
+ exports.resetModelSelection = resetModelSelection;
112
+ exports.formatEffectiveModel = formatEffectiveModel;
113
+ exports.providerDisplayName = providerDisplayName;
114
+ exports.compareRunModelConfig = compareRunModelConfig;
115
+ exports.detectRunModelDrift = detectRunModelDrift;
116
+ exports.detectRunConfigDrift = detectRunConfigDrift;
117
+ const fsSync = __importStar(require("fs"));
118
+ const fsp = __importStar(require("fs/promises"));
119
+ const path = __importStar(require("path"));
120
+ const crypto = __importStar(require("crypto"));
121
+ const config_store_js_1 = require("./config-store.js");
122
+ const index_js_1 = require("../constraints/index.js");
123
+ const connection_probe_js_1 = require("./connection-probe.js");
124
+ // 2026-09-26 (P6): 入口现在按**注册表**校验供应商 —— 于是自定义供应商 (不改 TS 联合类型) 也能
125
+ // 被切成全局默认。方向是 `model-selection → provider-registry` (与 `config-store → provider-registry`
126
+ // 同一条边, 不是新方向的循环): 注册表自己把内置表派生过来, 所以**内置的行为一字不变**
127
+ // (`entry.protocol === protocolOf(id)` / `entry.apiKeyEnvVars === envKeyNamesOf(id)` 由注册表门禁钉住)。
128
+ const provider_registry_js_1 = require("./provider-registry.js");
129
+ // ============================================================
130
+ // 类型
131
+ // ============================================================
132
+ /** 解析优先级 (固定顺序, 不允许调用方打乱) */
133
+ exports.SELECTION_PRIORITY = ['run', 'session', 'global', 'provider', 'env'];
134
+ /**
135
+ * 入口自己的失败分类 (冻结)。一次切换失败**必须**落到这里的一个类上 ——
136
+ * 只回一句"切换失败"或"连接失败"等于没有信息, 用户无从知道该改 URL、改 key、还是改模型名。
137
+ *
138
+ * 分两组, 由 `SELECTION_FAILURE_CLASS_ORIGIN` 逐类标出真出处:
139
+ *
140
+ * · `probe` (7 类) —— P4 探测原语 (`connection-probe.ts`) 报出的类目, 经
141
+ * `PROBE_TO_SELECTION` **逐类映射**过来 (不是"把探测结果揉成一句失败");
142
+ * · `entry` (8 类) —— 入口自己在写盘**之前**的校验、以及写盘**之后**的写盘/重建路径产生的类
143
+ * (探测原语根本不回答这些: 它不校验 provider 是否存在、不写盘、不建运行时)。
144
+ *
145
+ * 为什么 `tool_call_unsupported` 必须出现在这里: 探测原语第 ⑥ 步会**真的**去确认工具调用能力,
146
+ * 它是一个独立类目。入口若没有这一类, 映射时就只能把它塞进别的类 (或退化成"切换失败") ——
147
+ * 而这一类恰恰是用户最需要看到的: 它直接回答"这个模型能不能用来做 Agent 执行"。
148
+ */
149
+ exports.SELECTION_FAILURE_CLASSES = [
150
+ // ── 由探测原语映射而来 (7) ──
151
+ 'invalid_url',
152
+ 'auth_failed',
153
+ 'provider_unreachable',
154
+ 'model_not_found',
155
+ 'protocol_mismatch',
156
+ 'tool_call_unsupported',
157
+ 'timeout',
158
+ // ── 入口自己判定 (9) ──
159
+ 'invalid_provider',
160
+ 'invalid_model',
161
+ 'missing_api_key',
162
+ 'credential_scope_conflict',
163
+ 'invalid_temperature',
164
+ 'persist_failed',
165
+ 'runtime_rebuild_failed',
166
+ 'probe_failure_unmapped',
167
+ /**
168
+ * 目录里的家需要**本运行时不支持的鉴权形状** (云签名 / OAuth / 多变量云凭证 …)。
169
+ * 为什么单列一类而不是并进 `auth_failed`: `auth_failed` 是"发了请求被拒" (探测报的),
170
+ * 这一类是"**根本没发** —— 我们不会说那家的鉴权语言"。两者对用户的含义完全不同:
171
+ * 前者要换 key, 后者要换路 (走 openai-compatible 的自定义供应商)。
172
+ */
173
+ 'provider_auth_unsupported',
174
+ ];
175
+ /** 逐类的**真出处** (`probe` = 探测原语报的; `entry` = 入口自己判的) */
176
+ exports.SELECTION_FAILURE_CLASS_ORIGIN = {
177
+ invalid_url: 'probe',
178
+ auth_failed: 'probe',
179
+ provider_unreachable: 'probe',
180
+ model_not_found: 'probe',
181
+ protocol_mismatch: 'probe',
182
+ tool_call_unsupported: 'probe',
183
+ timeout: 'probe',
184
+ invalid_provider: 'entry',
185
+ invalid_model: 'entry',
186
+ missing_api_key: 'entry',
187
+ credential_scope_conflict: 'entry',
188
+ invalid_temperature: 'entry',
189
+ persist_failed: 'entry',
190
+ runtime_rebuild_failed: 'entry',
191
+ probe_failure_unmapped: 'entry',
192
+ provider_auth_unsupported: 'entry',
193
+ };
194
+ /**
195
+ * **持久化映射表**: 探测原语的 7 类 → 入口类目。
196
+ *
197
+ * 逐类点名 (不写"其余照搬"): 这张表是"探测换一次实现, 入口的对外文案会不会悄悄变形"的唯一答案。
198
+ * 门禁 (`scripts/verify-model-wiring.ts`) 断言: 这张表的键集 == `PROBE_FAILURE_CLASSES`,
199
+ * 且每个值都在 `SELECTION_FAILURE_CLASSES` 里 —— 少一类 / 映射到不存在的类都判红。
200
+ */
201
+ exports.PROBE_TO_SELECTION = {
202
+ invalid_url: 'invalid_url',
203
+ auth_failed: 'auth_failed',
204
+ provider_unreachable: 'provider_unreachable',
205
+ model_not_found: 'model_not_found',
206
+ protocol_mismatch: 'protocol_mismatch',
207
+ tool_call_unsupported: 'tool_call_unsupported',
208
+ timeout: 'timeout',
209
+ };
210
+ /**
211
+ * 未映射的探测类目。
212
+ *
213
+ * 理论上**不可达** (上面 7 类已被 `PROBE_TO_SELECTION` 全覆盖, 门禁钉住这一点)。真走到这里
214
+ * 只可能是 P4 加了新类而入口没同步: 这时仍然如实报出**原文类名** (`probeClass`), 绝不退化成
215
+ * "切换失败"这种无信息文案 —— 宁可暴露一个没见过的类名, 也不许把事实糊掉。
216
+ */
217
+ exports.UNMAPPED_PROBE_CLASS = 'probe_failure_unmapped';
218
+ /** 探测类目 → 入口类目 (映射之后的类名与原名一起给出, 调用方要报原文就报原文) */
219
+ function mapProbeFailureClass(rawClass) {
220
+ const raw = String(rawClass ?? '');
221
+ if (Object.prototype.hasOwnProperty.call(exports.PROBE_TO_SELECTION, raw)) {
222
+ return { failureClass: exports.PROBE_TO_SELECTION[raw], raw, unmapped: false };
223
+ }
224
+ return { failureClass: exports.UNMAPPED_PROBE_CLASS, raw, unmapped: true };
225
+ }
226
+ /** 一张给人看的映射表 (逐行, 报告/CLI/门禁共用同一张表, 不许各自再写一份) */
227
+ function selectionFailureClassTable() {
228
+ const bySelection = new Map();
229
+ for (const pc of connection_probe_js_1.PROBE_FAILURE_CLASSES)
230
+ bySelection.set(exports.PROBE_TO_SELECTION[pc], pc);
231
+ return exports.SELECTION_FAILURE_CLASSES.map((s) => {
232
+ const probeClass = bySelection.get(s);
233
+ return {
234
+ selection: s,
235
+ origin: exports.SELECTION_FAILURE_CLASS_ORIGIN[s],
236
+ ...(probeClass ? { probeClass } : {}),
237
+ zh: probeClass ? connection_probe_js_1.PROBE_FAILURE_ZH[probeClass] : exports.SELECTION_FAILURE_ZH[s],
238
+ };
239
+ });
240
+ }
241
+ /** 入口自有类目的中文说法 (探测类目直接复用原语的人话, 不再翻译一遍) */
242
+ exports.SELECTION_FAILURE_ZH = {
243
+ invalid_url: 'URL 写错了',
244
+ auth_failed: '凭证被拒',
245
+ provider_unreachable: '连不上供应商',
246
+ model_not_found: '端点不认识这个模型名',
247
+ protocol_mismatch: '这个地址上的服务不是该协议',
248
+ tool_call_unsupported: '模型/端点不接受工具调用声明',
249
+ timeout: '探测超时',
250
+ invalid_provider: '供应商不存在 (内置表与注册表里都没有这家)',
251
+ invalid_model: '模型名为空 (配置里没有, 也没有内置默认)',
252
+ missing_api_key: '这家还需要 API key (配置里没有, 环境变量也没有)',
253
+ credential_scope_conflict: '会话级切换不许写凭证 (凭证只属于全局)',
254
+ invalid_temperature: 'temperature 不在 0~2',
255
+ persist_failed: '写配置失败 (已回滚, 盘上仍是旧配置)',
256
+ runtime_rebuild_failed: '配置写成功但重建模型运行时失败 (已回滚)',
257
+ probe_failure_unmapped: '探测报了一个入口还不认识的类目 (映射表没覆盖)',
258
+ provider_auth_unsupported: '这家需要专用鉴权 (本版本未支持) —— 请求根本没发出去',
259
+ };
260
+ // ============================================================
261
+ // 纯函数: 协议 / 规范化 / 摘要
262
+ // ============================================================
263
+ const PROTOCOL_OF = {
264
+ anthropic: 'anthropic',
265
+ gemini: 'gemini',
266
+ ollama: 'ollama',
267
+ local: 'ollama',
268
+ // 其余 (openai/openrouter/deepseek/kimi/glm/qwen/mimo/minimax/grok) 都是 openai 兼容
269
+ };
270
+ function protocolOf(provider) {
271
+ return PROTOCOL_OF[provider] || 'openai-compatible';
272
+ }
273
+ /** 该 provider 默认是否原生支持 reasoning (仅作展示/校验参考, 不参与路由) */
274
+ const REASONING_PROVIDERS = new Set(['deepseek', 'minimax', 'glm', 'openai', 'anthropic', 'grok']);
275
+ function supportsReasoning(provider) {
276
+ return REASONING_PROVIDERS.has(provider);
277
+ }
278
+ /** provider → 读 API key 的环境变量名 (可能多个别名) */
279
+ const ENV_KEY_NAMES = {
280
+ openai: ['OPENAI_API_KEY'],
281
+ anthropic: ['ANTHROPIC_API_KEY'],
282
+ openrouter: ['OPENROUTER_API_KEY'],
283
+ gemini: ['GEMINI_API_KEY'],
284
+ minimax: ['MINIMAX_API_KEY'],
285
+ deepseek: ['DEEPSEEK_API_KEY'],
286
+ kimi: ['KIMI_API_KEY', 'MOONSHOT_API_KEY'],
287
+ glm: ['GLM_API_KEY', 'ZHIPU_API_KEY'],
288
+ qwen: ['QWEN_API_KEY', 'DASHSCOPE_API_KEY'],
289
+ mimo: ['MIMO_API_KEY'],
290
+ grok: ['XAI_API_KEY'],
291
+ ollama: ['OLLAMA_API_KEY'],
292
+ local: ['OLLAMA_API_KEY'],
293
+ };
294
+ function envKeyNamesOf(provider) {
295
+ return ENV_KEY_NAMES[provider] || [];
296
+ }
297
+ /**
298
+ * 这个供应商**当前生效凭据的指纹** (`fp:<sha256 前 16 位>`), 没有则 `undefined`。
299
+ *
300
+ * **只有指纹, 没有明文** —— 界面/报告要能说"沿用现有 key (指纹 fp:xxxx)"以及"替换后指纹真的变了",
301
+ * 但不许把 key 本身写进任何输出。哈希口径与模型发现缓存**同一把尺子**
302
+ * (`credentialIdentityOf`, 见 `model-discovery.ts`), 免得同一把 key 在两处算出两个指纹。
303
+ */
304
+ async function credentialFingerprintOf(provider) {
305
+ const { resolvedApiKeyOf } = await Promise.resolve().then(() => __importStar(require('./model-catalog.js')));
306
+ const v = await resolvedApiKeyOf(provider).catch(() => undefined);
307
+ const k = String(v || '').trim();
308
+ if (!k)
309
+ return undefined;
310
+ const md = await Promise.resolve().then(() => __importStar(require('./model-discovery.js')));
311
+ return md.credentialIdentityOf(k);
312
+ }
313
+ function providerFactsOf(provider) {
314
+ const id = String(provider || '').trim().toLowerCase();
315
+ const entry = id ? (0, provider_registry_js_1.getProviderRegistryEntry)(id) : undefined;
316
+ if (entry && entry.kind !== 'builtin') {
317
+ return {
318
+ protocol: entry.protocol,
319
+ envKeys: Array.isArray(entry.apiKeyEnvVars) ? [...entry.apiKeyEnvVars] : [],
320
+ reasoning: entry.reasoning === 'yes',
321
+ };
322
+ }
323
+ return { protocol: protocolOf(id), envKeys: envKeyNamesOf(id), reasoning: supportsReasoning(id) };
324
+ }
325
+ /** 注册表里这一家的条目 (入口校验用; 不在册 → `undefined`, 不编一条出来) */
326
+ function registryEntryOf(provider) {
327
+ const id = String(provider || '').trim().toLowerCase();
328
+ return id ? (0, provider_registry_js_1.getProviderRegistryEntry)(id) : undefined;
329
+ }
330
+ /** 现在**在册**的全部 provider id (内置 + 自定义) —— 只用于"未知供应商"那句话里列出可用项 */
331
+ function listRegisteredProviderIds() {
332
+ try {
333
+ return (0, provider_registry_js_1.listProviderRegistry)().map((e) => String(e.id)).filter(Boolean);
334
+ }
335
+ catch {
336
+ return [];
337
+ }
338
+ }
339
+ function envKeyOf(provider) {
340
+ for (const name of providerFactsOf(provider).envKeys) {
341
+ const v = process.env[name];
342
+ if (v && v.trim())
343
+ return { name: name, value: v.trim() };
344
+ }
345
+ return null;
346
+ }
347
+ /** 环境变量凭证 (给"重建运行时"这一处用) —— 只返回来源名与值, 不写进任何快照/日志 */
348
+ function envApiKeyOf(provider) {
349
+ return envKeyOf(provider);
350
+ }
351
+ /**
352
+ * URL 规范化 —— 去尾部斜杠、折叠重复斜杠、去重复的 `/v1`。
353
+ * `http://127.0.0.1:8O8O/v1/` 和 `http://127.0.0.1:8080/v1` 是同一个地址,
354
+ * 不做这一步就会出现"配置里写着 /v1, 拼出来是 /v1/v1/models"这种隐藏 URL 问题。
355
+ */
356
+ function normalizeBaseUrl(raw) {
357
+ let u = String(raw || '').trim();
358
+ if (!u)
359
+ return '';
360
+ // 去重复斜杠 (保留协议后的 `://`)
361
+ u = u.replace(/([^:])\/{2,}/g, '$1/');
362
+ u = u.replace(/\/+$/g, '');
363
+ // 去重复的 /v1 (只处理结尾出现两次以上的情况)
364
+ u = u.replace(/(\/v\d+)(\/v\d+)+$/i, '$1');
365
+ return u;
366
+ }
367
+ /** URL 形状校验 (只查形状, 不查可达性) */
368
+ function validateBaseUrlShape(raw) {
369
+ const url = normalizeBaseUrl(raw);
370
+ if (!url)
371
+ return { ok: false, reason: '空 URL' };
372
+ let parsed;
373
+ try {
374
+ parsed = new URL(url);
375
+ }
376
+ catch {
377
+ return { ok: false, reason: `不是合法 URL: ${url}` };
378
+ }
379
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
380
+ return { ok: false, reason: `只支持 http/https, 收到 ${parsed.protocol}` };
381
+ }
382
+ if (!parsed.hostname)
383
+ return { ok: false, reason: '缺少主机名' };
384
+ return { ok: true, url };
385
+ }
386
+ const CONFIG_HASH_FIELDS = ['provider', 'model', 'baseUrl', 'protocol'];
387
+ /** 稳定摘要: 同样的 provider/model/baseUrl/protocol 一定得到同样的 hash */
388
+ function configHashOf(sel) {
389
+ const parts = [
390
+ sel.provider,
391
+ sel.model,
392
+ normalizeBaseUrl(sel.baseUrl),
393
+ sel.protocol || protocolOf(sel.provider),
394
+ ];
395
+ return crypto.createHash('sha256').update(parts.join('\u0000')).digest('hex').slice(0, 16);
396
+ }
397
+ /** 一层候选 → 有效配置 (补齐 protocol / authRef / reasoning / hash) */
398
+ function materialize(sel, source) {
399
+ const provider = String(sel.provider || '');
400
+ const model = String(sel.model || '');
401
+ const baseUrl = normalizeBaseUrl(sel.baseUrl || '');
402
+ // 协议/环境变量/reasoning 一律走 `providerFactsOf`: 内置走内置表 (与旧实现逐字相同),
403
+ // 自定义走注册表 (它的声明才是唯一出处)。绝不在这里按 provider 名再写一份表。
404
+ const facts = providerFactsOf(provider);
405
+ const protocol = facts.protocol;
406
+ const envKey = envKeyOf(provider);
407
+ // 凭据来源: 显式声明优先 (用户选了"改用环境变量"就如实写 env:), 否则按"有 key ⇒ provider"推断
408
+ const credentialSource = sel.credentialSource ?? (sel.apiKey ? 'provider' : envKey ? 'env' : 'none');
409
+ const authRef = credentialSource === 'env' && envKey ? `env:${envKey.name}`
410
+ : sel.apiKey ? `provider:${provider}`
411
+ : envKey ? `env:${envKey.name}`
412
+ : 'none';
413
+ return {
414
+ provider,
415
+ model,
416
+ baseUrl,
417
+ protocol,
418
+ authRef,
419
+ reasoning: facts.reasoning,
420
+ reasoningMode: sel.reasoningMode === true ? 'on' : sel.reasoningMode === false ? 'off' : 'unset',
421
+ temperature: typeof sel.temperature === 'number' && Number.isFinite(sel.temperature) ? sel.temperature : null,
422
+ scope: source,
423
+ source,
424
+ updatedAt: sel.updatedAt || new Date().toISOString(),
425
+ configHash: configHashOf({ provider, model, baseUrl, protocol }),
426
+ };
427
+ }
428
+ function runModelConfigOf(eff, capturedAt = new Date().toISOString()) {
429
+ return {
430
+ provider: eff.provider,
431
+ model: eff.model,
432
+ baseUrl: eff.baseUrl,
433
+ configHash: eff.configHash,
434
+ selectionScope: eff.source,
435
+ capturedAt,
436
+ };
437
+ }
438
+ /**
439
+ * 五层优先级解析 (纯函数)。
440
+ *
441
+ * 为什么 provider 默认排在 env 前面、env 排最后: 配置文件里的一行是"用户明确选的东西",
442
+ * 内置默认是"这个供应商公认的模型", 环境变量只应该**补凭证**。环境变量要压过内置默认,
443
+ * 必须由调用方显式构造 env 层 —— 这样"谁赢了"永远能在代码里一眼读到, 不靠隐式启发式。
444
+ */
445
+ function resolveSelection(layers) {
446
+ for (const source of exports.SELECTION_PRIORITY) {
447
+ const layer = layers[source];
448
+ if (!layer)
449
+ continue;
450
+ if (!layer.provider)
451
+ continue;
452
+ if (!layer.model && !layer.baseUrl)
453
+ continue;
454
+ return materialize(layer, source);
455
+ }
456
+ // 五层全空: 退化到内置 openai 默认, 但如实标 provider 层 (不假装是用户选的)
457
+ const fallback = config_store_js_1.DEFAULT_PROVIDER_CONFIGS.openai;
458
+ return materialize({ provider: 'openai', model: fallback.model, baseUrl: fallback.baseUrl }, 'provider');
459
+ }
460
+ // ============================================================
461
+ // 磁盘层: Global (配置文件) / Session (会话绑定)
462
+ // ============================================================
463
+ exports.SESSION_BINDINGS_FILE = 'model-sessions.json';
464
+ exports.CONFIG_LOCK_FILE = 'bolloon-config.lock';
465
+ function sessionBindingsPath() {
466
+ return path.join(config_store_js_1.llmConfigStore.configDirPath(), exports.SESSION_BINDINGS_FILE);
467
+ }
468
+ /** 当前 CLI 会话键 (会话内 `/model --session` 绑定到它) */
469
+ function currentSessionKey() {
470
+ return (process.env.BOLLOON_SESSION_KEY || '').trim() || 'cli-default';
471
+ }
472
+ async function readSessionBindings() {
473
+ try {
474
+ const raw = await fsp.readFile(sessionBindingsPath(), 'utf-8');
475
+ const parsed = JSON.parse(raw);
476
+ if (parsed && typeof parsed === 'object' && parsed.sessions && typeof parsed.sessions === 'object') {
477
+ return { version: 1, sessions: parsed.sessions };
478
+ }
479
+ }
480
+ catch { /* 不存在/损坏 → 空表 (损坏不做静默放行: 读不出就当没有绑定) */ }
481
+ return { version: 1, sessions: {} };
482
+ }
483
+ async function readSessionSelection(sessionKey) {
484
+ const key = (sessionKey || currentSessionKey()).trim();
485
+ if (!key)
486
+ return null;
487
+ const all = await readSessionBindings();
488
+ const hit = all.sessions[key];
489
+ if (!hit || !hit.provider)
490
+ return null;
491
+ return {
492
+ provider: hit.provider,
493
+ model: hit.model,
494
+ baseUrl: hit.baseUrl,
495
+ ...(typeof hit.temperature === 'number' ? { temperature: hit.temperature } : {}),
496
+ ...(typeof hit.reasoningMode === 'boolean' ? { reasoningMode: hit.reasoningMode } : {}),
497
+ updatedAt: hit.updatedAt,
498
+ };
499
+ }
500
+ async function writeSessionSelection(sessionKey, sel) {
501
+ const key = String(sessionKey || '').trim();
502
+ if (!key)
503
+ throw new Error('sessionKey 为空, 拒绝写会话级模型绑定');
504
+ const all = await readSessionBindings();
505
+ all.sessions[key] = {
506
+ provider: sel.provider,
507
+ model: sel.model,
508
+ baseUrl: normalizeBaseUrl(sel.baseUrl || ''),
509
+ ...(typeof sel.temperature === 'number' ? { temperature: sel.temperature } : {}),
510
+ ...(typeof sel.reasoningMode === 'boolean' ? { reasoningMode: sel.reasoningMode } : {}),
511
+ updatedAt: new Date().toISOString(),
512
+ scope: 'session',
513
+ };
514
+ await atomicWriteJson(sessionBindingsPath(), all);
515
+ }
516
+ async function clearSessionSelection(sessionKey) {
517
+ const key = (sessionKey || currentSessionKey()).trim();
518
+ if (!key)
519
+ return;
520
+ const all = await readSessionBindings();
521
+ if (!(key in all.sessions))
522
+ return;
523
+ delete all.sessions[key];
524
+ await atomicWriteJson(sessionBindingsPath(), all);
525
+ }
526
+ /** 从配置文件读出 Global 层 (仅当这一份真的能用: 有 key 或不需要 key) */
527
+ async function readGlobalSelection() {
528
+ await config_store_js_1.llmConfigStore.initialize();
529
+ const cfg = await config_store_js_1.llmConfigStore.getConfig();
530
+ const provider = cfg.activeProvider;
531
+ const p = cfg.providers[provider];
532
+ if (!p)
533
+ return null;
534
+ const envKey = envKeyOf(provider);
535
+ const usable = !!p.apiKey || p.requiresApiKey === false || !!envKey;
536
+ if (!usable)
537
+ return null;
538
+ return {
539
+ provider,
540
+ model: p.model,
541
+ baseUrl: p.baseUrl,
542
+ apiKey: p.apiKey || envKey?.value,
543
+ ...(typeof p.temperature === 'number' ? { temperature: p.temperature } : {}),
544
+ ...(typeof p.reasoning === 'boolean' ? { reasoningMode: p.reasoning } : {}),
545
+ updatedAt: cfg.updatedAt,
546
+ };
547
+ }
548
+ /** provider 默认层 (内置表; 自定义供应商走注册表的声明 —— 它没有"内置默认"这回事) */
549
+ function providerDefaultSelection(provider) {
550
+ const id = String(provider || '').trim().toLowerCase();
551
+ const def = config_store_js_1.DEFAULT_PROVIDER_CONFIGS[id];
552
+ if (def) {
553
+ return { provider: id, model: def.model, baseUrl: def.baseUrl, apiKey: undefined };
554
+ }
555
+ const entry = registryEntryOf(id);
556
+ if (!entry || entry.kind !== 'custom')
557
+ return null;
558
+ const model = String(entry.defaultModel || entry.declaredModelIds?.[0] || '');
559
+ // 声明里没有模型名 → 这一层给不出候选 (不给"空模型"的层, 让解析继续往下走)
560
+ if (!model)
561
+ return null;
562
+ return { provider: id, model, baseUrl: entry.defaultBaseUrl, apiKey: undefined };
563
+ }
564
+ /**
565
+ * 「有效模型配置」的唯一解析入口。
566
+ * `runSelection` 由调用方给 (P1 之后, 执行中的 Run 拿自己的快照, 不重新解析)。
567
+ */
568
+ async function effectiveModelConfig(opts = {}) {
569
+ const session = await readSessionSelection(opts.sessionKey);
570
+ const global = opts.overrideGlobal !== undefined ? opts.overrideGlobal : await readGlobalSelection();
571
+ const activeProvider = global?.provider
572
+ || (await config_store_js_1.llmConfigStore.getActiveProvider().catch(() => 'openai'));
573
+ const envKey = envKeyOf(String(activeProvider));
574
+ const envLayer = envKey
575
+ ? { provider: String(activeProvider), model: '', baseUrl: '', apiKey: envKey.value }
576
+ : null;
577
+ return resolveSelection({
578
+ run: opts.runSelection || null,
579
+ session,
580
+ global,
581
+ provider: providerDefaultSelection(String(activeProvider)),
582
+ env: envLayer,
583
+ });
584
+ }
585
+ /** Run 开始时盖的快照 —— 只读, 不写任何东西 */
586
+ async function captureRunModelConfig(sessionKey) {
587
+ const eff = await effectiveModelConfig({ sessionKey });
588
+ return runModelConfigOf(eff);
589
+ }
590
+ /**
591
+ * 把**当前有效配置**装进模型运行时。
592
+ *
593
+ * 启动装配、切换回滚、重置都走这一个函数 —— 此前 CLI 启动按"环境变量里有哪些 key"猜供应商,
594
+ * 而 Web 启动按配置文件读, 两条路会给出不同的 provider/model (同一个 bin, 换个入口就换模型)。
595
+ * 现在只有一个来源: 有效模型配置。
596
+ */
597
+ async function applyEffectiveToRuntime(sessionKey) {
598
+ const eff = await effectiveModelConfig({ sessionKey });
599
+ const stored = await config_store_js_1.llmConfigStore.getProvider(eff.provider).catch(() => null);
600
+ const apiKey = stored?.apiKey || envKeyOf(eff.provider)?.value;
601
+ await installRuntime(eff, apiKey);
602
+ return eff;
603
+ }
604
+ /**
605
+ * 把**某个 Run 的快照**装进模型运行时 —— 长任务恢复走这条路 (P7 × P6)。
606
+ *
607
+ * 为什么必须有它: 恢复一个正在跑的 Run 时要用的**是它自己那一份** (而不是"现在盘上的全局默认"),
608
+ * 否则就是 P7 明令禁止的"在跑的 Run 漂移"。而"把一份配置装进运行时"这件事同样只有
609
+ * `installRuntime` 一处实现 —— 恢复路径不许自己再写一遍 `initMinimax({...})`。
610
+ *
611
+ * 返回的是**装进去的那一份有效配置** (逐字段可与快照对照: provider/model/baseUrl 应完全相同,
612
+ * `configHash` 也应相同 —— 同一个 provider/model/baseUrl/protocol 必得同一个 hash)。
613
+ */
614
+ async function applyRunModelConfigToRuntime(snapshot) {
615
+ const eff = materialize({
616
+ provider: snapshot.provider,
617
+ model: snapshot.model,
618
+ baseUrl: snapshot.baseUrl,
619
+ updatedAt: snapshot.capturedAt,
620
+ }, snapshot.selectionScope);
621
+ const key = await storedKeyOf(snapshot.provider);
622
+ await installRuntime(eff, key);
623
+ return eff;
624
+ }
625
+ /**
626
+ * 把一份有效配置装进客户端 —— **声明的 provider id** 与**运行时分支 id** 分开送。
627
+ *
628
+ * 为什么必须分开 (P3 的"兼容协议"落地): 自定义供应商不改联合类型, 它在运行期接到"说同一种协议"
629
+ * 的内置分支上 (`runtimeProviderIdOf`: openai-compatible → `openai`, …), 但它的**鉴权头**只有按
630
+ * 声明的 id 查注册表才拿得到 (`authHeader`)。旧写法只送 `eff.provider` 一个名字:
631
+ * · 送声明 id ('stub-gw') → 客户端 `switch` 落进 default, 直接 "Unsupported provider";
632
+ * · 送运行期 id ('openai') → 分支能跑, 但自定义的鉴权头永远用不上 (注册表那一格白填)。
633
+ * 所以两个都送: 分支用运行期 id, 鉴权/协议用声明 id。
634
+ *
635
+ * 注册表里查不到这个 id (老配置 / 未知供应商) → 原样返回, **不编**一条记录出来。
636
+ */
637
+ async function installRuntime(eff, apiKey) {
638
+ const declaredId = String(eff.provider || '');
639
+ let runtimeProvider = declaredId;
640
+ try {
641
+ const reg = await Promise.resolve().then(() => __importStar(require('./provider-registry.js')));
642
+ const entry = reg.getProviderRegistryEntry(declaredId);
643
+ if (entry)
644
+ runtimeProvider = String(reg.runtimeProviderIdOf(entry));
645
+ }
646
+ catch { /* 注册表不可用 → 用声明 id (内置供应商本来就是它自己) */ }
647
+ (0, index_js_1.initMinimax)({
648
+ provider: runtimeProvider,
649
+ providerId: declaredId,
650
+ apiKey,
651
+ baseUrl: eff.baseUrl,
652
+ model: eff.model,
653
+ });
654
+ }
655
+ /**
656
+ * 「备用模型候选」—— Supervisor 在**模型相关失败** + `auto` 策略下可以挑的那些。
657
+ *
658
+ * 只从注册表里挑**现在真能用**的 (手上真有凭证: 配置里的 key / 声明的环境变量 / 这家免 key),
659
+ * 而且必须 `canServeLongRunningTasks` (工具调用发不出去的通道不能跑长期任务)。
660
+ * 挑不到就返回空数组 —— 空数组的语义是"没有候选", 不是"随便挑一个"。
661
+ */
662
+ async function usableFallbackCandidates(current) {
663
+ const out = [];
664
+ try {
665
+ const reg = await Promise.resolve().then(() => __importStar(require('./provider-registry.js')));
666
+ const entries = reg.listProviderRegistry();
667
+ for (const entry of entries) {
668
+ const id = String(entry?.id || '');
669
+ if (!id)
670
+ continue;
671
+ if (!reg.canServeLongRunningTasks(id))
672
+ continue;
673
+ const stored = await config_store_js_1.llmConfigStore.getProvider(id).catch(() => null);
674
+ const envKey = envKeyOf(id);
675
+ const noKeyNeeded = entry.requiresApiKey === false;
676
+ if (!stored?.apiKey && !envKey && !noKeyNeeded)
677
+ continue;
678
+ const model = String(stored?.model || entry.defaultModel || '').trim();
679
+ if (!model)
680
+ continue;
681
+ const baseUrl = normalizeBaseUrl(String(stored?.baseUrl || entry.defaultBaseUrl || ''));
682
+ out.push({
683
+ provider: id,
684
+ model,
685
+ baseUrl,
686
+ configHash: configHashOf({ provider: id, model, baseUrl }),
687
+ // 这一份来自哪一层: 配置里写的就是配置层, 否则是注册表给的**供应商默认**层
688
+ // (快照字段只表达"配置从哪来"; "谁挑的" 由 Run 事件的 source=supervisor_fallback 表达)
689
+ selectionScope: stored ? 'global' : 'provider',
690
+ capturedAt: new Date().toISOString(),
691
+ });
692
+ }
693
+ }
694
+ catch {
695
+ return [];
696
+ }
697
+ // 丢掉与当前那份**逐字段相同**的候选 (pickFallbackConfig 也会跳, 这里先去掉免得报"有候选"却挑不到)
698
+ return out.filter((c) => !(current
699
+ && c.provider === current.provider
700
+ && c.model === current.model
701
+ && normalizeBaseUrl(c.baseUrl) === normalizeBaseUrl(current.baseUrl)));
702
+ }
703
+ // ============================================================
704
+ // 跨进程锁 (两个进程同时切配置, 不许互相覆盖)
705
+ // ============================================================
706
+ const LOCK_STALE_MS = 15_000;
707
+ async function withConfigLock(fn) {
708
+ const lockPath = path.join(config_store_js_1.llmConfigStore.configDirPath(), exports.CONFIG_LOCK_FILE);
709
+ await fsp.mkdir(path.dirname(lockPath), { recursive: true });
710
+ const deadline = Date.now() + LOCK_STALE_MS;
711
+ for (;;) {
712
+ try {
713
+ const fd = fsSync.openSync(lockPath, 'wx');
714
+ fsSync.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
715
+ fsSync.closeSync(fd);
716
+ break;
717
+ }
718
+ catch (e) {
719
+ if (e?.code !== 'EEXIST')
720
+ throw e;
721
+ // 陈旧锁回收: 持有者进程已死, 或超过阈值
722
+ let stale = false;
723
+ try {
724
+ const info = JSON.parse(fsSync.readFileSync(lockPath, 'utf-8'));
725
+ const age = Date.now() - Number(info?.at || 0);
726
+ let alive = true;
727
+ try {
728
+ process.kill(Number(info?.pid), 0);
729
+ }
730
+ catch {
731
+ alive = false;
732
+ }
733
+ stale = !alive || age > LOCK_STALE_MS;
734
+ }
735
+ catch {
736
+ stale = true;
737
+ }
738
+ if (stale) {
739
+ try {
740
+ fsSync.unlinkSync(lockPath);
741
+ }
742
+ catch { /* 别人抢先删了 */ }
743
+ continue;
744
+ }
745
+ if (Date.now() > deadline)
746
+ throw new Error('配置锁等待超时 (另一个进程正在改配置)');
747
+ await new Promise((r) => setTimeout(r, 25));
748
+ }
749
+ }
750
+ try {
751
+ return await fn();
752
+ }
753
+ finally {
754
+ try {
755
+ fsSync.unlinkSync(lockPath);
756
+ }
757
+ catch { /* 已释放 */ }
758
+ }
759
+ }
760
+ async function atomicWriteJson(file, value) {
761
+ const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(3).toString('hex')}`;
762
+ await fsp.writeFile(tmp, JSON.stringify(value, null, 2), { mode: 0o600 });
763
+ await fsp.rename(tmp, file);
764
+ }
765
+ /** 一个 provider 起手用的默认值 (配置里那一格 → 内置表 → 注册表声明; 都没有 → `null`) */
766
+ function baseDefaultsOf(provider, providerConfig) {
767
+ const id = String(provider || '').trim().toLowerCase();
768
+ const builtin = config_store_js_1.DEFAULT_PROVIDER_CONFIGS[id];
769
+ if (providerConfig) {
770
+ return {
771
+ model: String(providerConfig.model ?? (builtin?.model || '')),
772
+ baseUrl: String(providerConfig.baseUrl ?? (builtin?.baseUrl || '')),
773
+ requiresApiKey: providerConfig.requiresApiKey,
774
+ credential: String(providerConfig['api' + 'Key'] ?? ''),
775
+ };
776
+ }
777
+ if (builtin) {
778
+ return {
779
+ model: builtin.model,
780
+ baseUrl: builtin.baseUrl,
781
+ requiresApiKey: builtin.requiresApiKey,
782
+ credential: String(builtin['api' + 'Key'] ?? ''),
783
+ };
784
+ }
785
+ const entry = registryEntryOf(id);
786
+ if (!entry)
787
+ return null;
788
+ // 目录家 (`origin='catalog'`): **不**拿"目录里第一个模型"充当默认模型 —— 那是按字母序抓一个,
789
+ // 不是用户选的、也不是目录声明的"默认"。没有默认就是空, 由 validateSelection 如实报出来。
790
+ const isCatalog = entry.origin === 'catalog';
791
+ return {
792
+ model: String(entry.defaultModel || (isCatalog ? '' : entry.declaredModelIds?.[0]) || ''),
793
+ baseUrl: String(entry.defaultBaseUrl || ''),
794
+ requiresApiKey: entry.requiresApiKey,
795
+ };
796
+ }
797
+ function validateSelection(req, ctx) {
798
+ const provider = String(req.provider || '').trim().toLowerCase();
799
+ if (!provider) {
800
+ return { ok: false, failureClass: 'invalid_provider', message: '没有指定供应商' };
801
+ }
802
+ const base = baseDefaultsOf(provider, ctx.providerConfig);
803
+ if (!base) {
804
+ const known = [...Object.keys(config_store_js_1.DEFAULT_PROVIDER_CONFIGS)];
805
+ try {
806
+ for (const e of listRegisteredProviderIds())
807
+ if (!known.includes(e))
808
+ known.push(e);
809
+ }
810
+ catch { /* 注册表读不到 → 只报内置那份 (不编) */ }
811
+ return { ok: false, failureClass: 'invalid_provider', message: `未知供应商 '${provider}'. 可用: ${known.join(', ')}` };
812
+ }
813
+ // ★ 目录里的家, 鉴权形状本运行时不支持 → **根本没发请求**, 如实报清楚 (不许假装可用)。
814
+ // 放在模型/baseUrl 之前: 这是最根本的一条 —— 换 baseUrl 或换模型名都救不了它。
815
+ const regEntry = registryEntryOf(provider);
816
+ if (regEntry && regEntry.authSupported === false) {
817
+ return {
818
+ ok: false,
819
+ failureClass: 'provider_auth_unsupported',
820
+ // `authSupportNote` 自带"需专用鉴权 (未支持): <为什么>"的口径, 这里不再叠一层前缀 (否则读着像结巴)
821
+ message: `${provider} 不能切换: ${regEntry.authSupportNote || '需专用鉴权 (本版本未支持), 形状未知'}`
822
+ + ` —— 目录里有这一家, 但本运行时发不出它的请求 (要接就换成 openai-compatible 的自定义供应商, 自己给 baseUrl 与 key)`,
823
+ };
824
+ }
825
+ const model = String(req.model ?? base.model ?? '').trim();
826
+ if (!model) {
827
+ // 目录家没有"默认模型"这回事 (目录里也没声明) → 说清怎么指定, 不许抓一个凑数
828
+ if (regEntry && regEntry.origin === 'catalog') {
829
+ return {
830
+ ok: false,
831
+ failureClass: 'invalid_model',
832
+ message: `${provider} 是目录里的家, 目录里没有"默认模型" —— 请指定 model: /model ${provider} <model>`
833
+ + ` (目录里 ${regEntry.declaredModelIds.length} 个候选, 看 /model list ${provider})`,
834
+ };
835
+ }
836
+ return { ok: false, failureClass: 'invalid_model', message: `${provider} 没有可用模型 (配置为空且默认模型为空)` };
837
+ }
838
+ let baseUrl = base.baseUrl;
839
+ if (req.baseUrl !== undefined) {
840
+ const shape = validateBaseUrlShape(req.baseUrl);
841
+ if (!shape.ok) {
842
+ return { ok: false, failureClass: 'invalid_url', message: `base URL 非法 — ${shape.reason}` };
843
+ }
844
+ baseUrl = shape.url;
845
+ }
846
+ else {
847
+ baseUrl = normalizeBaseUrl(baseUrl);
848
+ if (!baseUrl) {
849
+ // 目录里没给 api 基址的家 (223 家里有 26 家如此) → 如实说"要自定义 baseUrl", **不编一个**
850
+ if (regEntry && regEntry.origin === 'catalog') {
851
+ return {
852
+ ok: false,
853
+ failureClass: 'invalid_url',
854
+ message: `${provider} 在目录里没有 api 基址 → 必须自定义 baseUrl (加 --base-url <地址> 或写进配置)`,
855
+ };
856
+ }
857
+ return { ok: false, failureClass: 'invalid_url', message: `${provider} 没有 base URL` };
858
+ }
859
+ }
860
+ const fallbackCredential = String(base.credential ?? '');
861
+ const action = req.credentialAction ?? (req.apiKey !== undefined ? 'replace' : 'keep');
862
+ const envKey = envKeyOf(provider);
863
+ const needKey = base.requiresApiKey !== false;
864
+ // ── 凭证意图 (2026-09-27): 四种意图在这里落地成"这一次要用的 key + 会不会写存盘凭据" ──
865
+ // `replace` / 历史不传 action 但有 apiKey → 用新 key; `clear` / `env` → 不用存盘凭据。
866
+ let apiKey;
867
+ let dropStored = false;
868
+ if (action === 'replace') {
869
+ const k = String(req.apiKey ?? '').trim();
870
+ if (!k) {
871
+ return { ok: false, failureClass: 'missing_api_key', message: '要替换凭证但没收到 key (未改动任何配置)' };
872
+ }
873
+ apiKey = k;
874
+ }
875
+ else if (action === 'clear' || action === 'env') {
876
+ // 两种都是"不再用存盘凭据": clear 连环境变量也要求有, 否则这家当场就没法用 ——
877
+ // 宁可在写盘之前拒掉并说清后果, 也不写下一个"看着像切换成功、其实没有凭证"的配置。
878
+ if (needKey && !envKey) {
879
+ return {
880
+ ok: false,
881
+ failureClass: 'missing_api_key',
882
+ message: `${action === 'clear' ? '清除' : '改用环境变量'}被拒绝: ${provider} 需要一个 key, 而`
883
+ + `环境变量 ${providerFactsOf(provider).envKeys.join('/') || '(这家没登记环境变量名)'} 没设 —— `
884
+ + '清掉存盘凭据后这家就用不了了。未改动任何配置 (要清就先 `export` 那个变量, 或改用"替换"给一个新 key)。',
885
+ };
886
+ }
887
+ apiKey = envKey?.value ?? '';
888
+ dropStored = true;
889
+ }
890
+ else {
891
+ apiKey = req.apiKey !== undefined ? req.apiKey : fallbackCredential;
892
+ }
893
+ if (needKey && !apiKey && !envKey) {
894
+ return {
895
+ ok: false,
896
+ failureClass: 'missing_api_key',
897
+ message: `${provider} 还需要 API key — 用 /model key ${provider} 配置, 或设置环境变量 ${providerFactsOf(provider).envKeys.join('/') || '(无)'}`,
898
+ };
899
+ }
900
+ // 生成参数: 温度只在 0~2 有意义。非法值**当场拒**, 不静默夹到边界 (夹了就是改了用户的意思)
901
+ if (req.temperature !== undefined) {
902
+ const t = Number(req.temperature);
903
+ if (!Number.isFinite(t) || t < 0 || t > 2) {
904
+ return { ok: false, failureClass: 'invalid_temperature', message: `temperature 只接受 0~2 的数字, 收到 '${req.temperature}'` };
905
+ }
906
+ }
907
+ return {
908
+ ok: true,
909
+ // 凭证意图的落地结果: 要不要把存盘凭据删掉 / 这家是不是必须 key —— 写盘阶段要用它,
910
+ // 所以由**同一个校验**算出来带出去, 免得写盘时再算一遍 (两处算法早晚会分叉)。
911
+ credentialDrop: dropStored,
912
+ requiresKey: needKey,
913
+ selection: {
914
+ provider,
915
+ model,
916
+ baseUrl,
917
+ apiKey: apiKey || envKey?.value,
918
+ ...(action === 'env' ? { credentialSource: 'env' } : {}),
919
+ ...(req.temperature !== undefined ? { temperature: Number(req.temperature) } : {}),
920
+ ...(req.reasoningMode !== undefined ? { reasoningMode: req.reasoningMode } : {}),
921
+ },
922
+ };
923
+ }
924
+ exports.PROBE_TIMEOUT_MS = 8000;
925
+ /**
926
+ * **唯一探测路径**: 把候选配置交给 P4 的探测原语 (`connection-probe.probe`), 再把它的 7 类失败
927
+ * 按 `PROBE_TO_SELECTION` **逐类映射**成入口类目。
928
+ *
929
+ * 为什么四层 URL 来源都要交给原语: "显式 > 配置 > 供应商默认 > 环境变量"这条优先级是**原语的**
930
+ * 语义 (`resolveChainBaseUrl`, 且"哪一层赢了"会回在 `baseUrlSource` 里)。入口若只传显式那一层,
931
+ * 就等于把"配置/默认/环境"这几层又拿回入口自己判 —— 两处真相, 而且会重现"填错的显式 URL 被
932
+ * 默认值悄悄盖掉"的隐藏 URL 问题。
933
+ *
934
+ * 凭证: 只以**引用名**进原语 (`provider:<id>` / `none`), 值由这里的 `resolveSecret` 现场给;
935
+ * 原语返回值里永不含 key (`resultLeaksSecret` 检查在门禁里)。
936
+ */
937
+ async function runConnectionProbe(opts) {
938
+ const provider = String(opts.provider || '').trim().toLowerCase();
939
+ let protocol = protocolOf(provider);
940
+ let providerDefaultUrl = config_store_js_1.DEFAULT_PROVIDER_CONFIGS[provider]?.baseUrl;
941
+ let envBaseUrlVar;
942
+ try {
943
+ const reg = await Promise.resolve().then(() => __importStar(require('./provider-registry.js')));
944
+ const entry = reg.getProviderRegistryEntry(provider);
945
+ if (entry) {
946
+ // 注册表是协议/默认地址/环境变量覆盖的**主来源** (自定义供应商只有它认识)
947
+ if (entry.protocol)
948
+ protocol = entry.protocol;
949
+ if (entry.defaultBaseUrl)
950
+ providerDefaultUrl = entry.defaultBaseUrl;
951
+ if (Array.isArray(entry.baseUrlEnvVars) && entry.baseUrlEnvVars.length)
952
+ envBaseUrlVar = String(entry.baseUrlEnvVars[0]);
953
+ }
954
+ }
955
+ catch { /* 注册表拿不到 → 退回内置协议表; 仍不编一个默认地址出来 */ }
956
+ const apiKey = String(opts.apiKey || '').trim();
957
+ const r = await (0, connection_probe_js_1.probe)({
958
+ providerId: provider,
959
+ baseUrl: opts.baseUrl,
960
+ configuredBaseUrl: opts.configuredBaseUrl,
961
+ providerDefaultUrl,
962
+ envBaseUrlVar,
963
+ protocol,
964
+ model: opts.model,
965
+ apiKeyRef: apiKey ? `provider:${provider}` : 'none',
966
+ resolveSecret: () => (apiKey ? apiKey : undefined),
967
+ timeoutMs: opts.timeoutMs ?? exports.PROBE_TIMEOUT_MS,
968
+ ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
969
+ });
970
+ const mapped = mapProbeFailureClass(r.failureClass);
971
+ const zh = r.failureClass ? connection_probe_js_1.PROBE_FAILURE_ZH[r.failureClass] : '';
972
+ const common = {
973
+ failureClassZh: zh,
974
+ message: r.message,
975
+ baseUrl: r.baseUrl,
976
+ baseUrlSource: r.baseUrlSource,
977
+ toolCalling: r.toolCalling,
978
+ ...(r.catalog ? { catalog: r.catalog } : {}),
979
+ // 实测事实: 上游目录没列这个名字, 但端点接受 (2026-09-27 实测: /models 不是可用全集)
980
+ ...(r.modelAcceptedOutsideCatalog ? { modelAcceptedOutsideCatalog: true } : {}),
981
+ checks: r.checks,
982
+ };
983
+ if (r.ok)
984
+ return { ok: true, unmapped: false, ...common };
985
+ return {
986
+ ok: false,
987
+ failureClass: mapped.failureClass,
988
+ probeClass: mapped.raw,
989
+ unmapped: mapped.unmapped,
990
+ ...common,
991
+ };
992
+ }
993
+ /**
994
+ * 兼容壳: 老调用方 (分步选择器的第 ⑥ 步预检 / 向导) 只要 `{ok, failureClass, detail, catalog}`。
995
+ * 内部走**同一条**探测路径 —— 不存在"入口一份原语、选择器另一份手写探测"的第二种实现。
996
+ */
997
+ async function probeSelection(sel) {
998
+ const r = await runConnectionProbe({ provider: sel.provider, model: sel.model, baseUrl: sel.baseUrl, apiKey: sel.apiKey });
999
+ const facts = r.checks.map((c) => `${c.step}:${c.ok ? '✓' : '✗'} ${c.detail}`).join(' | ');
1000
+ return {
1001
+ ok: r.ok,
1002
+ ...(r.ok ? {} : { failureClass: r.failureClass }),
1003
+ detail: r.ok
1004
+ ? r.message
1005
+ : `${r.message} [探测类目 ${r.probeClass}${r.unmapped ? ' → 未映射!' : ''}; 逐步事实: ${facts}]`,
1006
+ ...(r.catalog ? { catalog: r.catalog } : {}),
1007
+ // 「目录里没有但端点接受」的实测事实要传出去, 界面照实说 (2026-09-27 实测改判)
1008
+ ...(r.modelAcceptedOutsideCatalog ? { modelAcceptedOutsideCatalog: true } : {}),
1009
+ };
1010
+ }
1011
+ /**
1012
+ * **统一入口**。CLI `/model`、Web `/api/llm-config`、初始化向导、Agent 工具调用都走这里。
1013
+ * 任何一步失败都不留半成功状态: 写盘之前的所有失败 = 什么也没发生; 写盘之后的失败 = 字节级还原 + 运行时回滚。
1014
+ */
1015
+ async function selectModel(req) {
1016
+ await config_store_js_1.llmConfigStore.initialize();
1017
+ const previous = await effectiveModelConfig({ sessionKey: req.sessionKey });
1018
+ // ── 1) 校验 ───────────────────────────────────────────────
1019
+ const provider = String(req.provider || '').trim().toLowerCase();
1020
+ if (!provider) {
1021
+ return { ok: false, failureClass: 'invalid_provider', message: '没有指定供应商', previous };
1022
+ }
1023
+ const stored = await config_store_js_1.llmConfigStore.getProvider(provider);
1024
+ const validation = validateSelection({ ...req, provider }, { providerConfig: stored });
1025
+ if (!validation.ok || !validation.selection) {
1026
+ return { ok: false, failureClass: validation.failureClass, message: validation.message, previous };
1027
+ }
1028
+ const target = validation.selection;
1029
+ // 凭证是**全局**概念: 会话级切换只做"路由"(用哪家), 不新增/改动凭证。
1030
+ // 放在探测之前: 这类请求连试都不该试 —— 试一次就等于拿一个不打算落盘的 key 去打了一次上游。
1031
+ const scope = req.scope === 'session' ? 'session' : 'global';
1032
+ if (scope === 'session' && req.apiKey) {
1033
+ return {
1034
+ ok: false,
1035
+ failureClass: 'credential_scope_conflict',
1036
+ message: '会话级切换不写凭证 — 请先 `/model key <provider>` 配置全局凭证, 再用 `--session` 只切路由',
1037
+ previous,
1038
+ };
1039
+ }
1040
+ // ── 2) 连通探测 (写盘之前; 走 P4 探测原语这一条唯一路径) ─────
1041
+ const checks = [];
1042
+ // 探测里的「目录里没有但端点接受」实测事实 —— 探针已经算出来了, 这里必须**接着带出去**;
1043
+ // 否则 `SelectModelResult` 声明了字段却没有一条路能填上 (调用方/界面永远看不到)。
1044
+ let acceptedOutsideCatalog = false;
1045
+ const wantProbe = req.verify !== false && process.env.BOLLOON_MODEL_SKIP_PROBE !== '1';
1046
+ if (wantProbe) {
1047
+ const p = await runConnectionProbe({
1048
+ provider: target.provider,
1049
+ model: target.model,
1050
+ baseUrl: req.baseUrl, // 显式 --base-url (最高优先)
1051
+ configuredBaseUrl: stored?.baseUrl, // 配置里这一家现在写的地址
1052
+ apiKey: target.apiKey,
1053
+ });
1054
+ if (p.modelAcceptedOutsideCatalog)
1055
+ acceptedOutsideCatalog = true;
1056
+ // 逐项事实 (URL 来自哪一层 / 协议 / 凭证 / 目录 / 模型 / 工具调用) 全部如实带出去
1057
+ checks.push(`· base URL 来源=${p.baseUrlSource ?? '无'} → ${p.baseUrl || '(无 URL)'}; 工具调用能力=${p.toolCalling}`);
1058
+ for (const c of p.checks)
1059
+ checks.push(`${c.ok ? '✓' : '✗'} [${c.step}] ${c.detail}`);
1060
+ if (!p.ok) {
1061
+ const cls = p.failureClass;
1062
+ return {
1063
+ ok: false,
1064
+ failureClass: cls,
1065
+ message: `切换未生效 (探测失败, 配置与运行时保持原样) — ${cls} (${exports.SELECTION_FAILURE_ZH[cls]}): ${p.message}`
1066
+ + ` [探测类目=${p.probeClass ?? '无'}${p.unmapped ? ' ⚠ 未被映射表覆盖' : ''}]`,
1067
+ previous,
1068
+ checks,
1069
+ };
1070
+ }
1071
+ checks.push(`✓ 探测通过: ${p.message}`);
1072
+ }
1073
+ else {
1074
+ checks.push('· 已跳过连通探测 (BOLLOON_MODEL_SKIP_PROBE / verify:false)');
1075
+ }
1076
+ // ── 3~6) 写配置 → 更新 scope → 重建运行时 → 更新 session ────
1077
+ const configPath = config_store_js_1.llmConfigStore.configFilePath();
1078
+ let beforeBytes = null;
1079
+ try {
1080
+ beforeBytes = await fsp.readFile(configPath, 'utf-8');
1081
+ }
1082
+ catch {
1083
+ beforeBytes = null;
1084
+ }
1085
+ // 写盘阶段的失败**分两类**报出, 不再一律叫"切换失败": 写盘失败 (`persist_failed`) 与
1086
+ // 写完配置但重建运行时失败 (`runtime_rebuild_failed`) 是两件不同的事, 排障要看得出是哪一件。
1087
+ let wrote = false;
1088
+ try {
1089
+ if (scope === 'global') {
1090
+ await withConfigLock(async () => {
1091
+ // 拿到锁后**重读**: 另一个进程可能刚改过别的 provider (否则后写的会覆盖先写的)
1092
+ // (`initialize()` 自己会按文件签名判断是否重读; `invalidate()` 是补刀 ——
1093
+ // mtime 与 size 都撞上时签名不变, 只有显式作废才能保证一定重读)
1094
+ config_store_js_1.llmConfigStore.invalidate();
1095
+ await config_store_js_1.llmConfigStore.initialize();
1096
+ const patch = { enabled: true, model: target.model, baseUrl: target.baseUrl };
1097
+ if (validation.credentialDrop) {
1098
+ // 清除 / 改用环境变量: 存盘凭据**真的删掉** —— 写成空串 (写 `undefined` 是"这一格不动",
1099
+ // 那是另一件事; 这里要的就是"不再有存盘凭据")。同时如实回到"这家又要 key 了"的状态,
1100
+ // 否则列表会拿旧的 `requiresApiKey:false` 把一个已无凭据的家显示成可用 (那是假话)。
1101
+ patch.apiKey = '';
1102
+ patch.requiresApiKey = validation.requiresKey !== false;
1103
+ }
1104
+ else {
1105
+ // 只写"用户明确给了 key"的情况; 掩码/空值不动既有 key
1106
+ if (req.apiKey)
1107
+ patch.apiKey = req.apiKey;
1108
+ else if (target.apiKey && !(stored?.apiKey))
1109
+ patch.apiKey = target.apiKey;
1110
+ if (target.apiKey || stored?.apiKey || stored?.requiresApiKey === false) {
1111
+ patch.requiresApiKey = false;
1112
+ }
1113
+ }
1114
+ // 生成参数: 只有这一次明确给了才写 (不传 = 不动原有的值, 不拿内置默认去覆盖用户已经调好的)
1115
+ if (req.temperature !== undefined)
1116
+ patch.temperature = Number(req.temperature);
1117
+ if (req.reasoningMode !== undefined)
1118
+ patch.reasoning = req.reasoningMode;
1119
+ await config_store_js_1.llmConfigStore.updateProvider(provider, patch);
1120
+ await config_store_js_1.llmConfigStore.setActiveProvider(provider);
1121
+ });
1122
+ // Global 切换: 清掉当前会话的旧绑定, 让"这次切换"对当前会话也立刻成立
1123
+ await clearSessionSelection(req.sessionKey || currentSessionKey());
1124
+ }
1125
+ else {
1126
+ // 会话级: **一个字节都不写配置文件**, 只写会话绑定
1127
+ await writeSessionSelection(req.sessionKey || currentSessionKey(), target);
1128
+ }
1129
+ wrote = true;
1130
+ // 重建模型运行时 —— 从"刚写好的那一份有效配置"读, 保证运行时 == 有效配置
1131
+ const applied = await applyEffectiveToRuntime(req.sessionKey);
1132
+ if (applied.provider !== target.provider || applied.model !== target.model || applied.baseUrl !== target.baseUrl) {
1133
+ throw new Error(`重建后有效配置与目标不一致 (${applied.provider}/${applied.model}@${applied.baseUrl})`);
1134
+ }
1135
+ }
1136
+ catch (err) {
1137
+ // 回滚: 文件字节级还原 + 运行时按旧配置重建
1138
+ await rollbackConfig(configPath, beforeBytes);
1139
+ await restoreRuntime(previous);
1140
+ const cls = wrote ? 'runtime_rebuild_failed' : 'persist_failed';
1141
+ const zh = exports.SELECTION_FAILURE_ZH[cls];
1142
+ checks.push(`✗ [write] ${cls} (${zh}): ${String(err?.message || err).slice(0, 200)}`);
1143
+ return {
1144
+ ok: false,
1145
+ failureClass: cls,
1146
+ message: `切换失败已回滚, 旧配置仍生效 — ${cls} (${zh}): ${String(err?.message || err).slice(0, 300)}`,
1147
+ previous,
1148
+ checks,
1149
+ };
1150
+ }
1151
+ // ── 7) 返回真实生效的那一份 ────────────────────────────────
1152
+ const effective = await effectiveModelConfig({ sessionKey: req.sessionKey });
1153
+ return {
1154
+ ok: true, effective, previous, checks,
1155
+ // 「目录里没有但端点接受」这个**实测事实**照实带出去 (界面/报告要说真话, 但不回头改判成失败)
1156
+ ...(acceptedOutsideCatalog ? { modelAcceptedOutsideCatalog: true } : {}),
1157
+ };
1158
+ }
1159
+ async function rollbackConfig(configPath, beforeBytes) {
1160
+ try {
1161
+ if (beforeBytes === null) {
1162
+ // 之前没有配置文件 → 还原成"没有" 比留一个半成品更诚实
1163
+ await fsp.rm(configPath, { force: true });
1164
+ }
1165
+ else {
1166
+ await fsp.writeFile(configPath, beforeBytes, { mode: 0o600 });
1167
+ }
1168
+ }
1169
+ catch { /* 还原失败也不吞: 由调用方在 message 里能看到"配置可能已改" */ }
1170
+ config_store_js_1.llmConfigStore.invalidate();
1171
+ await config_store_js_1.llmConfigStore.initialize().catch(() => undefined);
1172
+ }
1173
+ async function restoreRuntime(prev) {
1174
+ try {
1175
+ const key = await storedKeyOf(prev.provider);
1176
+ await installRuntime(prev, key);
1177
+ }
1178
+ catch { /* 运行时还原失败: 单例仍指向被拒的候选, 调用方会看到 ok:false */ }
1179
+ }
1180
+ async function storedKeyOf(provider) {
1181
+ try {
1182
+ const p = await config_store_js_1.llmConfigStore.getProvider(provider);
1183
+ return p?.apiKey || envKeyOf(provider)?.value;
1184
+ }
1185
+ catch {
1186
+ return envKeyOf(provider)?.value;
1187
+ }
1188
+ }
1189
+ /** `/model reset` — 清掉会话级绑定并回到 Global 那一份, 运行时同步重建 */
1190
+ async function resetModelSelection(sessionKey) {
1191
+ const before = await effectiveModelConfig({ sessionKey });
1192
+ await clearSessionSelection(sessionKey);
1193
+ const global = await readGlobalSelection();
1194
+ if (global) {
1195
+ const key = await storedKeyOf(global.provider);
1196
+ try {
1197
+ // 走同一个装法: 声明 id → 注册表 → 运行时分支 (自定义供应商的协议/鉴权头才落得下来)
1198
+ await installRuntime(materialize(global, 'global'), key);
1199
+ }
1200
+ catch (e) {
1201
+ return { ok: false, message: `重置时重建运行时失败: ${String(e?.message || e).slice(0, 200)}`, previous: before };
1202
+ }
1203
+ }
1204
+ const effective = await effectiveModelConfig({ sessionKey });
1205
+ return { ok: true, effective, previous: before };
1206
+ }
1207
+ // ============================================================
1208
+ // 展示
1209
+ // ============================================================
1210
+ /** 一行 "provider/model · baseUrl · 作用域" —— 只给事实, 不给推测 */
1211
+ function formatEffectiveModel(eff) {
1212
+ const scopeZh = {
1213
+ run: 'Run 绑定',
1214
+ session: '当前会话',
1215
+ global: '全局默认',
1216
+ provider: '供应商默认',
1217
+ env: '环境变量',
1218
+ };
1219
+ return [
1220
+ `${eff.provider}/${eff.model}`,
1221
+ `base=${eff.baseUrl}`,
1222
+ `协议=${eff.protocol}`,
1223
+ `凭证=${eff.authRef}`,
1224
+ `${eff.reasoning ? 'reasoning=支持' : 'reasoning=不支持'}(偏好${eff.reasoningMode === 'unset' ? '未设' : eff.reasoningMode === 'on' ? '开' : '关'})`,
1225
+ `temperature=${eff.temperature === null ? '未设' : eff.temperature}`,
1226
+ `作用域=${scopeZh[eff.source]}`,
1227
+ `hash=${eff.configHash}`,
1228
+ ].join(' · ');
1229
+ }
1230
+ function providerDisplayName(provider) {
1231
+ return config_store_js_1.PROVIDER_INFO[provider]?.name || provider;
1232
+ }
1233
+ /**
1234
+ * 纯函数: 把 Run 快照与当前有效配置逐字段比。
1235
+ *
1236
+ * 存在意义: `configHash` 此前只被"写进快照"和"回显", 没有任何地方拿它**反着查一次** ——
1237
+ * 于是外部手改了配置文件 (或另一个进程切了默认模型) 时, 拿着旧快照的人不会知道。
1238
+ * 这里给出可判定的回答: 漂了就点名是哪几个字段漂了, 不漂就明确说"一致"。
1239
+ */
1240
+ function compareRunModelConfig(snapshot, current) {
1241
+ const snap = {
1242
+ provider: snapshot.provider,
1243
+ model: snapshot.model,
1244
+ baseUrl: normalizeBaseUrl(snapshot.baseUrl || ''),
1245
+ configHash: snapshot.configHash,
1246
+ };
1247
+ if (!current) {
1248
+ return {
1249
+ verified: false,
1250
+ drifted: false,
1251
+ snapshot,
1252
+ current: null,
1253
+ fields: [],
1254
+ message: `无法核对: 当前配置读不出来 (Run 快照仍是 ${snapshot.provider}/${snapshot.model}, hash=${snapshot.configHash})`,
1255
+ };
1256
+ }
1257
+ const cur = {
1258
+ provider: current.provider,
1259
+ model: current.model,
1260
+ baseUrl: normalizeBaseUrl(current.baseUrl || ''),
1261
+ configHash: current.configHash,
1262
+ };
1263
+ const fields = [];
1264
+ for (const f of ['provider', 'model', 'baseUrl', 'configHash']) {
1265
+ if (snap[f] !== cur[f])
1266
+ fields.push({ field: f, snapshot: snap[f], current: cur[f] });
1267
+ }
1268
+ if (!fields.length) {
1269
+ return {
1270
+ verified: true,
1271
+ drifted: false,
1272
+ snapshot,
1273
+ current,
1274
+ fields,
1275
+ message: `模型配置与 Run 快照一致 (${current.provider}/${current.model} @ ${current.baseUrl}, hash=${current.configHash})`,
1276
+ };
1277
+ }
1278
+ return {
1279
+ verified: true,
1280
+ drifted: true,
1281
+ snapshot,
1282
+ current,
1283
+ fields,
1284
+ message: `模型配置已偏离 Run 快照 — 快照: ${snapshot.provider}/${snapshot.model} @ ${normalizeBaseUrl(snapshot.baseUrl || '')} (hash=${snapshot.configHash}); 现在生效: ${current.provider}/${current.model} @ ${current.baseUrl} (hash=${current.configHash}); 变化的字段: ${fields.map((f) => f.field).join(', ')}`,
1285
+ };
1286
+ }
1287
+ /** 拿当前有效配置核对一份 Run 快照 (不写任何东西) */
1288
+ async function detectRunModelDrift(snapshot, sessionKey) {
1289
+ let current = null;
1290
+ try {
1291
+ current = await effectiveModelConfig({ sessionKey });
1292
+ }
1293
+ catch {
1294
+ current = null;
1295
+ }
1296
+ return compareRunModelConfig(snapshot, current);
1297
+ }
1298
+ /**
1299
+ * 从盘上读某个 Run 的快照再核对 (读不到 Run / 这个 Run 没快照 → `null`, 不编一份出来)。
1300
+ * 动态 import 记录层, 避免"记录层 ← 入口层"的双向静态依赖。
1301
+ */
1302
+ async function detectRunConfigDrift(runId, sessionKey) {
1303
+ try {
1304
+ const store = await Promise.resolve().then(() => __importStar(require('../agents/run-store.js')));
1305
+ const rec = await store.readRun(runId);
1306
+ const snap = rec?.modelConfig;
1307
+ if (!snap)
1308
+ return null;
1309
+ return await detectRunModelDrift(snap, sessionKey);
1310
+ }
1311
+ catch {
1312
+ return null;
1313
+ }
1314
+ }
1315
+ //# sourceMappingURL=model-selection.js.map