@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,1202 @@
1
+ "use strict";
2
+ /**
3
+ * model-discovery.ts — 模型发现与缓存 (P5, 2026-09-26)
4
+ *
5
+ * ## 这一层解决什么
6
+ *
7
+ * P3 给了"供应商是谁、它的目录端点在哪、怎么认证"; P4 给了"探测原语与失败七类"。
8
+ * 但**没人真的去问过** `/models`, 也没有缓存 —— 于是选择器里永远是那几张内置清单,
9
+ * 一断网就什么都没有, 而"这家到底有哪些模型"完全是猜的。
10
+ *
11
+ * 本层就干四件事:
12
+ *
13
+ * ① **发现**: 供应商**已认证**时优先取目录端点 (`openai-models` → `GET <base>/models`,
14
+ * `ollama-tags` → `GET <base>/api/tags`, `gemini-models` → `GET <base>/models` 的 Google 形状)。
15
+ * 没认证 / 没有可用端点 → **不发请求**, 如实记下"为什么没问"。
16
+ * ② **缓存**: 发现结果按 **provider + baseUrl + 凭证身份** 三要素分组, 带有效期 (默认 30 分钟)。
17
+ * 凭证身份**只存指纹不存明文** (`fp:<sha256 前 16 位>`; 没凭据 = `anonymous`) ⇒
18
+ * **两个不同的 key 各自一份缓存, 互不可见/互不串收**。
19
+ * ③ **回退链**: 网络不可用 → 用**上一次成功缓存** (标记 `cached`); 从未成功 → 用**内置目录**
20
+ * (`curated`); 自定义模型**允许手动输入** (`admitManualModel`, 标记 `custom`)。
21
+ * ④ **不静默删**: 发现失败**绝不**把 provider 从列表里删掉 —— 保留它, 标 `unavailable` + 原因
22
+ * (`failure.failureClass` + 人话理由)。列表长度恒等于注册表长度, 有门钉住。
23
+ *
24
+ * ## 五种标记 (计划逐字) 与"两个字段"的划分
25
+ *
26
+ * | 标记 | 含义 | 落在哪个字段 |
27
+ * | --- | --- | --- |
28
+ * | `live` | **本轮**真取到目录 (HTTP 2xx 且解析出 ≥1 个模型) | `origin` + `discoveryState` |
29
+ * | `cached` | 用的缓存 (新鲜期内直接命中, 或网络不可用时的上次成功缓存) | `origin` + `discoveryState` |
30
+ * | `curated` | 清单来自**内置目录** (没端点 / 没凭据 / 从未发现成功) | `origin` + `discoveryState` |
31
+ * | `custom` | 清单来自**用户声明或手工输入**的模型 ID | 逐条在 `modelOrigins` |
32
+ * | `unavailable` | **本轮发现失败**, 且回退链上**什么都没有** | `origin`; 只要失败就 `discoveryState='unavailable'` |
33
+ *
34
+ * 为什么要有两个字段 (而不是一个): 一个事实, 两种问法。
35
+ * - "这份清单是从哪来的?" → `origin` (live/cached/curated/custom);
36
+ * - "这一家现在能不能发现?" → `discoveryState`, **只要本轮尝试失败就是 `unavailable` + `failure.reason`**
37
+ * —— 哪怕回退到了 `cached` 清单, 也必须让界面看得见"这次没问通"。
38
+ * 于是"发现失败不许静默"与"断网还能用缓存"两句话同时成立, 不需要挑一个装作不知道。
39
+ *
40
+ * ## 缓存落在哪 / 里面有什么
41
+ *
42
+ * `~/.bolloon/model-discovery-cache.json` (mode 0600, 目录跟随 `BOLLOON_HOME`, **调用时**解析)。
43
+ * 缓存是**运行时数据**, 不是配置: 所以不挤进 `bolloon-config.json` (那是有写锁的配置文件, 塞缓存
44
+ * 会让"配置变了"和"缓存变了"混成一件事)。单条记录只存**指纹**:
45
+ *
46
+ * ```json
47
+ * { "version": 1, "entries": { "<cacheKey>": {
48
+ * "provider": "deepseek", "baseUrl": "https://api.deepseek.com/v1",
49
+ * "credentialIdentity": "fp:1a2b…", "endpoint": "…/models",
50
+ * "models": ["deepseek-chat"], "facts": {}, "manualModels": [],
51
+ * "lastSuccessAt": "…", "fetchedAt": "…", "expiresAt": "…" } } }
52
+ * ```
53
+ *
54
+ * `cacheKey = sha256(provider \u0000 baseUrl \u0000 credentialIdentity)` —— 三要素任何一项不同
55
+ * 就是**另一条缓存**。明文 key 一个字节都不落盘 (有门比对: 文件里既没有 key, 也没有 key 的任何切片)。
56
+ *
57
+ * 凭证身份刻意用**截断指纹**: 它只是"分组桶", 不是凭据。**同一把 key 的指纹稳定** ⇒ 缓存能命中;
58
+ * **不同 key 的指纹几乎不可能相同** ⇒ 不会互相串收。
59
+ *
60
+ * ## 空目录 ≠ 失败
61
+ *
62
+ * - HTTP 401/403 → `auth_failed`; 5xx/429 → `provider_unreachable`; 无响应 → `timeout` (全部来自 P4)
63
+ * —— **一律不当"这家没有模型"**;
64
+ * - HTTP 200 但形状不是目录 (`{data:[…]}`/`{models:[…]}` 都没有) → `protocol_mismatch`;
65
+ * - HTTP 200 且形状对但**0 个模型** → `model_not_found` + 理由说明"不当成空目录缓存";
66
+ * 三种情形都**不覆盖**上次成功缓存 (缓存只由"真的取到 ≥1 个模型"的成功写)。
67
+ *
68
+ * ## 有真值才填 (P2 冻结的元数据填充点)
69
+ *
70
+ * 本层向 `registerModelMetadataSource()` 注册一个填充点 (`id = 'live-discovery'`):
71
+ * - `origin`: 只有当这个模型**真在**本轮的目录/手输清单里才填 (逐条 `modelOrigins`);
72
+ * - `toolCalling` / `reasoning` / `contextLength` / `displayName`: **只填响应体里字面写着的那几个字段**
73
+ * (见 `FACT_KEYS`), 其余一律不出现 (= 保持 `unknown`, 由 P2 显示"未知原因")。
74
+ * 内置清单 (`curatedModelIds`) 只有 ID, 所以没跑发现时这些字段照旧是 `unknown` —— 不许编。
75
+ * 填充点**纯同步**: 数据来自进程内快照 (`currentDiscoveryCatalog`), 不在填充点里发请求、读盘。
76
+ *
77
+ * ## 命令面 (能力做成可调用函数, 命令面接线归 P0/P6 那条线)
78
+ *
79
+ * ```
80
+ * /model refresh → refreshModelDiscovery() (强制真取一次)
81
+ * /model list → listModelCatalog() (全体, 一家都不删)
82
+ * /model list <provider> → listModelCatalog(providerId)
83
+ * 手工输入自定义模型 → admitManualModel(providerId, modelId) / forgetManualModel()
84
+ * 清缓存 → clearDiscoveryCache(providerId?)
85
+ * ```
86
+ *
87
+ * ## 凭据纪律
88
+ *
89
+ * `apiKey` 只在 `DiscoveryTarget.apiKey` 里为了**当次请求构造**而存在 (与 P4 的 `authHeadersFor` 同一约定):
90
+ * 不进日志、不进缓存、不进 catalog、不进报告 (`[REDACTED]`)。所有会把文本交出去的路径
91
+ * (失败理由 = 上游响应片段) 都过一遍 `redactSecretIn()`。
92
+ */
93
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
94
+ if (k2 === undefined) k2 = k;
95
+ var desc = Object.getOwnPropertyDescriptor(m, k);
96
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
97
+ desc = { enumerable: true, get: function() { return m[k]; } };
98
+ }
99
+ Object.defineProperty(o, k2, desc);
100
+ }) : (function(o, m, k, k2) {
101
+ if (k2 === undefined) k2 = k;
102
+ o[k2] = m[k];
103
+ }));
104
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
105
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
106
+ }) : function(o, v) {
107
+ o["default"] = v;
108
+ });
109
+ var __importStar = (this && this.__importStar) || (function () {
110
+ var ownKeys = function(o) {
111
+ ownKeys = Object.getOwnPropertyNames || function (o) {
112
+ var ar = [];
113
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
114
+ return ar;
115
+ };
116
+ return ownKeys(o);
117
+ };
118
+ return function (mod) {
119
+ if (mod && mod.__esModule) return mod;
120
+ var result = {};
121
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
122
+ __setModuleDefault(result, mod);
123
+ return result;
124
+ };
125
+ })();
126
+ Object.defineProperty(exports, "__esModule", { value: true });
127
+ exports.DISCOVERY_SOURCE_ID = exports.FINGERPRINT_PREFIX = exports.ANONYMOUS_IDENTITY = exports.DEFAULT_DISCOVERY_TIMEOUT_MS = exports.DEFAULT_DISCOVERY_TTL_MS = exports.DISCOVERY_CACHE_VERSION = exports.DISCOVERY_CACHE_FILE = void 0;
128
+ exports.credentialIdentityOf = credentialIdentityOf;
129
+ exports.isFingerprintIdentity = isFingerprintIdentity;
130
+ exports.discoveryCacheKey = discoveryCacheKey;
131
+ exports.redactSecretIn = redactSecretIn;
132
+ exports.discoveryCacheLeaks = discoveryCacheLeaks;
133
+ exports.discoveryCachePath = discoveryCachePath;
134
+ exports.readDiscoveryCache = readDiscoveryCache;
135
+ exports.writeDiscoveryCache = writeDiscoveryCache;
136
+ exports.clearDiscoveryCache = clearDiscoveryCache;
137
+ exports.parseCatalogBody = parseCatalogBody;
138
+ exports.resolveDiscoveryTarget = resolveDiscoveryTarget;
139
+ exports.currentDiscoveryCatalog = currentDiscoveryCatalog;
140
+ exports.currentDiscoveryProviders = currentDiscoveryProviders;
141
+ exports.setDiscoverySnapshot = setDiscoverySnapshot;
142
+ exports.resetDiscoverySnapshot = resetDiscoverySnapshot;
143
+ exports.discoverProviderModels = discoverProviderModels;
144
+ exports.cachedUpstreamModels = cachedUpstreamModels;
145
+ exports.listModelCatalog = listModelCatalog;
146
+ exports.refreshModelDiscovery = refreshModelDiscovery;
147
+ exports.admitManualModel = admitManualModel;
148
+ exports.forgetManualModel = forgetManualModel;
149
+ exports.catalogOriginZh = catalogOriginZh;
150
+ exports.formatCatalogLine = formatCatalogLine;
151
+ exports.formatListingSummary = formatListingSummary;
152
+ exports.modelDiscoveryMetadataSource = modelDiscoveryMetadataSource;
153
+ exports.registerDiscoveryMetadataSource = registerDiscoveryMetadataSource;
154
+ exports.ensureDiscoveryMetadataSource = ensureDiscoveryMetadataSource;
155
+ exports.isDiscoveryMetadataSourceWired = isDiscoveryMetadataSourceWired;
156
+ const fs = __importStar(require("fs/promises"));
157
+ const path = __importStar(require("path"));
158
+ const crypto = __importStar(require("crypto"));
159
+ const config_store_js_1 = require("./config-store.js");
160
+ const model_selection_js_1 = require("./model-selection.js");
161
+ const provider_registry_js_1 = require("./provider-registry.js");
162
+ const model_catalog_js_1 = require("./model-catalog.js");
163
+ const connection_probe_js_1 = require("./connection-probe.js");
164
+ // ★ 2026-09-27 (目录驱动): 发现层把"真目录覆盖了哪些模型"**注入**给目录层。
165
+ // 方向是 discovery → catalog (单向, 不成环): 目录层不 import 本文件, 它只认注入进来的探针函数。
166
+ const provider_catalog_js_1 = require("./provider-catalog.js");
167
+ // ============================================================
168
+ // 常量
169
+ // ============================================================
170
+ /** 缓存文件名 (落在配置目录 0600) */
171
+ exports.DISCOVERY_CACHE_FILE = 'model-discovery-cache.json';
172
+ /** 缓存文件格式版本 */
173
+ exports.DISCOVERY_CACHE_VERSION = 1;
174
+ /** 缓存有效期 (默认 30 分钟) */
175
+ exports.DEFAULT_DISCOVERY_TTL_MS = 30 * 60 * 1000;
176
+ /** 发现请求的超时 (复用 P4 的探测超时) */
177
+ exports.DEFAULT_DISCOVERY_TIMEOUT_MS = connection_probe_js_1.DEFAULT_PROBE_TIMEOUT_MS;
178
+ /** 没有凭据时的凭证身份 (不是"没有这一格", 是"这一格明确是匿名") */
179
+ exports.ANONYMOUS_IDENTITY = 'anonymous';
180
+ /** 指纹前缀 —— 一眼看出"这是指纹, 不是明文" */
181
+ exports.FINGERPRINT_PREFIX = 'fp:';
182
+ /** 手工输入的模型 ID 长度上限 (超了说明不是模型名) */
183
+ const MANUAL_MODEL_MAX_LEN = 200;
184
+ /**
185
+ * 从响应体里**字面读到**才算的能力字段 (键名 → 取法)。
186
+ * 只认字面 `boolean` / `number`: 字符串、对象、缺省一律不当结论 (那就是 `unknown`)。
187
+ */
188
+ const FACT_KEYS = {
189
+ contextLength: ['contextLength', 'context_length', 'context_window', 'contextWindow', 'inputTokenLimit', 'max_input_tokens', 'max_context_tokens'],
190
+ toolCalling: [['capabilities', 'toolCalling'], ['capabilities', 'tool_calling'], ['capabilities', 'tools'], ['tool_calling'], ['supports_tools']],
191
+ reasoning: [['capabilities', 'reasoning'], ['capabilities', 'thinking'], ['reasoning'], ['supports_reasoning']],
192
+ displayName: ['displayName', 'display_name'],
193
+ };
194
+ // ============================================================
195
+ // 凭证身份 / 缓存键 (纯函数)
196
+ // ============================================================
197
+ /**
198
+ * 凭证身份: **只产指纹, 不产明文**。
199
+ *
200
+ * 没凭据 → `anonymous`; 有凭据 → `fp:<sha256 前 16 位>` (先加一个固定命名空间前缀再哈希,
201
+ * 免得"某处一把 key 的裸 sha"能拿来对表)。
202
+ *
203
+ * 它只是**缓存分组桶**: 同一把 key 稳定命中, 不同 key 分成两份。
204
+ */
205
+ function credentialIdentityOf(apiKey) {
206
+ const k = String(apiKey ?? '');
207
+ if (!k.trim())
208
+ return exports.ANONYMOUS_IDENTITY;
209
+ const h = crypto.createHash('sha256').update(`bolloon-model-discovery\u0000${k}`).digest('hex');
210
+ return `${exports.FINGERPRINT_PREFIX}${h.slice(0, 16)}`;
211
+ }
212
+ /** 这个身份是不是指纹 (不是 `anonymous`) */
213
+ function isFingerprintIdentity(identity) {
214
+ return String(identity || '').startsWith(exports.FINGERPRINT_PREFIX);
215
+ }
216
+ /**
217
+ * 缓存键 = `sha256(provider \u0000 baseUrl \u0000 credentialIdentity)`。
218
+ *
219
+ * 三要素**任缺一不可**: 少了凭证身份就会"两个 key 共用一份缓存"(有变异门钉住),
220
+ * 少了 baseUrl 就会把"同一个 provider 换到自建网关上"的两份目录混起来。
221
+ */
222
+ function discoveryCacheKey(provider, baseUrl, credentialIdentity) {
223
+ const parts = [String(provider || ''), String(baseUrl || ''), String(credentialIdentity || '')];
224
+ return crypto.createHash('sha256').update(parts.join('\u0000')).digest('hex').slice(0, 24);
225
+ }
226
+ /** 把文本里的凭据抹掉 (失败理由 = 上游响应片段时要过这一道) */
227
+ function redactSecretIn(text, secret) {
228
+ const s = String(text ?? '');
229
+ const k = String(secret ?? '');
230
+ if (!k || k.length < 4)
231
+ return s;
232
+ return s.split(k).join('[REDACTED]');
233
+ }
234
+ /** 缓存文件里有没有凭据明文 (门用; 逐条切片也比) */
235
+ function discoveryCacheLeaks(cache, secret) {
236
+ const text = typeof cache === 'string' ? cache : JSON.stringify(cache ?? {});
237
+ const k = String(secret ?? '');
238
+ if (!k || k.length < 4)
239
+ return false;
240
+ if (text.includes(k))
241
+ return true;
242
+ // 连"大段切片"也不许出现 (防止只存了 key 的一部分还以为没事)
243
+ const slice = k.slice(0, 8);
244
+ return slice.length >= 8 && text.includes(slice);
245
+ }
246
+ // ============================================================
247
+ // 缓存文件 (只在配置目录里, 0600)
248
+ // ============================================================
249
+ /** 缓存文件绝对路径 (`BOLLOON_HOME` **调用时**解析, 与配置文件同一个目录) */
250
+ function discoveryCachePath() {
251
+ return path.join(config_store_js_1.llmConfigStore.configDirPath(), exports.DISCOVERY_CACHE_FILE);
252
+ }
253
+ /** 读缓存 (文件不在/坏了 → 空表 + `warnings`, **不抛**) */
254
+ async function readDiscoveryCache() {
255
+ const empty = { version: exports.DISCOVERY_CACHE_VERSION, entries: {} };
256
+ let text;
257
+ try {
258
+ text = await fs.readFile(discoveryCachePath(), 'utf-8');
259
+ }
260
+ catch {
261
+ return empty;
262
+ }
263
+ let parsed;
264
+ try {
265
+ parsed = JSON.parse(text);
266
+ }
267
+ catch {
268
+ return { ...empty, warnings: ['缓存文件不是合法 JSON → 当空表 (下次成功发现会覆盖它)'] };
269
+ }
270
+ if (!parsed || typeof parsed !== 'object')
271
+ return { ...empty, warnings: ['缓存文件形状不对 → 当空表'] };
272
+ if (parsed.version !== exports.DISCOVERY_CACHE_VERSION) {
273
+ return { ...empty, warnings: [`缓存文件版本 ${String(parsed.version)} ≠ ${exports.DISCOVERY_CACHE_VERSION} → 当空表`] };
274
+ }
275
+ const out = { version: exports.DISCOVERY_CACHE_VERSION, entries: {} };
276
+ for (const [key, raw] of Object.entries(parsed.entries || {})) {
277
+ if (!raw || typeof raw !== 'object')
278
+ continue;
279
+ const r = raw;
280
+ out.entries[key] = {
281
+ provider: String(r.provider || ''),
282
+ baseUrl: String(r.baseUrl || ''),
283
+ credentialIdentity: String(r.credentialIdentity || exports.ANONYMOUS_IDENTITY),
284
+ endpoint: String(r.endpoint || ''),
285
+ models: Array.isArray(r.models) ? r.models.filter((m) => typeof m === 'string' && m) : [],
286
+ facts: r.facts && typeof r.facts === 'object' ? r.facts : {},
287
+ manualModels: Array.isArray(r.manualModels) ? r.manualModels.filter((m) => typeof m === 'string' && m) : [],
288
+ lastSuccessAt: typeof r.lastSuccessAt === 'string' ? r.lastSuccessAt : null,
289
+ fetchedAt: typeof r.fetchedAt === 'string' ? r.fetchedAt : null,
290
+ expiresAt: typeof r.expiresAt === 'string' ? r.expiresAt : null,
291
+ };
292
+ }
293
+ return out;
294
+ }
295
+ /** 写缓存 (临时文件 + rename, mode 0600; 目录不在就建) */
296
+ async function writeDiscoveryCache(file) {
297
+ const target = discoveryCachePath();
298
+ const dir = path.dirname(target);
299
+ await fs.mkdir(dir, { recursive: true });
300
+ const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
301
+ const payload = { version: exports.DISCOVERY_CACHE_VERSION, entries: file.entries || {} };
302
+ await fs.writeFile(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 });
303
+ await fs.chmod(tmp, 0o600).catch(() => { });
304
+ await fs.rename(tmp, target);
305
+ await fs.chmod(target, 0o600).catch(() => { });
306
+ }
307
+ /** 清缓存 (给了 provider 就只清这一家; 返回清了几条) */
308
+ async function clearDiscoveryCache(provider) {
309
+ const file = await readDiscoveryCache();
310
+ const id = String(provider || '').trim();
311
+ if (!id) {
312
+ const n = Object.keys(file.entries).length;
313
+ await writeDiscoveryCache({ version: exports.DISCOVERY_CACHE_VERSION, entries: {} });
314
+ return n;
315
+ }
316
+ const keys = Object.keys(file.entries).filter((k) => file.entries[k].provider === id);
317
+ for (const k of keys)
318
+ delete file.entries[k];
319
+ await writeDiscoveryCache(file);
320
+ return keys.length;
321
+ }
322
+ function pickString(obj, key) {
323
+ const v = obj?.[key];
324
+ return typeof v === 'string' && v.trim() ? v.trim() : undefined;
325
+ }
326
+ function pickBool(obj, keyPaths) {
327
+ for (const kp of keyPaths) {
328
+ const path = Array.isArray(kp) ? kp : [kp];
329
+ let cur = obj;
330
+ for (const seg of path) {
331
+ if (cur === null || typeof cur !== 'object') {
332
+ cur = undefined;
333
+ break;
334
+ }
335
+ cur = cur[seg];
336
+ }
337
+ if (typeof cur === 'boolean')
338
+ return cur ? 'yes' : 'no';
339
+ }
340
+ return undefined;
341
+ }
342
+ function pickNumber(obj, keys) {
343
+ for (const k of keys) {
344
+ const v = obj?.[k];
345
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0)
346
+ return Math.floor(v);
347
+ }
348
+ return undefined;
349
+ }
350
+ /** 一家一个条目 → 事实 (只认字面字段) */
351
+ function factsOfItem(item) {
352
+ const facts = {};
353
+ if (!item || typeof item !== 'object')
354
+ return facts;
355
+ const tool = pickBool(item, FACT_KEYS.toolCalling);
356
+ if (tool)
357
+ facts.toolCalling = tool;
358
+ const reasoning = pickBool(item, FACT_KEYS.reasoning);
359
+ if (reasoning)
360
+ facts.reasoning = reasoning;
361
+ const ctx = pickNumber(item, FACT_KEYS.contextLength);
362
+ if (ctx)
363
+ facts.contextLength = ctx;
364
+ const name = pickString(item, FACT_KEYS.displayName[0]) ?? pickString(item, FACT_KEYS.displayName[1]);
365
+ if (name)
366
+ facts.displayName = name;
367
+ return facts;
368
+ }
369
+ /** 去掉 gemini 形状的 `models/` 前缀 (其余**逐字保留**) */
370
+ function stripModelsPrefix(id) {
371
+ return id.replace(/^models\//, '');
372
+ }
373
+ /**
374
+ * 解析目录响应体。**不编** —— 认不出形状就 `shape_error` (上层判 `protocol_mismatch`),
375
+ * 形状对但空列表就是空列表 (上层判 `model_not_found`, 不当"成功"缓存)。
376
+ */
377
+ function parseCatalogBody(body, protocol) {
378
+ if (body === null || typeof body !== 'object') {
379
+ return { kind: 'shape_error', detail: '响应体不是 JSON 对象' };
380
+ }
381
+ // 通用: 一个数组字段 → 逐个取 ID
382
+ let arr = null;
383
+ if (Array.isArray(body.data))
384
+ arr = body.data;
385
+ else if (Array.isArray(body.models))
386
+ arr = body.models;
387
+ else if (Array.isArray(body.tags))
388
+ arr = body.tags;
389
+ else if (Array.isArray(body))
390
+ arr = body;
391
+ if (arr) {
392
+ const ids = [];
393
+ const facts = {};
394
+ for (const item of arr) {
395
+ let id;
396
+ if (typeof item === 'string')
397
+ id = item;
398
+ else if (item && typeof item === 'object') {
399
+ id = pickString(item, 'id') ?? pickString(item, 'name') ?? pickString(item, 'model');
400
+ // gemini / ollama 的 name 是唯一真名字段, 这里再兜一次 model_id
401
+ if (!id)
402
+ id = pickString(item, 'model_id');
403
+ }
404
+ if (!id)
405
+ continue;
406
+ const finalId = protocol === 'gemini' ? stripModelsPrefix(id) : id;
407
+ if (!finalId)
408
+ continue;
409
+ const f = factsOfItem(item);
410
+ if (Object.keys(f).length)
411
+ facts[finalId] = { ...(facts[finalId] || {}), ...f };
412
+ ids.push(finalId);
413
+ }
414
+ return { kind: 'ok', ids, facts };
415
+ }
416
+ // ollama `/api/tags` 的另一种形状: { models: [...] } 已覆盖; 其余一律算形状不对
417
+ return {
418
+ kind: 'shape_error',
419
+ detail: '响应体里没有模型数组 (data/models/tags 都不是数组)',
420
+ };
421
+ }
422
+ // ============================================================
423
+ // 目标解析: baseUrl + 凭据 + 端点 (四层 URL 优先级复用 P4)
424
+ // ============================================================
425
+ async function resolveCredential(providerId, entry, opts) {
426
+ const explicit = String(opts.apiKey ?? '').trim();
427
+ if (explicit)
428
+ return { apiKey: explicit, source: 'explicit' };
429
+ if (!entry)
430
+ return { source: 'none' };
431
+ if (entry.kind === 'custom') {
432
+ // 2026-09-26 (P6 接线时逼出来的): 自定义供应商在**全新进程**里可能还没被注册表认识 ——
433
+ // `config-store.initialize()` 才会把 `customProviders` 推成注册表快照 (setCustomProviderSnapshot)。
434
+ // 此前这条分支直接读快照, 于是"刚起的进程 + 自定义供应商"会报"未配置凭据 + 发现失败原因未明"
435
+ // (内置那条分支顺手 initialize 了, 所以只有自定义会踩到)。
436
+ await ensureConfigSnapshot();
437
+ const spec = (0, provider_registry_js_1.customProviderSpecOf)(providerId);
438
+ const own = String(spec?.apiKey ?? '').trim();
439
+ if (own)
440
+ return { apiKey: own, source: 'config' };
441
+ const envName = String(spec?.apiKeyEnvVar ?? '').trim();
442
+ const fromEnv = envName ? String(process.env[envName] ?? '').trim() : '';
443
+ if (fromEnv)
444
+ return { apiKey: fromEnv, source: 'env' };
445
+ return { source: 'none' };
446
+ }
447
+ await ensureConfigSnapshot();
448
+ const cfg = await config_store_js_1.llmConfigStore.getConfig();
449
+ const own = String(cfg?.providers?.[providerId]?.apiKey ?? '').trim();
450
+ if (own)
451
+ return { apiKey: own, source: 'config' };
452
+ const env = (0, model_selection_js_1.envApiKeyOf)(providerId);
453
+ if (env?.value)
454
+ return { apiKey: env.value, source: 'env' };
455
+ return { source: 'none' };
456
+ }
457
+ /**
458
+ * 保证"配置里的自定义供应商"这一格已经进到注册表快照 (读盘失败不炸: 顶多更保守一点)。
459
+ * 幂等: `initialize()` 自己有文件签名缓存, 不会每次都真读盘。
460
+ */
461
+ async function ensureConfigSnapshot() {
462
+ try {
463
+ await config_store_js_1.llmConfigStore.initialize();
464
+ }
465
+ catch { /* 读不到配置 → 后面的判断按"没有这一家"走, 不编 */ }
466
+ }
467
+ /**
468
+ * 解析"去哪问、用什么认证"。
469
+ *
470
+ * base URL 四层优先级与切换链路**同一张表** (`resolveChainBaseUrl`): 显式 > 配置 > 供应商默认 > 环境变量。
471
+ * 不在册的供应商也能拿到一个 target (`entry: null` + `canDiscover: false`), 由上层标 `unavailable`,
472
+ * 而不是"看不见这一家"。
473
+ */
474
+ async function resolveDiscoveryTarget(providerId, opts = {}) {
475
+ ensureDiscoveryMetadataSource();
476
+ // 2026-09-26 (P6): 先把"配置里的自定义供应商"推进注册表快照, 再查这一家是谁 ——
477
+ // 否则全新进程里 `getProviderRegistryEntry('某自定义 id')` 会是 undefined, 这一家被当成
478
+ // "不在册", 于是拿着 0 个模型、报"未配置凭据 / 发现失败原因未明"。内置供应商此前是靠
479
+ // resolveCredential 里那句 initialize() 顺手救回来的, 所以只有自定义会踩。
480
+ await ensureConfigSnapshot();
481
+ const id = String(providerId || '').trim();
482
+ const entry = (0, provider_registry_js_1.getProviderRegistryEntry)(id) || null;
483
+ const notes = [];
484
+ if (!entry) {
485
+ return {
486
+ provider: id,
487
+ entry: null,
488
+ baseUrl: '',
489
+ baseUrlSource: null,
490
+ baseUrlRaw: '',
491
+ endpoint: '',
492
+ requiresApiKey: true,
493
+ credentialReady: false,
494
+ credentialSource: 'none',
495
+ credentialIdentity: exports.ANONYMOUS_IDENTITY,
496
+ cacheKey: discoveryCacheKey(id, '', exports.ANONYMOUS_IDENTITY),
497
+ canDiscover: false,
498
+ skipReason: `不在册的供应商: ${id} (注册表里没有这条) → 保留在列表里并标 unavailable, 但没法问它有哪些模型`,
499
+ notes,
500
+ };
501
+ }
502
+ let configured = '';
503
+ if (entry.kind === 'custom') {
504
+ configured = (0, provider_registry_js_1.customProviderSpecOf)(id)?.baseUrl || '';
505
+ }
506
+ else {
507
+ await config_store_js_1.llmConfigStore.initialize();
508
+ const cfg = await config_store_js_1.llmConfigStore.getConfig();
509
+ configured = String(cfg?.providers?.[id]?.baseUrl || '');
510
+ }
511
+ const resolved = (0, connection_probe_js_1.resolveChainBaseUrl)({
512
+ explicit: opts.baseUrl,
513
+ configured,
514
+ providerDefault: entry.defaultBaseUrl,
515
+ envVar: entry.baseUrlEnvVars[0],
516
+ }, process.env);
517
+ const baseUrl = resolved.baseUrl;
518
+ const cred = await resolveCredential(id, entry, opts);
519
+ const credentialIdentity = credentialIdentityOf(cred.apiKey);
520
+ const credentialReady = !!cred.apiKey || entry.requiresApiKey === false;
521
+ const declaredEndpoint = entry.kind === 'custom' ? ((0, provider_registry_js_1.customProviderSpecOf)(id)?.modelsEndpoint || '') : '';
522
+ const endpoint = baseUrl ? (0, provider_registry_js_1.modelsEndpointOf)({ ...entry, defaultBaseUrl: baseUrl }, declaredEndpoint) : '';
523
+ const target = {
524
+ provider: id,
525
+ entry,
526
+ baseUrl,
527
+ baseUrlSource: resolved.source,
528
+ baseUrlRaw: resolved.raw,
529
+ endpoint,
530
+ requiresApiKey: entry.requiresApiKey,
531
+ credentialReady,
532
+ credentialSource: cred.source,
533
+ credentialIdentity,
534
+ cacheKey: discoveryCacheKey(id, baseUrl, credentialIdentity),
535
+ canDiscover: false,
536
+ notes,
537
+ };
538
+ if (cred.apiKey)
539
+ target.apiKey = cred.apiKey;
540
+ // ★ 2026-09-27 (目录驱动): 目录里需要**专用鉴权**的家 (云签名/OAuth/多变量云凭证) ——
541
+ // 本运行时不会说那家的鉴权语言, 于是**根本没发请求**。如实报出来, 不许假装做了一次发现。
542
+ // 放在 baseUrl/endpoint 检查之前: 换地址也救不了这一条。
543
+ if (entry.authSupported === false) {
544
+ // `authSupportNote` 自带"需专用鉴权 (未支持): <为什么>"的口径, 这里不再叠前缀
545
+ target.skipReason = `这家不能发现: ${entry.authSupportNote || '需专用鉴权 (本版本未支持), 形状未知'}`
546
+ + ` → 本运行时不发它的请求 (它仍然出现在列表里, 并如实标注不能用)`;
547
+ return target;
548
+ }
549
+ if (!baseUrl) {
550
+ target.skipReason = entry.origin === 'catalog'
551
+ ? '目录里这一家没有 api 基址 (公开目录没给) 且你也没给 → 需自定义 baseUrl (--base-url 或写进配置)'
552
+ : '没有任何一层给出 base URL (显式/配置/供应商默认/环境变量都空) → 没有发现可做';
553
+ return target;
554
+ }
555
+ if (!endpoint) {
556
+ target.skipReason = `这家没有可用的模型目录端点 (发现方式=${entry.discovery}) → 只能手工填 model ID`;
557
+ return target;
558
+ }
559
+ if (!credentialReady) {
560
+ target.skipReason = `未配置凭据 → 本轮没有做发现 (注册表说这家需要 API key${entry.apiKeyEnvVars.length ? `: ${entry.apiKeyEnvVars.join('/')}` : ''})`;
561
+ return target;
562
+ }
563
+ target.canDiscover = true;
564
+ return target;
565
+ }
566
+ /**
567
+ * 合并清单。顺序 = **优先级降序**: `live` → `cached` → `custom` → `catalog` → `curated`。
568
+ * 同一模型出现多次时按**更高优先级的来源**记 (但首次出现的位置保留 —— 列表顺序稳定)。
569
+ */
570
+ function mergeModels(layers) {
571
+ const origins = {};
572
+ const rank = { live: 5, cached: 4, custom: 3, catalog: 2, curated: 1, unavailable: 0 };
573
+ const order = [];
574
+ for (const layer of layers) {
575
+ for (const raw of layer.ids) {
576
+ const id = String(raw || '').trim();
577
+ if (!id)
578
+ continue;
579
+ if (!(id in origins)) {
580
+ origins[id] = layer.origin;
581
+ order.push(id);
582
+ continue;
583
+ }
584
+ if ((rank[layer.origin] ?? 0) > (rank[origins[id]] ?? 0))
585
+ origins[id] = layer.origin;
586
+ }
587
+ }
588
+ return { models: order, origins };
589
+ }
590
+ // ============================================================
591
+ // 进程内快照 (元数据填充点必须是纯同步的)
592
+ // ============================================================
593
+ const discoverySnapshot = new Map();
594
+ /** 最近一次发现的结论 (同步; 填充点与展示用) */
595
+ function currentDiscoveryCatalog(provider) {
596
+ return discoverySnapshot.get(String(provider || '').trim());
597
+ }
598
+ /** 当前快照里的全部 provider */
599
+ function currentDiscoveryProviders() {
600
+ return [...discoverySnapshot.keys()];
601
+ }
602
+ /** 推一批结论进快照 (本层自己调; 也可由调用方在别处拿到结果后推进来) */
603
+ function setDiscoverySnapshot(catalogs) {
604
+ for (const c of catalogs) {
605
+ if (!c || !c.provider)
606
+ continue;
607
+ discoverySnapshot.set(c.provider, c);
608
+ }
609
+ installLiveFactsProbe();
610
+ }
611
+ /**
612
+ * 把"真目录 (live/cached) 覆盖了这条模型"这件事告诉目录层 —— **幂等**, 接一次。
613
+ *
614
+ * 为什么要有它: 目录层 (223 家的目录快照) 也是元数据填充点之一, 而"先注册的源赢"这条冻结规则
615
+ * 会让目录快照压住真发现到的事实。两者**都**是字面声明, 但真目录更新 (它来自供应商自己的 API),
616
+ * 所以这里显式让位: 真目录答过的字段, 目录快照不再答。
617
+ */
618
+ function installLiveFactsProbe() {
619
+ if (liveProbeInstalled)
620
+ return;
621
+ liveProbeInstalled = true;
622
+ (0, provider_catalog_js_1.setLiveFactsProbe)((provider, model) => {
623
+ const cat = discoverySnapshot.get(String(provider || '').trim());
624
+ if (!cat || (cat.origin !== 'live' && cat.origin !== 'cached'))
625
+ return undefined;
626
+ const f = cat.facts?.[model];
627
+ if (!f)
628
+ return undefined;
629
+ return { toolCalling: f.toolCalling, reasoning: f.reasoning, contextLength: f.contextLength };
630
+ });
631
+ }
632
+ let liveProbeInstalled = false;
633
+ /** 清空快照 (测试用; 生产路径不调用) */
634
+ function resetDiscoverySnapshot() {
635
+ discoverySnapshot.clear();
636
+ }
637
+ // ============================================================
638
+ // 发现一次
639
+ // ============================================================
640
+ function trimNotes(notes) {
641
+ const seen = new Set();
642
+ const out = [];
643
+ for (const n of notes) {
644
+ const v = String(n || '').trim();
645
+ if (!v || seen.has(v))
646
+ continue;
647
+ seen.add(v);
648
+ out.push(v);
649
+ }
650
+ return out;
651
+ }
652
+ /** 把缓存条目降级成"回退清单" (只有 `lastSuccessAt` 非空才算"上次成功") */
653
+ function fallbackFromCache(entry) {
654
+ if (!entry || !entry.lastSuccessAt)
655
+ return { ids: [], facts: {}, ok: false };
656
+ return { ids: entry.models || [], facts: entry.facts || {}, ok: (entry.models || []).length > 0 };
657
+ }
658
+ /**
659
+ * 发现一家的模型目录。
660
+ *
661
+ * 决策顺序 (逐条都有门):
662
+ * 0. 新鲜缓存命中 (未 `force`) → `cached` —— **一个网络请求都不打**;
663
+ * 1. 不能问 (没端点/没凭据/不在册) → `curated`/`custom`/`unavailable` + "为什么没问";
664
+ * 2. 真问 → 2xx 且 ≥1 个模型 → `live` 并**写缓存**;
665
+ * 3. 问失败 (401/403/5xx/超时/空目录/形状不对) → **保留 provider**, 标 `discoveryState='unavailable'` +
666
+ * 原因; 清单退回**上次成功缓存** (`cached`) → 手输/声明 (`custom`) → 内置目录 (`curated`);
667
+ * 回退链全空 → `origin='unavailable'`。
668
+ */
669
+ async function discoverProviderModels(providerId, opts = {}) {
670
+ const now = opts.now ?? (() => Date.now());
671
+ const timeoutMs = Math.max(1, opts.timeoutMs ?? exports.DEFAULT_DISCOVERY_TIMEOUT_MS);
672
+ const ttlMs = Math.max(0, opts.ttlMs ?? exports.DEFAULT_DISCOVERY_TTL_MS);
673
+ const fetchImpl = opts.fetchImpl ?? fetch;
674
+ const target = await resolveDiscoveryTarget(providerId, opts);
675
+ const nowMs = now();
676
+ const nowIso = new Date(nowMs).toISOString();
677
+ const notes = [...target.notes];
678
+ const cacheFile = await readDiscoveryCache();
679
+ for (const w of cacheFile.warnings || [])
680
+ notes.push(w);
681
+ const cached = cacheFile.entries[target.cacheKey];
682
+ const declaredModels = target.entry
683
+ ? (target.entry.kind === 'custom' ? target.entry.declaredModelIds : [])
684
+ : [];
685
+ const manualModels = trimNotes([...(opts.manualModels || []), ...((cached?.manualModels) || [])]);
686
+ const curated = target.entry && target.entry.kind === 'builtin' ? (0, model_catalog_js_1.curatedModelIds)(target.provider) : [];
687
+ // 目录快照里这一家声明的模型 (目录驱动: 新家不用改代码就有清单可用)。
688
+ // 它和内置 `curated` 是同一层意思 (**这家真有的模型**), 但来源是公开目录, 所以 origin 记 'catalog'。
689
+ const catalogModels = target.entry && target.entry.origin === 'catalog' ? target.entry.declaredModelIds : [];
690
+ const base = {
691
+ provider: target.provider,
692
+ baseUrl: target.baseUrl,
693
+ baseUrlSource: target.baseUrlSource,
694
+ endpoint: target.endpoint,
695
+ requiresApiKey: target.entry ? target.entry.requiresApiKey : true,
696
+ credentialIdentity: target.credentialIdentity,
697
+ credentialSource: target.credentialSource,
698
+ credentialReady: target.credentialReady,
699
+ cacheKey: target.cacheKey,
700
+ manualModels,
701
+ declaredModels,
702
+ fetchedAt: cached?.fetchedAt ?? null,
703
+ expiresAt: cached?.expiresAt ?? null,
704
+ // 目录家需要专用鉴权时, 这一条一直带着 (展示层据此如实标"未支持")
705
+ ...(target.entry?.authSupported === false
706
+ ? { authUnsupported: String(target.entry.authSupportNote || '需专用鉴权 (形状未知)') }
707
+ : {}),
708
+ notes: [],
709
+ };
710
+ const finish = (parts) => {
711
+ const merged = mergeModels(parts.layers);
712
+ const failureReason = parts.failure ? redactSecretIn(parts.failure.reason, target.apiKey) : undefined;
713
+ const cat = {
714
+ ...base,
715
+ origin: parts.origin,
716
+ discoveryState: parts.discoveryState,
717
+ models: merged.models,
718
+ modelOrigins: merged.origins,
719
+ facts: parts.facts || {},
720
+ fromCache: parts.fromCache,
721
+ stale: parts.stale,
722
+ discoveryFailed: parts.discoveryState === 'unavailable',
723
+ keptDespiteFailure: parts.discoveryState === 'unavailable',
724
+ // 理由 / 备注也要过脱敏: 上游可能在错误体里把收到的凭据回显出来
725
+ notes: trimNotes(notes).map((n) => redactSecretIn(n, target.apiKey)),
726
+ ...(parts.failure ? { failure: { ...parts.failure, reason: failureReason || parts.failure.reason } } : {}),
727
+ ...(failureReason ? { failureReason } : {}),
728
+ };
729
+ discoverySnapshot.set(cat.provider, cat);
730
+ installLiveFactsProbe();
731
+ return cat;
732
+ };
733
+ /**
734
+ * 回退链的两层 (真目录之外的两层; 空层会被 mergeModels 忽略)。
735
+ *
736
+ * **有真目录 (live/cached) 时不再混入内置目录** —— 内置目录只有 ID, 混进去会让清单里出现
737
+ * "这台端点上根本不存在的模型"。内置目录只在**没有真目录**时兜底 (`curated`)。
738
+ * 用户显式声明/手输的模型 (`custom`) 则**总是**并入 (那是用户自己说的, 可撤)。
739
+ */
740
+ const fallbackLayers = () => [
741
+ { origin: 'custom', ids: [...manualModels, ...declaredModels] },
742
+ { origin: 'catalog', ids: catalogModels },
743
+ { origin: 'curated', ids: curated },
744
+ ];
745
+ /** 真目录已有 → 只再并入"用户说的那一层" (不混内置目录) */
746
+ const customLayers = () => [
747
+ { origin: 'custom', ids: [...manualModels, ...declaredModels] },
748
+ ];
749
+ // 1) 新鲜缓存命中
750
+ const fresh = !opts.force && !!cached?.lastSuccessAt && !!cached.expiresAt
751
+ && Date.parse(cached.expiresAt) > nowMs && (cached.models || []).length > 0;
752
+ if (fresh) {
753
+ notes.push(`缓存命中 (有效期到 ${cached.expiresAt}) → 本轮没有发发现请求`);
754
+ notes.push(`供应商已认证 (凭据来源=${target.credentialSource}), 想强制刷新就 refresh`);
755
+ return finish({
756
+ origin: 'cached',
757
+ discoveryState: 'cached',
758
+ layers: [{ origin: 'cached', ids: cached.models }, ...customLayers()],
759
+ facts: cached.facts,
760
+ fromCache: true,
761
+ stale: false,
762
+ });
763
+ }
764
+ // 2) 没法问 (先说清为什么)
765
+ if (!target.canDiscover) {
766
+ notes.push(String(target.skipReason || '没有可做的发现'));
767
+ const cachedOk = fallbackFromCache(cached);
768
+ if (cachedOk.ok) {
769
+ notes.push('有上次成功缓存 → 用它 (虽然本轮没有做发现)');
770
+ return finish({
771
+ origin: 'cached',
772
+ discoveryState: 'cached',
773
+ layers: [{ origin: 'cached', ids: cachedOk.ids }, ...customLayers()],
774
+ facts: cachedOk.facts,
775
+ fromCache: true,
776
+ stale: false,
777
+ });
778
+ }
779
+ const hasCustom = manualModels.length > 0 || declaredModels.length > 0;
780
+ const origin = hasCustom ? 'custom' : (curated.length ? 'curated' : 'unavailable');
781
+ return finish({
782
+ origin,
783
+ discoveryState: origin,
784
+ layers: fallbackLayers(),
785
+ fromCache: false,
786
+ stale: false,
787
+ });
788
+ }
789
+ // 3) 真问
790
+ const cred = target.entry ? (0, provider_registry_js_1.authHeadersFor)(target.entry, target.apiKey) : { headers: {}, query: {} };
791
+ let url = target.endpoint;
792
+ if (Object.keys(cred.query).length) {
793
+ const sep = url.includes('?') ? '&' : '?';
794
+ url = `${url}${sep}${new URLSearchParams(cred.query).toString()}`;
795
+ }
796
+ const flag = { timedOut: false };
797
+ let res = null;
798
+ let failure = null;
799
+ let parsed = null;
800
+ try {
801
+ res = await (0, connection_probe_js_1.fetchWithProbeTimeout)(url, { method: 'GET', headers: cred.headers }, timeoutMs, fetchImpl, flag);
802
+ }
803
+ catch (e) {
804
+ const c = (0, connection_probe_js_1.classifyNetworkError)(e, flag, timeoutMs);
805
+ failure = { failureClass: c.failureClass, reason: c.detail, at: nowIso };
806
+ }
807
+ if (res) {
808
+ let body = null;
809
+ try {
810
+ const text = await res.text();
811
+ body = text ? JSON.parse(text) : null;
812
+ }
813
+ catch {
814
+ body = null;
815
+ }
816
+ const statusFail = (0, connection_probe_js_1.classifyStatus)(res, body, url, 'catalog');
817
+ if (statusFail) {
818
+ failure = { failureClass: statusFail.failureClass, reason: statusFail.detail, at: nowIso };
819
+ }
820
+ else {
821
+ parsed = parseCatalogBody(body, target.entry?.protocol || 'openai-compatible');
822
+ if (parsed.kind === 'shape_error') {
823
+ failure = {
824
+ failureClass: 'protocol_mismatch',
825
+ reason: `端点回了 200 但不是模型目录形状 — ${parsed.detail} (${(0, connection_probe_js_1.snippetOf)(body, res)})`,
826
+ at: nowIso,
827
+ };
828
+ }
829
+ else if (parsed.ids.length === 0) {
830
+ failure = {
831
+ failureClass: 'model_not_found',
832
+ reason: '目录端点返回了 0 个模型 (HTTP 200) — **不当作空目录**, 也不覆盖上次成功缓存',
833
+ at: nowIso,
834
+ };
835
+ }
836
+ }
837
+ }
838
+ // 4) 成功: 写缓存 + 快照
839
+ if (parsed && parsed.kind === 'ok' && parsed.ids.length > 0) {
840
+ const expiresAt = new Date(nowMs + ttlMs).toISOString();
841
+ const entry = {
842
+ provider: target.provider,
843
+ baseUrl: target.baseUrl,
844
+ credentialIdentity: target.credentialIdentity,
845
+ // **不带认证 query**: gemini/query-key 那种把凭据放在 URL 上的协议,
846
+ // 存进来的必须是没有凭据的那一份端点 (凭据一个字节都不落盘)
847
+ endpoint: target.endpoint,
848
+ models: parsed.ids,
849
+ facts: parsed.facts,
850
+ manualModels,
851
+ lastSuccessAt: nowIso,
852
+ fetchedAt: nowIso,
853
+ expiresAt,
854
+ };
855
+ const next = { version: exports.DISCOVERY_CACHE_VERSION, entries: { ...cacheFile.entries, [target.cacheKey]: entry } };
856
+ await writeDiscoveryCache(next);
857
+ notes.push(`真取到 ${parsed.ids.length} 个模型 → 已写缓存 (有效期到 ${expiresAt}, 键 = provider+baseUrl+凭证指纹)`);
858
+ return finish({
859
+ origin: 'live',
860
+ discoveryState: 'live',
861
+ layers: [
862
+ { origin: 'live', ids: parsed.ids },
863
+ ...customLayers(),
864
+ ],
865
+ facts: parsed.facts,
866
+ fromCache: false,
867
+ stale: false,
868
+ });
869
+ }
870
+ // 5) 失败: 保留 provider + 标 unavailable + 原因, 清单走回退链
871
+ const fail = failure || { failureClass: 'provider_unreachable', reason: '发现失败但没拿到原因 (内部错误)', at: nowIso };
872
+ notes.push(`发现失败 (${fail.failureClass}): ${fail.reason}`);
873
+ notes.push('provider **没有被删除** —— 保留在列表里并标 unavailable, 原因如上');
874
+ const cachedOk = fallbackFromCache(cached);
875
+ const useManualLayers = fallbackLayers();
876
+ if (cachedOk.ok) {
877
+ notes.push(`回退到上次成功缓存 (${cachedOk.ids.length} 个模型, 取自 ${cached.lastSuccessAt})`);
878
+ return finish({
879
+ origin: 'cached',
880
+ discoveryState: 'unavailable',
881
+ layers: [{ origin: 'cached', ids: cachedOk.ids }, ...customLayers()],
882
+ facts: cachedOk.facts,
883
+ fromCache: true,
884
+ stale: !(cached.expiresAt && Date.parse(cached.expiresAt) > nowMs),
885
+ failure: fail,
886
+ });
887
+ }
888
+ const hasCustomFallback = useManualLayers.some((l) => l.ids.length > 0 && l.origin === 'custom');
889
+ const hasFallback = useManualLayers.some((l) => l.ids.length > 0);
890
+ if (hasFallback) {
891
+ notes.push('没有上次成功缓存 → 回退到' + (hasCustomFallback ? '用户声明/手输的模型' : '内置目录'));
892
+ }
893
+ else {
894
+ notes.push('没有上次成功缓存, 也没有内置目录/手输模型 → 这一家只保留名字, 清单为空 (不编)');
895
+ }
896
+ return finish({
897
+ origin: hasCustomFallback ? 'custom' : (curated.length ? 'curated' : 'unavailable'),
898
+ discoveryState: 'unavailable',
899
+ layers: useManualLayers,
900
+ facts: {},
901
+ fromCache: false,
902
+ stale: false,
903
+ failure: fail,
904
+ });
905
+ }
906
+ // ============================================================
907
+ // 命令面能力 (做成可调用函数; 命令面接线不归本层)
908
+ // ============================================================
909
+ /**
910
+ * 在册的 provider id 清单 (顺序 = 注册表自己的展示顺序: 优先清单 → 其余内置 → 自定义)。
911
+ *
912
+ * **不在本层另排一份**: 直接读 `provider-registry` 的那张表, 于是"注册表加了新家、列表里看不到"
913
+ * 这种两处真相不会发生。
914
+ */
915
+ function registryProviderIds() {
916
+ const ids = [];
917
+ const seen = new Set();
918
+ for (const entry of (0, provider_registry_js_1.listProviderRegistry)()) {
919
+ if (!entry?.id || seen.has(entry.id))
920
+ continue;
921
+ seen.add(entry.id);
922
+ ids.push(entry.id);
923
+ }
924
+ // 注册表快照是同步读口; 快照还没刷过时补上盘上的自定义供应商 (仍然一家不删)
925
+ for (const spec of Object.values((0, provider_registry_js_1.customProviderSnapshot)())) {
926
+ const id = String(spec?.providerId || '');
927
+ if (id && !seen.has(id)) {
928
+ seen.add(id);
929
+ ids.push(id);
930
+ }
931
+ }
932
+ return ids;
933
+ }
934
+ /** 一家一行的目录结论 (**不发请求**, 只读: 进程内快照 → 盘上缓存) */
935
+ async function cachedUpstreamModels(provider, opts = {}) {
936
+ const id = String(provider || '').trim();
937
+ if (!id)
938
+ return undefined;
939
+ const snap = currentDiscoveryCatalog(id);
940
+ if (snap && snap.models.length) {
941
+ return { models: [...snap.models], origin: snap.origin === 'live' ? 'live' : 'cached' };
942
+ }
943
+ const file = await readDiscoveryCache();
944
+ const want = String(opts.baseUrl || '').replace(/\/+$/, '');
945
+ const hit = Object.values(file.entries || {}).find((e) => e && e.provider === id
946
+ && Array.isArray(e.models) && e.models.length
947
+ && (!want || String(e.baseUrl || '').replace(/\/+$/, '') === want));
948
+ if (!hit)
949
+ return undefined;
950
+ return { models: [...hit.models], origin: 'cached' };
951
+ }
952
+ /**
953
+ * 全部在册供应商的目录结论 (**一家都不删**)。
954
+ *
955
+ * `providerId` 给了就只看这一家; 没给就看全册 (命令面 `/model list`)。
956
+ */
957
+ async function listModelCatalog(providerId, opts = {}) {
958
+ const single = String(providerId || '').trim();
959
+ const ids = single ? [single] : registryProviderIds();
960
+ const entries = await Promise.all(ids.map((id) => discoverProviderModels(id, opts).catch((e) => {
961
+ // 连发现本身崩了也不许把这家删掉: 保留 + 标 unavailable + 原因
962
+ return crashCatalog(id, String(e?.message ?? e));
963
+ })));
964
+ const unavailable = entries
965
+ .filter((c) => c.discoveryState === 'unavailable')
966
+ .map((c) => ({
967
+ provider: c.provider,
968
+ ...(c.failure ? { failureClass: c.failure.failureClass } : {}),
969
+ reason: c.failureReason || c.notes[c.notes.length - 1] || '发现失败',
970
+ }));
971
+ const notes = [];
972
+ if (!single) {
973
+ const absent = registryProviderIds().filter((id) => !entries.some((e) => e.provider === id));
974
+ if (absent.length)
975
+ notes.push(`内部错误: ${absent.join(', ')} 没出现在结果里 (门会判红)`);
976
+ }
977
+ return {
978
+ generatedAt: new Date((opts.now ?? Date.now)()).toISOString(),
979
+ cachePath: discoveryCachePath(),
980
+ entries,
981
+ unavailable,
982
+ notes,
983
+ };
984
+ }
985
+ /** 发现过程中自己崩了 → 也要有一条"保留 + 原因"的记录 */
986
+ function crashCatalog(provider, reason) {
987
+ const cat = {
988
+ provider,
989
+ baseUrl: '',
990
+ baseUrlSource: null,
991
+ endpoint: '',
992
+ requiresApiKey: true,
993
+ credentialIdentity: exports.ANONYMOUS_IDENTITY,
994
+ credentialSource: 'none',
995
+ credentialReady: false,
996
+ cacheKey: discoveryCacheKey(provider, '', exports.ANONYMOUS_IDENTITY),
997
+ origin: 'unavailable',
998
+ discoveryState: 'unavailable',
999
+ models: [],
1000
+ modelOrigins: {},
1001
+ facts: {},
1002
+ manualModels: [],
1003
+ declaredModels: [],
1004
+ fetchedAt: null,
1005
+ expiresAt: null,
1006
+ fromCache: false,
1007
+ stale: false,
1008
+ discoveryFailed: true,
1009
+ failure: { failureClass: 'provider_unreachable', reason: `发现过程中出错: ${reason}`, at: new Date().toISOString() },
1010
+ failureReason: `发现过程中出错: ${reason}`,
1011
+ keptDespiteFailure: true,
1012
+ notes: ['发现过程中抛错 → provider 仍保留 (不静默删)'],
1013
+ };
1014
+ discoverySnapshot.set(provider, cat);
1015
+ return cat;
1016
+ }
1017
+ /**
1018
+ * `/model refresh` 的能力: **强制**真取一次 (忽略新鲜缓存), 返回逐家结论 + 失误清单。
1019
+ *
1020
+ * `providerIds` 省略 = 全册。
1021
+ */
1022
+ async function refreshModelDiscovery(providerIds, opts = {}) {
1023
+ const ids = providerIds === undefined
1024
+ ? registryProviderIds()
1025
+ : (Array.isArray(providerIds) ? providerIds : [providerIds]).map((s) => String(s || '').trim()).filter(Boolean);
1026
+ const results = await Promise.all(ids.map((id) => discoverProviderModels(id, { ...opts, force: opts.force !== false })
1027
+ .catch((e) => crashCatalog(id, String(e?.message ?? e)))));
1028
+ const counts = { live: 0, cached: 0, curated: 0, custom: 0, catalog: 0, unavailable: 0 };
1029
+ for (const c of results)
1030
+ counts[c.discoveryState] = (counts[c.discoveryState] || 0) + 1;
1031
+ const failures = results
1032
+ .filter((c) => c.discoveryFailed && c.failure)
1033
+ .map((c) => ({ provider: c.provider, failureClass: c.failure.failureClass, reason: c.failureReason || c.failure.reason }));
1034
+ const notes = [];
1035
+ if (counts.unavailable)
1036
+ notes.push(`${counts.unavailable} 家本轮发现失败 —— 全部保留在列表里并带原因, 一家都没删`);
1037
+ return {
1038
+ refreshedAt: new Date((opts.now ?? Date.now)()).toISOString(),
1039
+ force: true,
1040
+ cachePath: discoveryCachePath(),
1041
+ results,
1042
+ counts,
1043
+ failures,
1044
+ notes,
1045
+ };
1046
+ }
1047
+ /**
1048
+ * 手工输入一个自定义模型 ID (自定义模型允许手动输入)。
1049
+ *
1050
+ * 落在**该 provider + baseUrl + 凭证身份**那条缓存记录的 `manualModels` 上:
1051
+ * 于是它在断网、端点没目录、凭证被拒时**依然在清单里**, 标记 `custom`。
1052
+ */
1053
+ async function admitManualModel(providerId, modelId, opts = {}) {
1054
+ const id = String(providerId || '').trim();
1055
+ const model = String(modelId || '').trim();
1056
+ if (!model)
1057
+ return { ok: false, reason: '模型 ID 为空' };
1058
+ if (model.length > MANUAL_MODEL_MAX_LEN)
1059
+ return { ok: false, reason: `模型 ID 太长 (${model.length} > ${MANUAL_MODEL_MAX_LEN})` };
1060
+ if (/\s/.test(model))
1061
+ return { ok: false, reason: '模型 ID 里不许有空白字符' };
1062
+ if (/[\u0000-\u001f]/.test(model))
1063
+ return { ok: false, reason: '模型 ID 里有控制字符' };
1064
+ const target = await resolveDiscoveryTarget(id, opts);
1065
+ const file = await readDiscoveryCache();
1066
+ const prev = file.entries[target.cacheKey];
1067
+ const manualModels = trimNotes([...(prev?.manualModels || []), model]);
1068
+ const entry = {
1069
+ provider: target.provider,
1070
+ baseUrl: target.baseUrl,
1071
+ credentialIdentity: target.credentialIdentity,
1072
+ endpoint: target.endpoint,
1073
+ models: prev?.models || [],
1074
+ facts: prev?.facts || {},
1075
+ manualModels,
1076
+ lastSuccessAt: prev?.lastSuccessAt ?? null,
1077
+ fetchedAt: prev?.fetchedAt ?? null,
1078
+ expiresAt: prev?.expiresAt ?? null,
1079
+ };
1080
+ await writeDiscoveryCache({ version: exports.DISCOVERY_CACHE_VERSION, entries: { ...file.entries, [target.cacheKey]: entry } });
1081
+ const catalog = await discoverProviderModels(id, { ...opts, manualModels });
1082
+ return { ok: true, catalog };
1083
+ }
1084
+ /** 撤掉一个手工输入的模型 ID (`true` = 真有这一条被撤掉) */
1085
+ async function forgetManualModel(providerId, modelId, opts = {}) {
1086
+ const id = String(providerId || '').trim();
1087
+ const model = String(modelId || '').trim();
1088
+ const target = await resolveDiscoveryTarget(id, opts);
1089
+ const file = await readDiscoveryCache();
1090
+ const prev = file.entries[target.cacheKey];
1091
+ if (!prev)
1092
+ return false;
1093
+ const manualModels = (prev.manualModels || []).filter((m) => m !== model);
1094
+ if (manualModels.length === (prev.manualModels || []).length)
1095
+ return false;
1096
+ await writeDiscoveryCache({
1097
+ version: exports.DISCOVERY_CACHE_VERSION,
1098
+ entries: { ...file.entries, [target.cacheKey]: { ...prev, manualModels } },
1099
+ });
1100
+ return true;
1101
+ }
1102
+ // ============================================================
1103
+ // 展示 (凭据**不进**这里)
1104
+ // ============================================================
1105
+ /** 来源标记 → 中文 (渲染层只翻译, 不产生值) */
1106
+ function catalogOriginZh(o) {
1107
+ switch (o) {
1108
+ case 'live': return '实时目录';
1109
+ case 'cached': return '上次成功缓存';
1110
+ case 'curated': return '内置目录';
1111
+ case 'custom': return '用户声明/手输';
1112
+ case 'catalog': return '目录快照';
1113
+ case 'unavailable': return '发现不可用';
1114
+ default: return String(o);
1115
+ }
1116
+ }
1117
+ /** 一家一行 (命令面 `/model list` 直接用) */
1118
+ function formatCatalogLine(c) {
1119
+ const bits = [
1120
+ `${catalogOriginZh(c.origin)}(${c.origin})`,
1121
+ `${c.models.length} models`,
1122
+ ];
1123
+ if (c.fromCache)
1124
+ bits.push(c.stale ? '缓存已过期' : '缓存命中');
1125
+ const cred = c.credentialIdentity === exports.ANONYMOUS_IDENTITY
1126
+ ? (c.requiresApiKey ? '未配置凭据' : '免 key')
1127
+ : `凭据指纹 ${c.credentialIdentity}`;
1128
+ bits.push(cred);
1129
+ // 目录家需要专用鉴权 → **如实标"未支持"** (这一行最容易变得含糊: 必须一眼看出"没发请求")
1130
+ if (c.authUnsupported)
1131
+ bits.push(`⚠ ${String(c.authUnsupported).slice(0, 90)}`);
1132
+ if (c.discoveryFailed)
1133
+ bits.push(`⚠ 本轮发现失败: ${String(c.failureReason || '原因未明').slice(0, 100)}`);
1134
+ return `· ${c.provider} · ${bits.join(' · ')}`;
1135
+ }
1136
+ /** 整个列表的摘要行 (命令面直接打) */
1137
+ function formatListingSummary(l) {
1138
+ const out = [`模型目录 · ${l.entries.length} 家 (含本轮发现失败的, 一家都没删) · 缓存: ${l.cachePath}`];
1139
+ for (const u of l.unavailable)
1140
+ out.push(` ⚠ ${u.provider} · unavailable${u.failureClass ? `(${u.failureClass})` : ''}: ${String(u.reason).slice(0, 120)}`);
1141
+ for (const n of l.notes)
1142
+ out.push(` ${n}`);
1143
+ return out;
1144
+ }
1145
+ // ============================================================
1146
+ // 元数据填充点 (P2 冻结接口; 只填自己有真值的项)
1147
+ // ============================================================
1148
+ exports.DISCOVERY_SOURCE_ID = 'live-discovery';
1149
+ /**
1150
+ * 目录缓存/发现结果的元数据填充点。
1151
+ *
1152
+ * - `origin`: 只在这个模型**真在**本层某一份清单里时填 (逐条来源);
1153
+ * - 能力字段: **只填响应体里字面读到的** (见 `FACT_KEYS`), 其余不出现 → 保持 `unknown`;
1154
+ * - 纯同步: 只读进程内快照 (`currentDiscoveryCatalog`), 不发请求、不读盘。
1155
+ */
1156
+ function modelDiscoveryMetadataSource() {
1157
+ return {
1158
+ id: exports.DISCOVERY_SOURCE_ID,
1159
+ metadataOf: ({ provider, model }) => {
1160
+ const cat = discoverySnapshot.get(String(provider || ''));
1161
+ if (!cat)
1162
+ return undefined;
1163
+ const id = String(model || '');
1164
+ const facts = cat.facts[id] || {};
1165
+ const out = {};
1166
+ const origin = cat.modelOrigins[id];
1167
+ // 只填**本层真有结论**的来源: `curated` 是 P2 自己的默认口径 (`curatedOriginOf`),
1168
+ // 本层不认领它 —— 否则"没跑过发现"的 provider 也会被填成一个非本层的答案。
1169
+ if (origin && origin !== 'curated')
1170
+ out.origin = origin;
1171
+ if (facts.displayName)
1172
+ out.displayName = facts.displayName;
1173
+ if (facts.toolCalling)
1174
+ out.toolCalling = facts.toolCalling;
1175
+ if (facts.reasoning)
1176
+ out.reasoning = facts.reasoning;
1177
+ if (typeof facts.contextLength === 'number')
1178
+ out.contextLength = facts.contextLength;
1179
+ return Object.keys(out).length ? out : undefined;
1180
+ },
1181
+ };
1182
+ }
1183
+ let discoverySourceWired = false;
1184
+ /** 显式接线 (测试里 `resetModelMetadataSources()` 之后靠它接回去) */
1185
+ function registerDiscoveryMetadataSource() {
1186
+ (0, model_catalog_js_1.registerModelMetadataSource)(modelDiscoveryMetadataSource());
1187
+ discoverySourceWired = true;
1188
+ }
1189
+ /**
1190
+ * 调用时自动接线 (幂等, 只做一次) —— 与 P3 的填充点同一套做法:
1191
+ * 不放在模块体里 (避免 import 顺序造成的 TDZ), 但**任何一次**本层公开调用都会接上。
1192
+ */
1193
+ function ensureDiscoveryMetadataSource() {
1194
+ if (discoverySourceWired)
1195
+ return;
1196
+ registerDiscoveryMetadataSource();
1197
+ }
1198
+ /** 本层的填充点现在有没有接上 (排障/门用) */
1199
+ function isDiscoveryMetadataSourceWired() {
1200
+ return (0, model_catalog_js_1.listModelMetadataSources)().includes(exports.DISCOVERY_SOURCE_ID);
1201
+ }
1202
+ //# sourceMappingURL=model-discovery.js.map