@bolloon/bolloon-agent 0.5.0 → 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 (106) hide show
  1. package/dist/agents/execution-supervisor.js +87 -5
  2. package/dist/agents/model-policy.js +651 -0
  3. package/dist/agents/pi-sdk-tools.js +84 -33
  4. package/dist/agents/pi-sdk.js +83 -23
  5. package/dist/agents/run-store.js +53 -0
  6. package/dist/agents/task/task-runner.js +8 -1
  7. package/dist/agents/workflow-pivot-loop.js +19 -12
  8. package/dist/cli/clipboard.js +65 -0
  9. package/dist/cli/ink-app.js +145 -17
  10. package/dist/cli/log-gate.js +386 -0
  11. package/dist/cli/mention-data.js +2 -0
  12. package/dist/cli/model-selector.js +538 -0
  13. package/dist/cli/reply-hygiene.js +54 -0
  14. package/dist/cli/setup-wizard.js +686 -69
  15. package/dist/cli/startup-notice.js +160 -0
  16. package/dist/cli/stores.js +22 -1
  17. package/dist/cli/theme.js +49 -0
  18. package/dist/cli/tui-select.js +970 -0
  19. package/dist/cli-entry.js +125 -18
  20. package/dist/electron-build/agents/execution-supervisor.js +86 -4
  21. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  22. package/dist/electron-build/agents/goal-store.js.map +1 -1
  23. package/dist/electron-build/agents/model-policy.js +706 -0
  24. package/dist/electron-build/agents/model-policy.js.map +1 -0
  25. package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
  26. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  27. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  28. package/dist/electron-build/agents/pi-sdk.js +83 -23
  29. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  30. package/dist/electron-build/agents/run-store.js +55 -1
  31. package/dist/electron-build/agents/run-store.js.map +1 -1
  32. package/dist/electron-build/agents/task/task-runner.js +8 -1
  33. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  34. package/dist/electron-build/agents/workflow-pivot-loop.js +19 -12
  35. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  36. package/dist/electron-build/llm/config-store.js +146 -6
  37. package/dist/electron-build/llm/config-store.js.map +1 -1
  38. package/dist/electron-build/llm/connection-probe.js +764 -0
  39. package/dist/electron-build/llm/connection-probe.js.map +1 -0
  40. package/dist/electron-build/llm/custom-provider-store.js +272 -0
  41. package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
  42. package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
  43. package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
  44. package/dist/electron-build/llm/model-catalog.js +778 -0
  45. package/dist/electron-build/llm/model-catalog.js.map +1 -0
  46. package/dist/electron-build/llm/model-discovery.js +1202 -0
  47. package/dist/electron-build/llm/model-discovery.js.map +1 -0
  48. package/dist/electron-build/llm/model-selection.js +1315 -0
  49. package/dist/electron-build/llm/model-selection.js.map +1 -0
  50. package/dist/electron-build/llm/pi-ai.js +70 -11
  51. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  52. package/dist/electron-build/llm/provider-catalog.js +969 -0
  53. package/dist/electron-build/llm/provider-catalog.js.map +1 -0
  54. package/dist/electron-build/llm/provider-registry.js +950 -0
  55. package/dist/electron-build/llm/provider-registry.js.map +1 -0
  56. package/dist/electron-build/llm/tool-name.js +266 -0
  57. package/dist/electron-build/llm/tool-name.js.map +1 -0
  58. package/dist/electron-build/utils/dual-source-facts.js +236 -0
  59. package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
  60. package/dist/electron-build/utils/dual-source.js +32 -156
  61. package/dist/electron-build/utils/dual-source.js.map +1 -1
  62. package/dist/electron-build/utils/update-manager.js +22 -72
  63. package/dist/electron-build/utils/update-manager.js.map +1 -1
  64. package/dist/electron-build/utils/update-state.js +2 -19
  65. package/dist/electron-build/utils/update-state.js.map +1 -1
  66. package/dist/electron-build/utils/version-identity.js +193 -0
  67. package/dist/electron-build/utils/version-identity.js.map +1 -0
  68. package/dist/electron-build/utils/version-info.js +47 -101
  69. package/dist/electron-build/utils/version-info.js.map +1 -1
  70. package/dist/index.js +282 -96
  71. package/dist/ios/bolloon-web.json +10 -0
  72. package/dist/ios/mobile-contacts.js +44 -11
  73. package/dist/ios/mobile-core.js +1723 -84
  74. package/dist/ios/mobile-tasks.js +339 -0
  75. package/dist/ios/mobile-update.js +1442 -0
  76. package/dist/ios/mobile.js +308 -35
  77. package/dist/ios/routes-llm-config.js +344 -52
  78. package/dist/ios/routes-mobile-tasks.js +353 -0
  79. package/dist/ios/server.js +163 -4
  80. package/dist/ios/sw.js +1 -1
  81. package/dist/llm/config-store.js +146 -6
  82. package/dist/llm/connection-probe.js +744 -0
  83. package/dist/llm/custom-provider-store.js +224 -0
  84. package/dist/llm/data/provider-catalog-baked.js +19 -0
  85. package/dist/llm/model-catalog.js +717 -0
  86. package/dist/llm/model-discovery.js +1137 -0
  87. package/dist/llm/model-selection.js +1241 -0
  88. package/dist/llm/pi-ai.js +70 -11
  89. package/dist/llm/provider-catalog.js +898 -0
  90. package/dist/llm/provider-registry.js +917 -0
  91. package/dist/llm/tool-name.js +220 -0
  92. package/dist/setup/onboard.js +34 -8
  93. package/dist/utils/dual-source-facts.js +223 -0
  94. package/dist/utils/dual-source.js +12 -141
  95. package/dist/utils/update-manager.js +16 -59
  96. package/dist/utils/update-state.js +6 -18
  97. package/dist/utils/version-identity.js +176 -0
  98. package/dist/utils/version-info.js +9 -78
  99. package/dist/web/bolloon-web.json +10 -0
  100. package/dist/web/mobile-core.js +1721 -84
  101. package/dist/web/mobile-update.js +1442 -0
  102. package/dist/web/mobile.js +308 -35
  103. package/dist/web/routes-llm-config.js +344 -52
  104. package/dist/web/sw.js +1 -1
  105. package/package.json +1 -1
  106. package/scripts/build-web.ts +36 -0
@@ -0,0 +1,969 @@
1
+ "use strict";
2
+ /**
3
+ * provider-catalog.ts — 「供应商目录」运行时 (目录驱动, 2026-09-27)
4
+ *
5
+ * ## 这一层解决什么
6
+ *
7
+ * 供应商此前是**一家一家手写**进代码的 (内置 13 家), 加一家要改表、改类型、改白名单。
8
+ * 本层把公开模型目录收成一份**只含公共元数据**的数据, 由运行时读它 —— 于是
9
+ * **新家只要在目录里就自动可用, 不需要改代码**。
10
+ *
11
+ * 数据来自公开源 `https://models.dev/api.json` (由 `scripts/gen-provider-catalog.ts` 取),
12
+ * 有两条来路:
13
+ *
14
+ * ① **构建期烘焙** (`src/llm/data/provider-catalog-baked.ts`, 编译器带进 dist)
15
+ * —— 离线可用, 永远有一份能用的目录;
16
+ * ② **运行期拉最新** (`${BOLLOON_HOME:-~/.bolloon}/provider-catalog.json`, mode 0600)
17
+ * —— 由 `refreshProviderCatalog()` 真取一次公开源后落盘, 记
18
+ * `sourceUrl` / `sourceBytes` / `sourceSha256` / `fetchedAt` / 家数。
19
+ *
20
+ * 两者走**同一个**规范化器 (`normalizeCatalogData`), 运行期文件合法且比烘焙数据新才覆盖它;
21
+ * 文件坏了 → 保留烘焙数据 + 把问题如实记在 `warnings` 里 (不静默拿旧的当新的:
22
+ * `catalogStatusLine()` 会把**这份目录是几号生成的、新不新、来自哪**打出来)。
23
+ *
24
+ * ## 只信目录里字面写着的东西 (铁律)
25
+ *
26
+ * · **无 api 基址 → 空** (`hasBaseUrl=false` + "需自定义 baseUrl"), 不许编一个地址;
27
+ * · **能力字段** (`toolCalling` / `reasoning` / `contextLength`) 只填目录里字面声明过的
28
+ * (`tool_call` / `reasoning` / `limit.context`), 其余一律 `unknown` (不出现在填充结果里);
29
+ * · **鉴权形状 `special`** (AWS / Azure / GCP 服务账号 / 多变量云凭证) → **如实标"需专用鉴权
30
+ * (未支持)"**, `speakable=false`, 不许假装能用;
31
+ * · 环境变量只存**名字**, 永不取值 —— 本层不读 key 值, 不打印, 不落盘。
32
+ *
33
+ * ## 协议族是**少数几个**, 从目录推导 (不是 223 个分支)
34
+ *
35
+ * `familyOfProvider()` 按 `npm` 包名 + `api` 形状把 223 家收成四族:
36
+ * `openai-compatible` / `anthropic` / `gemini` / `special`。判定用的信号在
37
+ * `CatalogProviderView.familySignal` 里逐家可查 (不许只给结论不给理由)。
38
+ *
39
+ * ## 与其它层的关系 (依赖方向, 不许成环)
40
+ *
41
+ * ```
42
+ * config-store → provider-registry → provider-catalog → model-catalog
43
+ * model-discovery → {provider-registry, model-catalog, provider-catalog}
44
+ * ```
45
+ *
46
+ * 本层**不**静态 import provider-registry / config-store (那会成环, 且历史上撞过 TDZ):
47
+ * 运行期文件的目录路径与读写在**异步**路径里动态 import; 同步读口只吃进程内快照。
48
+ * 「真发现的目录是否已覆盖这条模型」由发现层用 `setLiveFactsProbe()` **注入**一个判定函数
49
+ * (方向是 discovery → catalog, 不成环), 于是 live/cached 的字面事实优先于目录快照。
50
+ */
51
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
52
+ if (k2 === undefined) k2 = k;
53
+ var desc = Object.getOwnPropertyDescriptor(m, k);
54
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
55
+ desc = { enumerable: true, get: function() { return m[k]; } };
56
+ }
57
+ Object.defineProperty(o, k2, desc);
58
+ }) : (function(o, m, k, k2) {
59
+ if (k2 === undefined) k2 = k;
60
+ o[k2] = m[k];
61
+ }));
62
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
63
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
64
+ }) : function(o, v) {
65
+ o["default"] = v;
66
+ });
67
+ var __importStar = (this && this.__importStar) || (function () {
68
+ var ownKeys = function(o) {
69
+ ownKeys = Object.getOwnPropertyNames || function (o) {
70
+ var ar = [];
71
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
72
+ return ar;
73
+ };
74
+ return ownKeys(o);
75
+ };
76
+ return function (mod) {
77
+ if (mod && mod.__esModule) return mod;
78
+ var result = {};
79
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
80
+ __setModuleDefault(result, mod);
81
+ return result;
82
+ };
83
+ })();
84
+ Object.defineProperty(exports, "__esModule", { value: true });
85
+ exports.CATALOG_FAMILIES = exports.MIN_PROVIDERS_FOR_TRUST = exports.CATALOG_METADATA_SOURCE_ID = exports.CATALOG_STALE_AFTER_DAYS = exports.CATALOG_VERSION = exports.CATALOG_FILE_NAME = exports.CATALOG_SOURCE_URL = void 0;
86
+ exports.normalizeCatalogData = normalizeCatalogData;
87
+ exports.renderCatalogJson = renderCatalogJson;
88
+ exports.factsOfModelRecord = factsOfModelRecord;
89
+ exports.toCatalogProviderRecord = toCatalogProviderRecord;
90
+ exports.buildCatalogData = buildCatalogData;
91
+ exports.familyOfProvider = familyOfProvider;
92
+ exports.authShapeOfProvider = authShapeOfProvider;
93
+ exports.buildCatalogViews = buildCatalogViews;
94
+ exports.catalogLoad = catalogLoad;
95
+ exports.catalogProviders = catalogProviders;
96
+ exports.catalogProviderIds = catalogProviderIds;
97
+ exports.getCatalogProvider = getCatalogProvider;
98
+ exports.catalogModelFactsOf = catalogModelFactsOf;
99
+ exports.catalogStats = catalogStats;
100
+ exports.resetProviderCatalogSnapshot = resetProviderCatalogSnapshot;
101
+ exports.catalogFilePath = catalogFilePath;
102
+ exports.loadedCatalogPath = loadedCatalogPath;
103
+ exports.initializeProviderCatalog = initializeProviderCatalog;
104
+ exports.fetchCatalogSource = fetchCatalogSource;
105
+ exports.refreshProviderCatalog = refreshProviderCatalog;
106
+ exports.catalogDateText = catalogDateText;
107
+ exports.catalogAgeText = catalogAgeText;
108
+ exports.catalogStatusLine = catalogStatusLine;
109
+ exports.catalogGroupLine = catalogGroupLine;
110
+ exports.formatCatalogProviderLine = formatCatalogProviderLine;
111
+ exports.setLiveFactsProbe = setLiveFactsProbe;
112
+ exports.setCatalogFillScope = setCatalogFillScope;
113
+ exports.catalogAnswersProvider = catalogAnswersProvider;
114
+ exports.providerCatalogMetadataSource = providerCatalogMetadataSource;
115
+ exports.registerProviderCatalogMetadataSource = registerProviderCatalogMetadataSource;
116
+ exports.ensureProviderCatalogMetadataSource = ensureProviderCatalogMetadataSource;
117
+ exports.isProviderCatalogMetadataSourceWired = isProviderCatalogMetadataSourceWired;
118
+ exports.catalogProviderCount = catalogProviderCount;
119
+ exports.defaultCatalogPathHint = defaultCatalogPathHint;
120
+ const fs = __importStar(require("node:fs"));
121
+ const path = __importStar(require("node:path"));
122
+ const os = __importStar(require("node:os"));
123
+ const crypto = __importStar(require("node:crypto"));
124
+ const provider_catalog_baked_js_1 = require("./data/provider-catalog-baked.js");
125
+ const model_catalog_js_1 = require("./model-catalog.js");
126
+ // ============================================================
127
+ // 常量
128
+ // ============================================================
129
+ /** 公开源 (数据从这里取; 换源要显式 `--url`) */
130
+ exports.CATALOG_SOURCE_URL = 'https://models.dev/api.json';
131
+ /** 运行期目录文件名 (落在配置目录, mode 0600) */
132
+ exports.CATALOG_FILE_NAME = 'provider-catalog.json';
133
+ /** 数据格式版本 */
134
+ exports.CATALOG_VERSION = 1;
135
+ /** 超过这么多天算陈旧 (界面要如实标, 不许静默拿旧的当新的) */
136
+ exports.CATALOG_STALE_AFTER_DAYS = 30;
137
+ /** 元数据填充点 id */
138
+ exports.CATALOG_METADATA_SOURCE_ID = 'provider-catalog';
139
+ /** 家数下限: 低于它认为这份数据不可信 (截断/被拦/被换成别的东西) */
140
+ exports.MIN_PROVIDERS_FOR_TRUST = 100;
141
+ /** 允许出现的协议族 (少数几个; 加族要改这里并说明理由) */
142
+ exports.CATALOG_FAMILIES = ['openai-compatible', 'anthropic', 'gemini', 'special'];
143
+ function pickStr(v) {
144
+ return typeof v === 'string' ? v.trim() : '';
145
+ }
146
+ function pickNum(v) {
147
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
148
+ }
149
+ function normalizeFacts(raw) {
150
+ const out = {};
151
+ if (!raw || typeof raw !== 'object')
152
+ return out;
153
+ const m = raw;
154
+ const name = pickStr(m.name);
155
+ if (name)
156
+ out.name = name;
157
+ if (typeof m.toolCalling === 'boolean')
158
+ out.toolCalling = m.toolCalling;
159
+ if (typeof m.reasoning === 'boolean')
160
+ out.reasoning = m.reasoning;
161
+ if (typeof m.contextLength === 'number' && Number.isFinite(m.contextLength) && m.contextLength > 0) {
162
+ out.contextLength = Math.floor(m.contextLength);
163
+ }
164
+ return out;
165
+ }
166
+ /**
167
+ * 规范化一份目录数据 (纯函数)。**不补默认值**: 缺字段就按缺处理, 形状不对就明确拒绝。
168
+ *
169
+ * 运行期文件是用户可改的 (在 `~/.bolloon/` 里), 所以这里对每个字段都做形状检查;
170
+ * 坏数据唯一的后果是"退回烘焙数据 + 一条 warning", 不是"带着半份目录继续跑"。
171
+ */
172
+ function normalizeCatalogData(raw) {
173
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
174
+ return { ok: false, reason: '不是对象 (目录数据是一个「一家一个键」的对象)' };
175
+ }
176
+ const r = raw;
177
+ const version = pickNum(r.version);
178
+ if (version !== exports.CATALOG_VERSION) {
179
+ return { ok: false, reason: `版本 ${String(r.version)} ≠ ${exports.CATALOG_VERSION} (不认识的形状不当成数据)` };
180
+ }
181
+ const providersRaw = r.providers;
182
+ if (!providersRaw || typeof providersRaw !== 'object' || Array.isArray(providersRaw)) {
183
+ return { ok: false, reason: 'providers 不是对象' };
184
+ }
185
+ const providers = {};
186
+ for (const [key, val] of Object.entries(providersRaw)) {
187
+ const id = String(key || '').trim();
188
+ if (!id || !val || typeof val !== 'object')
189
+ continue;
190
+ const p = val;
191
+ const modelsRaw = p.models && typeof p.models === 'object' && !Array.isArray(p.models) ? p.models : {};
192
+ const models = {};
193
+ for (const [mid, m] of Object.entries(modelsRaw)) {
194
+ const id2 = String(mid || '').trim();
195
+ if (!id2)
196
+ continue;
197
+ models[id2] = normalizeFacts(m);
198
+ }
199
+ providers[id] = {
200
+ id: pickStr(p.id) || id,
201
+ name: pickStr(p.name) || id,
202
+ env: Array.isArray(p.env) ? p.env.filter((e) => typeof e === 'string' && String(e).trim()).map((e) => String(e).trim()) : [],
203
+ npm: pickStr(p.npm),
204
+ api: pickStr(p.api),
205
+ doc: pickStr(p.doc),
206
+ models,
207
+ };
208
+ }
209
+ const count = Object.keys(providers).length;
210
+ if (count === 0)
211
+ return { ok: false, reason: 'providers 是空的 (空目录不当成一份目录)' };
212
+ const prov = r.provenance && typeof r.provenance === 'object' ? r.provenance : {};
213
+ return {
214
+ ok: true,
215
+ value: {
216
+ version: exports.CATALOG_VERSION,
217
+ generatedAt: pickStr(r.generatedAt) || pickStr(prov.fetchedAt),
218
+ generatedBy: pickStr(r.generatedBy) || 'unknown',
219
+ provenance: {
220
+ sourceUrl: pickStr(prov.sourceUrl),
221
+ sourceBytes: pickNum(prov.sourceBytes),
222
+ sourceSha256: pickStr(prov.sourceSha256),
223
+ fetchedAt: pickStr(prov.fetchedAt),
224
+ sourceProviderCount: pickNum(prov.sourceProviderCount),
225
+ },
226
+ providerCount: count,
227
+ providers,
228
+ },
229
+ };
230
+ }
231
+ /**
232
+ * 目录数据 → 规范 JSON 文本 (**唯一**的写出格式: 运行期落盘与构建期产物都用它)。
233
+ *
234
+ * 格式选择: 头字段缩进可读, 每家**一行** (带全部模型) —— 于是 `git diff` 一眼看出"哪几家变了",
235
+ * 而文件又不会因为上万个模型各自换行而膨胀。
236
+ */
237
+ function renderCatalogJson(data) {
238
+ const head = {
239
+ version: data.version,
240
+ generatedAt: data.generatedAt,
241
+ generatedBy: data.generatedBy,
242
+ provenance: data.provenance,
243
+ providerCount: data.providerCount,
244
+ };
245
+ const out = ['{'];
246
+ const headLines = JSON.stringify(head, null, 2).split('\n').slice(1, -1);
247
+ // 头字段后面还有 `providers`, 所以**最后一行必须补逗号** —— 少这个逗号就是一份坏 JSON
248
+ // (2026-09-27 真踩过: 直接读这个文件的门会 JSON.parse 报错)。
249
+ headLines.forEach((l, i) => out.push(i === headLines.length - 1 ? `${l},` : l));
250
+ out.push(' "providers": {');
251
+ const ids = Object.keys(data.providers).sort();
252
+ ids.forEach((id, i) => {
253
+ const rec = data.providers[id];
254
+ out.push(` ${JSON.stringify(id)}: ${JSON.stringify(rec)}${i === ids.length - 1 ? '' : ','}`);
255
+ });
256
+ out.push(' }');
257
+ out.push('}');
258
+ const text = out.join('\n') + '\n';
259
+ // **自校验**: 写出去的必须是能读回来的 JSON (格式自检, 不靠"看着像")
260
+ const back = JSON.parse(text);
261
+ if (back.providerCount !== data.providerCount || Object.keys(back.providers || {}).length !== Object.keys(data.providers).length) {
262
+ throw new Error('renderCatalogJson 自校验失败: 读回来的家数对不上');
263
+ }
264
+ return text;
265
+ }
266
+ // ============================================================
267
+ // 公开源 → 目录数据 (纯函数; 构建期烘焙与运行期刷新**同一份**实现)
268
+ // ============================================================
269
+ /** 源里一条模型 → 事实 (只认字面字段; 字符串 "true"/0/负数一律不认) */
270
+ function factsOfModelRecord(raw) {
271
+ const out = {};
272
+ if (!raw || typeof raw !== 'object')
273
+ return out;
274
+ const m = raw;
275
+ const name = pickStr(m.name);
276
+ if (name)
277
+ out.name = name;
278
+ if (typeof m.tool_call === 'boolean')
279
+ out.toolCalling = m.tool_call;
280
+ if (typeof m.reasoning === 'boolean')
281
+ out.reasoning = m.reasoning;
282
+ const limit = m.limit && typeof m.limit === 'object' ? m.limit : null;
283
+ const ctx = limit ? limit.context : undefined;
284
+ if (typeof ctx === 'number' && Number.isFinite(ctx) && ctx > 0)
285
+ out.contextLength = Math.floor(ctx);
286
+ return out;
287
+ }
288
+ /** 源里一家 → 目录记录 (纯函数) */
289
+ function toCatalogProviderRecord(id, raw) {
290
+ const p = (raw && typeof raw === 'object' ? raw : {});
291
+ const env = Array.isArray(p.env)
292
+ ? p.env.filter((e) => typeof e === 'string' && String(e).trim()).map((e) => String(e).trim())
293
+ : [];
294
+ const modelsRaw = p.models && typeof p.models === 'object' && !Array.isArray(p.models)
295
+ ? p.models
296
+ : {};
297
+ const models = {};
298
+ for (const [mid, m] of Object.entries(modelsRaw)) {
299
+ const key = String(mid || '').trim();
300
+ if (!key)
301
+ continue;
302
+ models[key] = factsOfModelRecord(m);
303
+ }
304
+ return {
305
+ id: pickStr(p.id) || id,
306
+ name: pickStr(p.name) || pickStr(p.id) || id,
307
+ env,
308
+ npm: pickStr(p.npm),
309
+ // 目录里没有 api → **空串** (不许顺手补一个默认地址)
310
+ api: pickStr(p.api),
311
+ doc: pickStr(p.doc),
312
+ models,
313
+ };
314
+ }
315
+ /**
316
+ * 整个源 → 目录数据 (纯函数; 排序稳定: 家与模型都按 id 字典序)。
317
+ *
318
+ * 形状不认就**抛错拒绝**, 不猜 (调用方决定是"拒绝落盘"还是"报错不采用")。
319
+ */
320
+ function buildCatalogData(raw, provenance, generatedAt) {
321
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
322
+ throw new Error('源不是「一家一个键」的对象 → 拒绝生成 (不猜形状)');
323
+ }
324
+ const src = raw;
325
+ const ids = Object.keys(src).sort();
326
+ const providers = {};
327
+ for (const id of ids) {
328
+ const rec = toCatalogProviderRecord(id, src[id]);
329
+ const sortedModels = {};
330
+ for (const mid of Object.keys(rec.models).sort())
331
+ sortedModels[mid] = rec.models[mid];
332
+ providers[id] = { ...rec, models: sortedModels };
333
+ }
334
+ return {
335
+ version: exports.CATALOG_VERSION,
336
+ generatedAt,
337
+ generatedBy: 'scripts/gen-provider-catalog.ts',
338
+ provenance: { ...provenance, sourceProviderCount: ids.length },
339
+ providerCount: ids.length,
340
+ providers,
341
+ };
342
+ }
343
+ // ============================================================
344
+ // 协议族 / 鉴权形状 (少数几个族, 从目录推导)
345
+ // ============================================================
346
+ /** npm 包名 → 协议族 (只列**有把握**的; 其余落到 openai-compatible 或 special, 见下) */
347
+ const FAMILY_BY_NPM = [
348
+ { match: /amazon-bedrock/i, family: 'special', signal: 'npm 指明是 Amazon Bedrock (AWS SigV4 / 专用凭证)' },
349
+ { match: /@ai-sdk\/azure/i, family: 'special', signal: 'npm 指明是 Azure (端点 + api-version + api-key 组合)' },
350
+ { match: /google-vertex/i, family: 'special', signal: 'npm 指明是 Google Vertex (GCP 服务账号 / OAuth)' },
351
+ { match: /@ai-sdk\/anthropic/i, family: 'anthropic', signal: 'npm 指明是 Anthropic 客户端' },
352
+ { match: /@ai-sdk\/google$/i, family: 'gemini', signal: 'npm 指明是 Google Generative AI 客户端' },
353
+ { match: /sap-ai-provider/i, family: 'special', signal: 'npm 指明是 SAP AI Core (OAuth 换 token)' },
354
+ { match: /watsonx/i, family: 'special', signal: 'npm 指明是 IBM watsonx (apiKey + projectId)' },
355
+ { match: /gitlab/i, family: 'special', signal: 'npm 指明是 GitLab (instance + token 组合)' },
356
+ ];
357
+ /** 环境变量名里的"云凭证味道" —— 出现即判 special (要多个变量/要走云签名, 不是一个 Bearer 的事) */
358
+ const CLOUD_ENV_SIGNALS = [
359
+ { match: /^AWS_/, signal: '环境变量是 AWS 签名凭证 (AWS_*)' },
360
+ { match: /^AZURE_/, signal: '环境变量是 Azure 资源名 + key 组合 (AZURE_*)' },
361
+ { match: /^GOOGLE_APPLICATION_CREDENTIALS$|^GOOGLE_VERTEX_/, signal: '环境变量是 GCP 服务账号 / Vertex 项目 (GOOGLE_*)' },
362
+ { match: /^WATSONX_/, signal: '环境变量是 watsonx 的 apiKey + projectId' },
363
+ { match: /^DATABRICKS_/, signal: '环境变量是 Databricks host + token 组合' },
364
+ { match: /^SNOWFLAKE_/, signal: '环境变量是 Snowflake account + PAT 组合' },
365
+ { match: /^CLOUDFLARE_(ACCOUNT_ID|GATEWAY_ID)$/, signal: '环境变量是 Cloudflare 账号/网关 id (不止一个 key)' },
366
+ { match: /^(.*_)?ENDPOINT$/, signal: '环境变量里有端点地址 (不是单纯的 key)' },
367
+ { match: /_PROJECT_ID$|_PRODUCT_ID$|^INFOMANIAK_/, signal: '环境变量里有项目/产品 id (不是单纯的 key)' },
368
+ ];
369
+ /**
370
+ * 一家的协议族 (纯函数)。
371
+ *
372
+ * 判定顺序: ① npm 包名 (最明确); ② 环境变量形状 (云凭证味道 → special); ③ 默认 openai-compatible。
373
+ * `special` 不是"猜不到", 而是"**本运行时不支持这种鉴权形状**" —— 界面上如实标出来, 不当可用。
374
+ */
375
+ function familyOfProvider(rec) {
376
+ for (const rule of FAMILY_BY_NPM) {
377
+ if (rec.npm && rule.match.test(rec.npm))
378
+ return { family: rule.family, signal: rule.signal };
379
+ }
380
+ for (const sig of CLOUD_ENV_SIGNALS) {
381
+ const hit = rec.env.find((e) => sig.match.test(e));
382
+ if (hit)
383
+ return { family: 'special', signal: `${sig.signal} (命中 ${hit})` };
384
+ }
385
+ if (rec.npm)
386
+ return { family: 'openai-compatible', signal: `npm ${rec.npm} 走 OpenAI 兼容形状 (目录里没有别的声明)` };
387
+ return { family: 'openai-compatible', signal: '目录里没给 npm 包名 → 按 OpenAI 兼容形状处理 (可被 --base-url 覆盖)' };
388
+ }
389
+ /**
390
+ * 鉴权形状 (纯函数)。
391
+ *
392
+ * `special` 的家**没有**单一的 key 变量可用 (或需要云签名/多变量), 所以 `supported=false`,
393
+ * 理由写在 `note` 里 —— 界面直接打这句, 不许假装能用。
394
+ */
395
+ function authShapeOfProvider(rec, family, signal) {
396
+ const first = rec.env[0];
397
+ if (family === 'special') {
398
+ return {
399
+ kind: 'special',
400
+ ...(first ? { envVar: first } : {}),
401
+ note: `需专用鉴权 (未支持): ${signal}`,
402
+ supported: false,
403
+ };
404
+ }
405
+ if (!rec.env.length) {
406
+ return { kind: 'none', note: '目录里没给环境变量名 → 凭据无从读起 (需自定义凭据)', supported: false };
407
+ }
408
+ if (family === 'anthropic') {
409
+ return { kind: 'x-api-key', envVar: first, note: `x-api-key 头 (读环境变量 ${first})`, supported: true };
410
+ }
411
+ if (family === 'gemini') {
412
+ return { kind: 'x-goog-api-key', envVar: first, note: `x-goog-api-key 头 / ?key= (读环境变量 ${first})`, supported: true };
413
+ }
414
+ return { kind: 'bearer', envVar: first, note: `Authorization: Bearer (读环境变量 ${first})`, supported: true };
415
+ }
416
+ /** 目录数据 → 运行时视图 (纯函数; 逐家带上族/信号/鉴权/能不能用) */
417
+ function buildCatalogViews(data) {
418
+ return Object.keys(data.providers).sort().map((id) => {
419
+ const rec = data.providers[id];
420
+ const { family, signal } = familyOfProvider(rec);
421
+ const auth = authShapeOfProvider(rec, family, signal);
422
+ const hasBaseUrl = !!rec.api;
423
+ const modelIds = Object.keys(rec.models).sort();
424
+ const unusableReason = !auth.supported
425
+ ? auth.note
426
+ : (!hasBaseUrl ? '目录里这家没有 api 基址 → 需自定义 baseUrl' : null);
427
+ return {
428
+ id,
429
+ name: rec.name,
430
+ env: [...rec.env],
431
+ npm: rec.npm,
432
+ api: rec.api,
433
+ doc: rec.doc,
434
+ modelIds,
435
+ models: rec.models,
436
+ family,
437
+ familySignal: signal,
438
+ auth,
439
+ hasBaseUrl,
440
+ speakable: auth.supported && hasBaseUrl,
441
+ unusableReason,
442
+ };
443
+ });
444
+ }
445
+ // ============================================================
446
+ // 进程内快照 (同步读口的唯一数据源)
447
+ // ============================================================
448
+ /** 烘焙数据: 模块体里只解析**一次**的形状检查 (不做任何 I/O) */
449
+ function parseBaked() {
450
+ try {
451
+ return normalizeCatalogData(JSON.parse(provider_catalog_baked_js_1.PROVIDER_CATALOG_JSON));
452
+ }
453
+ catch (e) {
454
+ return { ok: false, reason: `烘焙数据不是合法 JSON: ${String(e?.message || e)}` };
455
+ }
456
+ }
457
+ const bakedParse = parseBaked();
458
+ function emptyData(reason) {
459
+ return {
460
+ version: exports.CATALOG_VERSION,
461
+ generatedAt: '',
462
+ generatedBy: 'unavailable',
463
+ provenance: { sourceUrl: exports.CATALOG_SOURCE_URL, sourceBytes: 0, sourceSha256: '', fetchedAt: '', sourceProviderCount: 0 },
464
+ providerCount: 0,
465
+ providers: {},
466
+ };
467
+ }
468
+ let snapshot = bakedParse.ok
469
+ ? {
470
+ data: bakedParse.value,
471
+ source: 'bundled',
472
+ path: null,
473
+ provenance: bakedParse.value.provenance,
474
+ generatedAt: bakedParse.value.generatedAt,
475
+ ageDays: ageDaysOf(bakedParse.value),
476
+ stale: ageDaysOf(bakedParse.value) > exports.CATALOG_STALE_AFTER_DAYS,
477
+ warnings: [],
478
+ }
479
+ : {
480
+ data: emptyData(bakedParse.reason),
481
+ source: 'bundled',
482
+ path: null,
483
+ provenance: { sourceUrl: exports.CATALOG_SOURCE_URL, sourceBytes: 0, sourceSha256: '', fetchedAt: '', sourceProviderCount: 0 },
484
+ generatedAt: '',
485
+ ageDays: 0,
486
+ stale: true,
487
+ warnings: [`烘焙目录数据不可用: ${bakedParse.reason}`],
488
+ };
489
+ let views = buildCatalogViews(snapshot.data);
490
+ let viewsById = new Map(views.map((v) => [v.id, v]));
491
+ let runtimePath = null;
492
+ function ageDaysOf(data, nowMs = Date.now()) {
493
+ const t = Date.parse(data.provenance.fetchedAt || data.generatedAt);
494
+ if (!Number.isFinite(t))
495
+ return Number.POSITIVE_INFINITY;
496
+ return Math.max(0, (nowMs - t) / 86_400_000);
497
+ }
498
+ function applySnapshot(next) {
499
+ snapshot = next;
500
+ views = buildCatalogViews(snapshot.data);
501
+ viewsById = new Map(views.map((v) => [v.id, v]));
502
+ return snapshot;
503
+ }
504
+ /** 当前这份目录的加载结论 (同步; 含来源/生成时间/陈旧标记) */
505
+ function catalogLoad() {
506
+ return snapshot;
507
+ }
508
+ /** 当前目录的全部家 (同步; 按 id 排序) */
509
+ function catalogProviders() {
510
+ return views;
511
+ }
512
+ /** 当前目录的家 id (同步; 按 id 排序) */
513
+ function catalogProviderIds() {
514
+ return views.map((v) => v.id);
515
+ }
516
+ /** 一家的视图 (不在目录里 → `undefined`, 不编) */
517
+ function getCatalogProvider(id) {
518
+ return viewsById.get(String(id || '').trim());
519
+ }
520
+ /** 某家某模型在目录里**字面声明过**的事实 (没有 → `undefined`) */
521
+ function catalogModelFactsOf(provider, model) {
522
+ return getCatalogProvider(provider)?.models[String(model || '')];
523
+ }
524
+ /** 目录统计 (全部从当前目录真算) */
525
+ function catalogStats() {
526
+ const families = { 'openai-compatible': 0, anthropic: 0, gemini: 0, special: 0 };
527
+ let withApi = 0, envNamed = 0, models = 0, specialAuth = 0, noBaseUrl = 0, speakable = 0;
528
+ for (const v of views) {
529
+ families[v.family] = (families[v.family] || 0) + 1;
530
+ if (v.hasBaseUrl)
531
+ withApi++;
532
+ else
533
+ noBaseUrl++;
534
+ if (v.env.length)
535
+ envNamed++;
536
+ models += v.modelIds.length;
537
+ if (!v.auth.supported)
538
+ specialAuth++;
539
+ if (v.speakable)
540
+ speakable++;
541
+ }
542
+ return {
543
+ providers: views.length,
544
+ withApi,
545
+ withoutApi: views.length - withApi,
546
+ envNamed,
547
+ models,
548
+ families,
549
+ specialAuth,
550
+ noBaseUrl,
551
+ speakable,
552
+ };
553
+ }
554
+ /** 清掉进程内快照对运行期文件的记忆 (测试用; 生产路径不调用) */
555
+ function resetProviderCatalogSnapshot() {
556
+ runtimePath = null;
557
+ applySnapshot(bakedParse.ok
558
+ ? {
559
+ data: bakedParse.value,
560
+ source: 'bundled',
561
+ path: null,
562
+ provenance: bakedParse.value.provenance,
563
+ generatedAt: bakedParse.value.generatedAt,
564
+ ageDays: ageDaysOf(bakedParse.value),
565
+ stale: ageDaysOf(bakedParse.value) > exports.CATALOG_STALE_AFTER_DAYS,
566
+ warnings: [],
567
+ }
568
+ : snapshot);
569
+ }
570
+ // ============================================================
571
+ // 运行期文件 (读: 异步 + 动态 import 配置目录; 同步读口只吃快照)
572
+ // ============================================================
573
+ /** 配置目录 (`BOLLOON_HOME` 与配置存储同一处 —— **调用时**解析, 不静态 import 它以免成环) */
574
+ async function configDirPath() {
575
+ const mod = await Promise.resolve().then(() => __importStar(require('./config-store.js')));
576
+ return String(mod.llmConfigStore.configDirPath());
577
+ }
578
+ /** 运行期目录文件路径 (异步: 需要配置目录) */
579
+ async function catalogFilePath() {
580
+ return path.join(await configDirPath(), exports.CATALOG_FILE_NAME);
581
+ }
582
+ /** 已经读进来的运行期文件路径 (还没读过 → `null`; 展示用) */
583
+ function loadedCatalogPath() {
584
+ return runtimePath;
585
+ }
586
+ /**
587
+ * 读一次运行期文件并(在合法且更新时)覆盖进程内快照。
588
+ *
589
+ * 规矩:
590
+ * · 文件不在 → 保持烘焙数据 (这不是错误, 是"还没拉过最新");
591
+ * · 文件坏了/版本不认识/家数少得不合理 → **保持烘焙数据** + warning (不许带着半份目录跑);
592
+ * · 文件合法但**不比现在的新** → 保持现在这份 (防止用旧文件盖掉更新的内存数据), 记 warning。
593
+ */
594
+ async function initializeProviderCatalog(opts = {}) {
595
+ if (runtimePath && !opts.force)
596
+ return snapshot;
597
+ let file;
598
+ try {
599
+ file = await catalogFilePath();
600
+ }
601
+ catch (e) {
602
+ return applySnapshot({ ...snapshot, warnings: [...snapshot.warnings, `拿不到配置目录 → 用烘焙目录 (${String(e?.message || e)})`] });
603
+ }
604
+ runtimePath = file;
605
+ let text;
606
+ try {
607
+ text = fs.readFileSync(file, 'utf-8');
608
+ }
609
+ catch {
610
+ return snapshot; // 还没拉过最新: 保持烘焙数据 (不是错误)
611
+ }
612
+ const warnings = [...snapshot.warnings];
613
+ let parsed;
614
+ try {
615
+ parsed = normalizeCatalogData(JSON.parse(text));
616
+ }
617
+ catch (e) {
618
+ warnings.push(`运行期目录文件不是合法 JSON → 用烘焙目录 (${String(e?.message || e)})`);
619
+ return applySnapshot({ ...snapshot, warnings });
620
+ }
621
+ if (!parsed.ok) {
622
+ warnings.push(`运行期目录文件被忽略: ${parsed.reason} → 用烘焙目录`);
623
+ return applySnapshot({ ...snapshot, warnings });
624
+ }
625
+ if (parsed.value.providerCount < exports.MIN_PROVIDERS_FOR_TRUST) {
626
+ warnings.push(`运行期目录只有 ${parsed.value.providerCount} 家 (< ${exports.MIN_PROVIDERS_FOR_TRUST}) → 用烘焙目录 (不信任半份目录)`);
627
+ return applySnapshot({ ...snapshot, warnings });
628
+ }
629
+ const incoming = Date.parse(parsed.value.provenance.fetchedAt || parsed.value.generatedAt);
630
+ const current = Date.parse(snapshot.generatedAt);
631
+ // **只有更新的那份能用**: 运行期文件比现在这份旧 (无论现在这份是烘焙数据还是上次刷的) → 保留现在这份。
632
+ // 这条防的是"盘上躺着一份两个月前拉的目录, 却把刚更新的烘焙数据盖掉"。
633
+ if (Number.isFinite(incoming) && (!Number.isFinite(current) || incoming <= current)) {
634
+ warnings.push(snapshot.source === 'runtime'
635
+ ? '运行期目录文件不比内存里这份新 → 保留内存里这份'
636
+ : `运行期目录文件 (${parsed.value.provenance.fetchedAt || '无时间戳'}) 比构建期烘焙数据 (${snapshot.generatedAt || '无时间戳'}) 旧 → 用烘焙数据 (想用最新就 /model catalog refresh)`);
637
+ return applySnapshot({ ...snapshot, warnings });
638
+ }
639
+ const age = ageDaysOf(parsed.value);
640
+ return applySnapshot({
641
+ data: parsed.value,
642
+ source: 'runtime',
643
+ path: file,
644
+ provenance: parsed.value.provenance,
645
+ generatedAt: parsed.value.generatedAt,
646
+ ageDays: age,
647
+ stale: age > exports.CATALOG_STALE_AFTER_DAYS,
648
+ warnings,
649
+ });
650
+ }
651
+ /** 取一次源 (纯 I/O; 超时与 HTTP 状态都如实报) */
652
+ async function fetchCatalogSource(url, opts = {}) {
653
+ if (opts.fromFile) {
654
+ const raw = fs.readFileSync(opts.fromFile);
655
+ const text = raw.toString('utf-8');
656
+ return {
657
+ text,
658
+ bytes: raw.byteLength,
659
+ sha256: crypto.createHash('sha256').update(raw).digest('hex'),
660
+ fetchedAt: new Date((opts.now ?? Date.now)()).toISOString(),
661
+ };
662
+ }
663
+ const f = opts.fetchImpl ?? fetch;
664
+ const ctrl = new AbortController();
665
+ const timer = setTimeout(() => ctrl.abort(), Math.max(1, opts.timeoutMs ?? 60_000));
666
+ try {
667
+ const res = await f(url, { signal: ctrl.signal, headers: { accept: 'application/json' } });
668
+ if (!res.ok)
669
+ throw new Error(`源回 HTTP ${res.status}`);
670
+ const text = await res.text();
671
+ return {
672
+ text,
673
+ bytes: Buffer.byteLength(text, 'utf-8'),
674
+ sha256: crypto.createHash('sha256').update(text, 'utf-8').digest('hex'),
675
+ fetchedAt: new Date((opts.now ?? Date.now)()).toISOString(),
676
+ };
677
+ }
678
+ finally {
679
+ clearTimeout(timer);
680
+ }
681
+ }
682
+ /**
683
+ * 真拉一次目录最新版 → 规范化 → 落盘 (`${BOLLOON_HOME}/provider-catalog.json`, mode 0600)
684
+ * → 覆盖进程内快照。
685
+ *
686
+ * 落盘的每一份都带**来源与生成时间** (`sourceUrl` / `sourceBytes` / `sourceSha256` / `fetchedAt`),
687
+ * 界面上打的就是这几个数 —— 于是"这份目录是几号的、是不是刚刷的"是可核事实, 不是感觉。
688
+ */
689
+ async function refreshProviderCatalog(opts = {}) {
690
+ const url = String(opts.url || exports.CATALOG_SOURCE_URL);
691
+ const now = opts.now ?? (() => Date.now());
692
+ const notes = [];
693
+ let file;
694
+ try {
695
+ file = await catalogFilePath();
696
+ }
697
+ catch (e) {
698
+ return {
699
+ ok: false, sourceUrl: url, sourceBytes: 0, sourceSha256: '', fetchedAt: new Date(now()).toISOString(),
700
+ providerCount: 0, modelCount: 0, path: '', fileMode: '', error: `拿不到配置目录: ${String(e?.message || e)}`,
701
+ notes, stats: catalogStats(),
702
+ };
703
+ }
704
+ let fetched;
705
+ try {
706
+ fetched = await fetchCatalogSource(url, { ...opts, now });
707
+ }
708
+ catch (e) {
709
+ return {
710
+ ok: false, sourceUrl: url, sourceBytes: 0, sourceSha256: '', fetchedAt: new Date(now()).toISOString(),
711
+ providerCount: 0, modelCount: 0, path: file, fileMode: '', error: `取源失败: ${String(e?.message || e)}`,
712
+ notes: [...notes, '没有改动盘上与内存里的目录 (失败不留半成品)'], stats: catalogStats(),
713
+ };
714
+ }
715
+ let parsedJson;
716
+ try {
717
+ parsedJson = JSON.parse(fetched.text);
718
+ }
719
+ catch (e) {
720
+ return {
721
+ ok: false, sourceUrl: url, sourceBytes: fetched.bytes, sourceSha256: fetched.sha256, fetchedAt: fetched.fetchedAt,
722
+ providerCount: 0, modelCount: 0, path: file, fileMode: '', error: `源不是合法 JSON: ${String(e?.message || e)}`,
723
+ notes: [...notes, '没有改动盘上与内存里的目录'], stats: catalogStats(),
724
+ };
725
+ }
726
+ // 源形状 → 目录数据 (与构建期**同一个**纯函数: `buildCatalogData`)
727
+ let data;
728
+ try {
729
+ data = buildCatalogData(parsedJson, {
730
+ sourceUrl: url,
731
+ sourceBytes: fetched.bytes,
732
+ sourceSha256: fetched.sha256,
733
+ fetchedAt: fetched.fetchedAt,
734
+ sourceProviderCount: 0,
735
+ }, fetched.fetchedAt);
736
+ }
737
+ catch (e) {
738
+ return {
739
+ ok: false, sourceUrl: url, sourceBytes: fetched.bytes, sourceSha256: fetched.sha256, fetchedAt: fetched.fetchedAt,
740
+ providerCount: 0, modelCount: 0, path: file, fileMode: '', error: `源形状不认: ${String(e?.message || e)}`,
741
+ notes: [...notes, '没有改动盘上与内存里的目录'], stats: catalogStats(),
742
+ };
743
+ }
744
+ if (data.providerCount < exports.MIN_PROVIDERS_FOR_TRUST) {
745
+ return {
746
+ ok: false, sourceUrl: url, sourceBytes: fetched.bytes, sourceSha256: fetched.sha256, fetchedAt: fetched.fetchedAt,
747
+ providerCount: data.providerCount, modelCount: 0, path: file, fileMode: '',
748
+ error: `源只解析出 ${data.providerCount} 家 (< ${exports.MIN_PROVIDERS_FOR_TRUST}) → 拒绝采用 (源可能被截断/被拦)`,
749
+ notes: [...notes, '没有改动盘上与内存里的目录'], stats: catalogStats(),
750
+ };
751
+ }
752
+ let fileMode = '';
753
+ if (opts.write !== false) {
754
+ try {
755
+ fs.mkdirSync(path.dirname(file), { recursive: true });
756
+ const tmp = `${file}.tmp-${process.pid}-${now()}`;
757
+ fs.writeFileSync(tmp, renderCatalogJson(data), { mode: 0o600 });
758
+ try {
759
+ fs.chmodSync(tmp, 0o600);
760
+ }
761
+ catch { /* 尽力而为 */ }
762
+ fs.renameSync(tmp, file);
763
+ try {
764
+ fs.chmodSync(file, 0o600);
765
+ }
766
+ catch { /* 尽力而为 */ }
767
+ fileMode = (fs.statSync(file).mode & 0o777).toString(8);
768
+ notes.push(`已落盘 ${file} (mode ${fileMode})`);
769
+ }
770
+ catch (e) {
771
+ return {
772
+ ok: false, sourceUrl: url, sourceBytes: fetched.bytes, sourceSha256: fetched.sha256, fetchedAt: fetched.fetchedAt,
773
+ providerCount: data.providerCount, modelCount: 0, path: file, fileMode: '',
774
+ error: `落盘失败: ${String(e?.message || e)}`, notes: [...notes, '内存与盘上的目录都没有改动'], stats: catalogStats(),
775
+ };
776
+ }
777
+ }
778
+ else {
779
+ notes.push('干跑 (write:false): 只解析, 没落盘');
780
+ }
781
+ runtimePath = file;
782
+ const age = ageDaysOf(data, now());
783
+ applySnapshot({
784
+ data,
785
+ source: 'runtime',
786
+ path: file,
787
+ provenance: data.provenance,
788
+ generatedAt: data.generatedAt,
789
+ ageDays: age,
790
+ stale: age > exports.CATALOG_STALE_AFTER_DAYS,
791
+ warnings: [],
792
+ });
793
+ const stats = catalogStats();
794
+ const modelCount = Object.values(data.providers).reduce((n, p) => n + Object.keys(p.models).length, 0);
795
+ notes.push(`家数 ${stats.providers} · 有 api 基址 ${stats.withApi} · 无 api 基址 ${stats.withoutApi} · 模型 ${modelCount}`);
796
+ return {
797
+ ok: true,
798
+ sourceUrl: url,
799
+ sourceBytes: fetched.bytes,
800
+ sourceSha256: fetched.sha256,
801
+ fetchedAt: fetched.fetchedAt,
802
+ providerCount: stats.providers,
803
+ modelCount,
804
+ path: file,
805
+ fileMode,
806
+ notes,
807
+ stats,
808
+ };
809
+ }
810
+ // ============================================================
811
+ // 展示 (中文 + 英文术语; 只翻译, 不产生值)
812
+ // ============================================================
813
+ /** 目录时间戳 → `YYYY-MM-DD` (拿不到就如实说"无时间戳") */
814
+ function catalogDateText(load = snapshot) {
815
+ const t = Date.parse(load.provenance.fetchedAt || load.generatedAt);
816
+ if (!Number.isFinite(t))
817
+ return '无时间戳';
818
+ return new Date(t).toISOString().slice(0, 10);
819
+ }
820
+ /** 新不新 (人话) */
821
+ function catalogAgeText(load = snapshot) {
822
+ if (!Number.isFinite(load.ageDays))
823
+ return '生成时间未知';
824
+ if (load.ageDays < 1 / 24)
825
+ return '刚刚刷新';
826
+ if (load.ageDays < 1)
827
+ return `${Math.round(load.ageDays * 24)} 小时前`;
828
+ return `${Math.floor(load.ageDays)} 天前`;
829
+ }
830
+ /**
831
+ * 目录状态行 —— 界面/status 都用它 (`目录数据: <日期>` 是**必有**的一段)。
832
+ * 陈旧 (>`CATALOG_STALE_AFTER_DAYS` 天) 时如实标 `⚠ 陈旧` + 刷新指引, 不静默拿旧的当新的。
833
+ */
834
+ function catalogStatusLine(load = snapshot) {
835
+ const s = catalogStats();
836
+ const src = load.source === 'runtime' ? `运行期文件 ${load.path}` : '构建期烘焙数据';
837
+ let line = `目录数据: ${catalogDateText(load)} (${catalogAgeText(load)}) · ${s.providers} 家`
838
+ + ` · 有基址 ${s.withApi} / 无基址 ${s.withoutApi} · 模型 ${s.models} · 源 ${load.provenance.sourceUrl || exports.CATALOG_SOURCE_URL}`
839
+ + ` · ${src}`;
840
+ if (load.stale)
841
+ line += ` ⚠ 陈旧 (超过 ${exports.CATALOG_STALE_AFTER_DAYS} 天) —— 用 /model catalog refresh 拉最新`;
842
+ for (const w of load.warnings)
843
+ line += `\n ⚠ ${w}`;
844
+ return line;
845
+ }
846
+ /** 分组/计数行 (status 里不刷 223 行噪音, 但要说清有哪些家) */
847
+ function catalogGroupLine() {
848
+ const s = catalogStats();
849
+ return `目录分组: openai-compatible ${s.families['openai-compatible']}`
850
+ + ` · anthropic ${s.families.anthropic}`
851
+ + ` · gemini ${s.families.gemini}`
852
+ + ` · special (需专用鉴权, 未支持) ${s.families.special}`
853
+ + ` · 无 api 基址 (需自定义 baseUrl) ${s.noBaseUrl}`;
854
+ }
855
+ /** 一家目录供应商一行 (含不能用时的**如实**标注) */
856
+ function formatCatalogProviderLine(v, opts = {}) {
857
+ const bits = [];
858
+ bits.push(`${v.modelIds.length} models (目录)`);
859
+ if (!v.auth.supported)
860
+ bits.push(`⚠ ${v.auth.note}`);
861
+ else if (opts.envConfigured)
862
+ bits.push(`已配置凭证 (${v.auth.envVar})`);
863
+ else
864
+ bits.push(`未配置 key (${v.auth.envVar || '环境变量名缺失'})`);
865
+ if (!v.hasBaseUrl)
866
+ bits.push('⚠ 目录里无 api 基址 → 需自定义 baseUrl');
867
+ else
868
+ bits.push(`api ${v.api}`);
869
+ bits.push(`族 ${v.family}`);
870
+ const mark = v.auth.supported && v.hasBaseUrl && opts.envConfigured ? '●' : '○';
871
+ return `${mark} ${v.id} · ${bits.join(' · ')}`;
872
+ }
873
+ let liveFactsProbe = null;
874
+ /** 注入/撤掉探针 (发现层调用; 传 `null` = 撤掉) */
875
+ function setLiveFactsProbe(probe) {
876
+ liveFactsProbe = probe;
877
+ }
878
+ let fillScope = null;
879
+ /** 注入 scope (只接一次; 后到的调用不覆盖先到的) */
880
+ function setCatalogFillScope(scope) {
881
+ if (!fillScope)
882
+ fillScope = scope;
883
+ }
884
+ /** 本层现在会不会回答这一家 (排障/门用; 纯同步) */
885
+ function catalogAnswersProvider(provider) {
886
+ if (!fillScope)
887
+ return false;
888
+ const id = String(provider || '').trim();
889
+ if (!id)
890
+ return false;
891
+ if (fillScope.builtinIds.includes(id))
892
+ return false;
893
+ return fillScope.kindOf(id) === 'catalog';
894
+ }
895
+ /**
896
+ * 目录的元数据填充点。
897
+ *
898
+ * 只回答**目录里字面声明过**的项:
899
+ * · `origin`: 这个模型真在目录里 → `'catalog'`;
900
+ * · `displayName` / `toolCalling` / `reasoning` / `contextLength`: 目录里写了才填, 没写就是 `unknown`
901
+ * (填充点返回的字段越少, 合成出的就是 `unknown` —— 这里**绝不**替供应商猜);
902
+ * · 真目录层 (live/cached) 已经覆盖的字段**不填** (让发现层的真事实答, 那边更新)。
903
+ *
904
+ * 内置 13 家与用户自定义的家**不走这条路** (由上面的 `CatalogFillScope` 挡掉) —— 它们的行为不许变。
905
+ */
906
+ function providerCatalogMetadataSource() {
907
+ return {
908
+ id: exports.CATALOG_METADATA_SOURCE_ID,
909
+ metadataOf: ({ provider, model }) => {
910
+ if (!catalogAnswersProvider(provider))
911
+ return undefined;
912
+ const view = getCatalogProvider(provider);
913
+ if (!view)
914
+ return undefined;
915
+ const facts = view.models[String(model || '')];
916
+ const live = liveFactsProbe ? (liveFactsProbe(provider, model) || undefined) : undefined;
917
+ const out = {};
918
+ if (facts) {
919
+ if (facts.name && !live?.displayName)
920
+ out.displayName = facts.name;
921
+ if (facts.toolCalling !== undefined && !live?.toolCalling)
922
+ out.toolCalling = facts.toolCalling ? 'yes' : 'no';
923
+ if (facts.reasoning !== undefined && !live?.reasoning)
924
+ out.reasoning = facts.reasoning ? 'yes' : 'no';
925
+ if (facts.contextLength !== undefined && typeof live?.contextLength !== 'number')
926
+ out.contextLength = facts.contextLength;
927
+ if (!live?.origin)
928
+ out.origin = 'catalog';
929
+ }
930
+ return Object.keys(out).length ? out : undefined;
931
+ },
932
+ };
933
+ }
934
+ let catalogSourceWired = false;
935
+ /** 显式接线 (测试里 `resetModelMetadataSources()` 之后靠它接回去; 必须先给 scope) */
936
+ function registerProviderCatalogMetadataSource(scope) {
937
+ if (scope)
938
+ setCatalogFillScope(scope);
939
+ if (!fillScope)
940
+ return; // 不知道范围就不接 (fail closed, 见 CatalogFillScope 注释)
941
+ (0, model_catalog_js_1.registerModelMetadataSource)(providerCatalogMetadataSource());
942
+ catalogSourceWired = true;
943
+ }
944
+ /**
945
+ * 自动接线 (幂等; 由注册表读口在调用时拉起来, 不放在模块体避免 import 顺序问题)。
946
+ * 必须带上 scope —— 见 `CatalogFillScope`: 不知道"哪些家归本层答"就会改到内置/自定义的行为。
947
+ */
948
+ function ensureProviderCatalogMetadataSource(scope) {
949
+ if (catalogSourceWired)
950
+ return;
951
+ registerProviderCatalogMetadataSource(scope);
952
+ }
953
+ /** 本层填充点现在有没有接上 (排障/门用) */
954
+ function isProviderCatalogMetadataSourceWired() {
955
+ return (0, model_catalog_js_1.listModelMetadataSources)().includes(exports.CATALOG_METADATA_SOURCE_ID);
956
+ }
957
+ /** 供排障: 当前快照的家数 (同步) */
958
+ function catalogProviderCount() {
959
+ return views.length;
960
+ }
961
+ /** 供排障: 配置目录里那个目录文件的路径 (不一定存在; 同步能算时给) */
962
+ function defaultCatalogPathHint() {
963
+ if (runtimePath)
964
+ return runtimePath;
965
+ const home = process.env.BOLLOON_HOME?.trim();
966
+ const dir = home || path.join(process.env.HOME || os.homedir() || '/tmp', '.bolloon');
967
+ return path.join(dir, exports.CATALOG_FILE_NAME);
968
+ }
969
+ //# sourceMappingURL=provider-catalog.js.map