@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,917 @@
1
+ /**
2
+ * provider-registry.ts — 供应商注册表 + 兼容协议 (P3, 2026-09-26)
3
+ *
4
+ * ## 这一层解决什么
5
+ *
6
+ * 此前「供应商」只是一串写死的名字 (`config-store.ModelProvider` 联合类型 + 几处各自的 switch),
7
+ * 于是每加一家就要改类型、改 switch、改白名单, 而且**谁也不知道某一家到底支不支持工具调用 /
8
+ * reasoning / 能不能当长期任务执行器** —— 这些判定此前散落在各处的 `if` 里, 甚至根本不存在。
9
+ *
10
+ * 本文件把它们收成两层:
11
+ *
12
+ * ① **注册表 (registry)**: 每个供应商一条**逐项有出处**的记录 —— 默认 URL · API key 环境变量 ·
13
+ * 默认模型 · 模型发现方式 · 认证方式 · 是否要 key · 工具调用 · reasoning · 是否本地 ·
14
+ * 是否允许作为长期任务执行器。字段值**不在这里编**, 能从既有真表派生的就派生
15
+ * (`config-store` 的默认表 / `model-selection` 的协议与鉴权表 / `model-catalog` 的本地判定),
16
+ * 派生不到的才允许是新事实, 且必须写明证据 (`toolCallingEvidence`)。
17
+ * ② **兼容协议 (protocol)**: `openai-compatible|anthropic|gemini|ollama` 四条线上协议。
18
+ * 自定义供应商**只声明协议**, 不上改 TS 联合类型 —— 运行时把它接到说同一种协议的内置分支上
19
+ * (`runtimeProviderIdOf`)。
20
+ *
21
+ * ## 与既有表的关系 (不许两处真相)
22
+ *
23
+ * | 字段 | 真出处 |
24
+ * | --- | --- |
25
+ * | `defaultBaseUrl` / `defaultModel` | `config-store.DEFAULT_PROVIDER_CONFIGS` (调用时读) |
26
+ * | `apiKeyEnvVars` | `model-selection.envKeyNamesOf` |
27
+ * | `protocol` | `model-selection.protocolOf` |
28
+ * | `reasoning` | `model-selection.supportsReasoning` (不在集合里 → `unknown`, **不写 `no`**) |
29
+ * | `requiresApiKey` | `config-store.PROVIDER_INFO` (P2 已冻结"注册表是这家的主来源") |
30
+ * | `isLocal` | `model-catalog.isLocalBaseUrl` (base URL 主机名真判定) |
31
+ * | `baseUrlEnvVars` | 客户端取 base URL 的那张表 (`pi-ai.ts`) |
32
+ * | `toolCalling` / `discovery` / `auth` | **本文件的新事实**, 证据写在 `toolCallingEvidence` / 表注释 |
33
+ *
34
+ * 逐项相等由门禁钉住 (`src/test/provider-registry.test.ts` 的源码级门 + `scripts/verify-provider-registry.ts`,
35
+ * 漂移即判红), 所以"派生"不是口头承诺。
36
+ *
37
+ * ## 元数据填充点
38
+ *
39
+ * 本文件向 P2 冻结的元数据接口注册一个填充点 (`id = 'provider-registry'`), **只回答自己有真值的项**:
40
+ *
41
+ * - `requiresApiKey`: 注册表维度的事实 → 填;
42
+ * - `origin`: 只有**自定义供应商**填 `'custom'` (它的模型清单来自用户声明)。内置供应商不填 ——
43
+ * `model-catalog.curatedOriginOf` 已经在回答, 这里覆盖它等于在没有新证据的情况下改别人那层的结论;
44
+ * - `toolCalling` / `reasoning` / `contextLength`: **只有**自定义供应商**显式声明**过的才填。
45
+ * 内置供应商一个都不填 —— 内置注册表只有**供应商级**能力, 模型级能力只有真目录 (P5) 能给,
46
+ * 拿供应商级数据装成模型级就是 P2 明令禁止的"编一个看起来像真的值"。
47
+ *
48
+ * 填充点必须**纯同步** (P2 冻结约定): 所以自定义供应商在进程内只以**已读到的快照**存在
49
+ * (`setCustomProviderSnapshot`)。快照由 `config-store.initialize()` (读配置后) 与
50
+ * `custom-provider-store` (每次读写后) 推入; 本文件**不**在这里读盘、不发请求。
51
+ */
52
+ import { DEFAULT_PROVIDER_CONFIGS, PROVIDER_INFO } from './config-store.js';
53
+ import { protocolOf, supportsReasoning, envKeyNamesOf, normalizeBaseUrl, } from './model-selection.js';
54
+ import { registerModelMetadataSource, isLocalBaseUrl, } from './model-catalog.js';
55
+ // 2026-09-27 (目录驱动): 目录里的家也进注册表。
56
+ // 方向是 registry → provider-catalog (**单向**, 不成环: provider-catalog 不 import 本文件,
57
+ // 它靠发现层注入的探针拿"真目录是否已覆盖这条模型")。
58
+ // 内置 13 家仍然由上面的内置表逐个派生, **一个字都不改**; 目录只在"内置表里没有这个 id"时回答。
59
+ import { catalogLoad, getCatalogProvider, catalogProviderIds, ensureProviderCatalogMetadataSource, } from './provider-catalog.js';
60
+ /**
61
+ * 注册表**必须提供**的能力字段 (P3 验收逐项对上这里; 少一项就算没做完)。
62
+ * 顺序 = 计划里的顺序。名字与 `ProviderRegistryEntry` 的键一致。
63
+ */
64
+ export const REQUIRED_REGISTRY_FIELDS = [
65
+ 'defaultBaseUrl', // 默认 URL
66
+ 'apiKeyEnvVars', // API key 环境变量
67
+ 'defaultModel', // 默认模型
68
+ 'discovery', // 模型发现方式
69
+ 'auth', // 认证方式
70
+ 'toolCalling', // 是否支持工具调用
71
+ 'reasoning', // 是否支持 reasoning
72
+ 'isLocal', // 是否本地
73
+ 'allowsLongRunningExecutor', // 是否允许作为长期任务执行器
74
+ ];
75
+ // ============================================================
76
+ // 新事实 (1): 谁能发原生工具调用
77
+ // ============================================================
78
+ /**
79
+ * 本运行时会**真的把原生 tools 发出去**的供应商。
80
+ *
81
+ * 真出处: 客户端的路由表 —— `generateText()` 的 switch 里, 只有指向
82
+ * `callOpenAI(..., openaiTools)` 的那条分支收得到工具数组; 其余分支这条签名里根本没有 tools。
83
+ * 所以这个集合不是"猜哪些厂商支持工具", 而是**这条运行路径上工具调用真的能通吗**。
84
+ *
85
+ * 门禁 (`src/test/provider-registry.test.ts`) 会去 parse 那张路由表, 双向断言集合相等:
86
+ * 客户端加了新分支 / 撤了旧分支, 而没同步这里 → **判红**。
87
+ */
88
+ export const NATIVE_TOOL_CALLING_IDS = [
89
+ 'openai', 'minimax', 'deepseek', 'kimi', 'glm', 'qwen', 'mimo', 'grok',
90
+ ];
91
+ const NATIVE_TOOL_EVIDENCE = '本运行时把原生 tools 交给 callOpenAI 分支 (客户端 generateText 路由表), 工具调用真能通';
92
+ const NO_NATIVE_TOOL_EVIDENCE = '本运行时的这条协议分支不接收原生 tools (客户端 generateText 路由表) → 工具调用发不出去; '
93
+ + '这是**运行时口径**, 不等于供应商 API 本身不支持工具';
94
+ // ============================================================
95
+ // 新事实 (2): 协议 → 发现方式 / 认证方式
96
+ // ============================================================
97
+ /**
98
+ * 协议 → 默认发现方式。
99
+ *
100
+ * 出处: 探活/取目录用的是 `config-store.buildTestRequest` 那张表 —— openai 兼容与 openrouter 走
101
+ * `GET <base>/models`, gemini 走 `<base>/models?key=`, ollama/local 走 `GET <base>/api/tags`。
102
+ */
103
+ export function discoveryOfProtocol(protocol) {
104
+ switch (protocol) {
105
+ case 'gemini': return 'gemini-models';
106
+ case 'ollama': return 'ollama-tags';
107
+ // anthropic 没有可用的 GET /v1/models (见下方覆盖表的理由) → 只能手工填 model ID
108
+ case 'anthropic': return 'manual';
109
+ case 'openai-compatible':
110
+ default: return 'openai-models';
111
+ }
112
+ }
113
+ /**
114
+ * 逐家覆盖 (只在"协议默认说法不成立"时才允许出现一条, 且必须写理由)。
115
+ *
116
+ * - `anthropic`: 仓库里已有的实测结论是 **没有可用的 `GET /v1/models`** (v0.2.15 的探活注释:
117
+ * "Anthropic has no GET /v1/models endpoint -> always 404"), 所以这家只能走**手工 model ID**。
118
+ * 这不是"懒得接", 是已知端点的真结论。
119
+ */
120
+ const DISCOVERY_OVERRIDES = {
121
+ anthropic: {
122
+ discovery: 'manual',
123
+ reason: '本仓实测: 没有可用的 GET /v1/models (探活表注释 v0.2.15) → 只能手工填 model ID',
124
+ },
125
+ };
126
+ /** 协议 → 默认认证方式 (出处: 取 base URL/鉴权头的那两张真表: config-store.buildHeaders + 客户端分支) */
127
+ export function authOfProtocol(protocol) {
128
+ switch (protocol) {
129
+ case 'anthropic':
130
+ return { kind: 'x-api-key', header: 'x-api-key' };
131
+ case 'gemini':
132
+ // 客户端 chat 分支用 `?key=`; 探活分支用 `x-goog-api-key` 头 —— 两个都写出来, 由调用方选
133
+ return { kind: 'query-key', header: 'x-goog-api-key', query: 'key' };
134
+ case 'ollama':
135
+ return { kind: 'none' };
136
+ case 'openai-compatible':
137
+ default:
138
+ return { kind: 'bearer', header: 'Authorization', scheme: 'Bearer' };
139
+ }
140
+ }
141
+ // ============================================================
142
+ // 内置优先保障清单
143
+ // ============================================================
144
+ /** 计划里点名要优先保障的那些 (只影响展示顺序, 不参与任何判定) */
145
+ export const PRIORITY_BUILTIN_IDS = [
146
+ 'openai', 'anthropic', 'gemini', 'deepseek', 'minimax', 'kimi', 'qwen', 'glm', 'openrouter', 'ollama', 'grok',
147
+ ];
148
+ /** 除优先清单外还在册的内置供应商 (`local` = 本地端点的另一个 id, `mimo` = 小米 MiMo) */
149
+ export const EXTRA_BUILTIN_IDS = ['mimo', 'local'];
150
+ /**
151
+ * 覆盖 base URL 的环境变量名 (真出处: 客户端取 base URL 的表)。
152
+ * 只列真有环境变量覆盖的那几家; 其余家只能靠显式 `--base-url` 或配置里的 baseUrl。
153
+ */
154
+ const BASE_URL_ENV_VARS = {
155
+ openai: ['OPENAI_BASE_URL'],
156
+ openrouter: ['OPENROUTER_BASE_URL'],
157
+ minimax: ['MINIMAX_BASE_URL'],
158
+ deepseek: ['DEEPSEEK_BASE_URL'],
159
+ kimi: ['KIMI_BASE_URL', 'MOONSHOT_BASE_URL'],
160
+ glm: ['GLM_BASE_URL', 'ZHIPU_BASE_URL'],
161
+ qwen: ['QWEN_BASE_URL', 'DASHSCOPE_BASE_URL'],
162
+ mimo: ['MIMO_BASE_URL'],
163
+ grok: ['XAI_BASE_URL'],
164
+ ollama: ['OLLAMA_BASE_URL'],
165
+ };
166
+ /** 在册的内置供应商 id (顺序 = 既有内置表顺序; 不参与判定) */
167
+ export function listBuiltinProviderIds() {
168
+ return Object.keys(DEFAULT_PROVIDER_CONFIGS);
169
+ }
170
+ /** 这个 id 是不是内置供应商 */
171
+ export function isBuiltinProvider(id) {
172
+ return Object.prototype.hasOwnProperty.call(DEFAULT_PROVIDER_CONFIGS, id);
173
+ }
174
+ // ============================================================
175
+ // 条目构造 (内置: 除 toolCalling/discovery/auth 外全部派生)
176
+ // ============================================================
177
+ const PROVENANCE = {
178
+ defaultBaseUrl: 'config-store.DEFAULT_PROVIDER_CONFIGS.baseUrl (内置默认表)',
179
+ defaultModel: 'config-store.DEFAULT_PROVIDER_CONFIGS.model (内置默认表)',
180
+ apiKeyEnvVars: 'model-selection.envKeyNamesOf (环境变量凭据表)',
181
+ protocol: 'model-selection.protocolOf (协议表)',
182
+ requiresApiKey: 'config-store.PROVIDER_INFO.requiresApiKey (P2 冻结: 注册表是主来源)',
183
+ reasoning: 'model-selection.supportsReasoning (不在集合里 → unknown, 不写 no)',
184
+ isLocal: 'model-catalog.isLocalBaseUrl (base URL 主机名真判定)',
185
+ baseUrlEnvVars: '客户端取 base URL 的表 (pi-ai.ts getBaseUrl)',
186
+ discovery: 'config-store.buildTestRequest 的端点表 + 逐家覆盖表',
187
+ auth: 'config-store.buildHeaders / 客户端取 base URL 与鉴权分支',
188
+ toolCalling: '本文件 NATIVE_TOOL_CALLING_IDS (客户端 generateText 路由表)',
189
+ allowsLongRunningExecutor: '本文件判定: 需要 toolCalling=yes (工具调用发不出去的通道不能跑长期任务)',
190
+ };
191
+ function longRunningVerdict(toolCalling, evidence, extraReason) {
192
+ if (toolCalling === 'yes') {
193
+ return { ok: true, reason: '允许: 该分支会发出原生工具调用 (工具调用=支持)' };
194
+ }
195
+ const why = toolCalling === 'no'
196
+ ? '工具调用=不支持 (这条协议分支发不出原生 tools)'
197
+ : '工具调用=未知 (没有真来源 → 不拿未知当可用)';
198
+ return { ok: false, reason: `不允许: ${why}${extraReason ? `; ${extraReason}` : ''} (证据: ${evidence})` };
199
+ }
200
+ /** 内置供应商的一条注册表记录 (`id` 不在内置表里 → `undefined`, 不编一条出来) */
201
+ export function builtinProviderEntry(id) {
202
+ const def = DEFAULT_PROVIDER_CONFIGS[id];
203
+ if (!def)
204
+ return undefined;
205
+ const info = PROVIDER_INFO[id];
206
+ const protocol = protocolOf(id);
207
+ const baseUrl = normalizeBaseUrl(def.baseUrl);
208
+ const discovery = DISCOVERY_OVERRIDES[id]?.discovery ?? discoveryOfProtocol(protocol);
209
+ const toolCalling = NATIVE_TOOL_CALLING_IDS.includes(id) ? 'yes' : 'no';
210
+ const evidence = toolCalling === 'yes' ? NATIVE_TOOL_EVIDENCE : NO_NATIVE_TOOL_EVIDENCE;
211
+ const verdict = longRunningVerdict(toolCalling, evidence);
212
+ const entry = {
213
+ id,
214
+ displayName: String(info?.name || id),
215
+ kind: 'builtin',
216
+ origin: 'builtin',
217
+ protocol,
218
+ defaultBaseUrl: baseUrl,
219
+ defaultModel: String(def.model || ''),
220
+ apiKeyEnvVars: envKeyNamesOf(id),
221
+ baseUrlEnvVars: BASE_URL_ENV_VARS[id] || [],
222
+ discovery,
223
+ modelsEndpoint: '',
224
+ auth: authOfProtocol(protocol),
225
+ requiresApiKey: info?.requiresApiKey !== false,
226
+ toolCalling,
227
+ reasoning: supportsReasoning(id) ? 'yes' : 'unknown',
228
+ declaredModelIds: declaredModelIdsOf(id),
229
+ isLocal: isLocalBaseUrl(baseUrl),
230
+ allowsLongRunningExecutor: verdict.ok,
231
+ longRunningReason: verdict.reason,
232
+ toolCallingEvidence: evidence,
233
+ authSupported: true,
234
+ authSupportNote: null,
235
+ catalogFamilySignal: null,
236
+ provenance: { ...PROVENANCE },
237
+ };
238
+ entry.modelsEndpoint = modelsEndpointOf(entry);
239
+ return entry;
240
+ }
241
+ // ============================================================
242
+ // 自定义供应商 → 条目
243
+ // ============================================================
244
+ /** 自定义供应商的能力一栏永远来自声明; 没声明就是 `unknown` (不补默认值) */
245
+ function customEntry(id, spec) {
246
+ const protocol = spec.protocol;
247
+ const baseUrl = normalizeBaseUrl(spec.baseUrl);
248
+ const declared = Array.isArray(spec.models) ? spec.models.filter((m) => typeof m === 'string' && m.trim()) : [];
249
+ const cap = spec.capabilities || {};
250
+ const toolCalling = cap.toolCalling ?? 'unknown';
251
+ const reasoning = cap.reasoning ?? 'unknown';
252
+ const localOnly = protocol === 'ollama' || isLocalBaseUrl(baseUrl);
253
+ const auth = spec.authHeader
254
+ ? {
255
+ kind: 'custom',
256
+ header: spec.authHeader.trim(),
257
+ // Authorization 是唯一有通用约定的头 (`Bearer <key>`); 其余自定义头按原值放
258
+ scheme: /^authorization$/i.test(spec.authHeader.trim()) ? 'Bearer' : undefined,
259
+ }
260
+ : authOfProtocol(protocol);
261
+ const evidence = toolCalling === 'yes'
262
+ ? '自定义供应商显式声明了 capabilities.toolCalling=yes'
263
+ : toolCalling === 'no'
264
+ ? '自定义供应商显式声明了 capabilities.toolCalling=no'
265
+ : '自定义供应商没有声明 capabilities.toolCalling → 未知 (不拿未知当可用)';
266
+ // 长期任务: 既要"声明支持工具调用", 也要协议本身是本运行时会发原生 tools 的那条
267
+ const openaiCompatible = protocol === 'openai-compatible';
268
+ let verdict;
269
+ if (toolCalling === 'yes' && openaiCompatible) {
270
+ verdict = { ok: true, reason: '允许: 声明 capabilities.toolCalling=yes 且协议是 openai-compatible (本运行时该分支发原生 tools)' };
271
+ }
272
+ else if (toolCalling === 'yes' && !openaiCompatible) {
273
+ verdict = { ok: false, reason: `不允许: 声明了工具调用能力, 但协议 ${protocol} 的分支不发原生 tools (证据: ${evidence})` };
274
+ }
275
+ else if (toolCalling === 'no') {
276
+ verdict = { ok: false, reason: `不允许: 工具调用=不支持 (自定义供应商显式声明); 证据: ${evidence}` };
277
+ }
278
+ else {
279
+ verdict = { ok: false, reason: `不允许: 工具调用=未知 (自定义供应商没有声明 capabilities.toolCalling, 不拿未知当可用)` };
280
+ }
281
+ const entry = {
282
+ id,
283
+ displayName: spec.displayName || id,
284
+ kind: 'custom',
285
+ origin: 'custom',
286
+ protocol,
287
+ defaultBaseUrl: baseUrl,
288
+ defaultModel: String(spec.model || ''),
289
+ apiKeyEnvVars: spec.apiKeyEnvVar ? [spec.apiKeyEnvVar.trim()] : [],
290
+ baseUrlEnvVars: [],
291
+ discovery: discoveryOfProtocol(protocol),
292
+ modelsEndpoint: '',
293
+ auth,
294
+ requiresApiKey: !localOnly,
295
+ toolCalling,
296
+ reasoning,
297
+ declaredModelIds: [...declared],
298
+ isLocal: isLocalBaseUrl(baseUrl),
299
+ allowsLongRunningExecutor: verdict.ok,
300
+ longRunningReason: verdict.reason,
301
+ toolCallingEvidence: evidence,
302
+ authSupported: true,
303
+ authSupportNote: null,
304
+ catalogFamilySignal: null,
305
+ provenance: {
306
+ defaultBaseUrl: '自定义供应商声明 (baseUrl)',
307
+ defaultModel: '自定义供应商声明 (model)',
308
+ apiKeyEnvVars: spec.apiKeyEnvVar ? '自定义供应商声明 (apiKeyEnvVar)' : '未声明 → 空 (只认配置/环境变量回退)',
309
+ protocol: '自定义供应商声明 (protocol)',
310
+ requiresApiKey: localOnly ? '本地端点或 ollama 协议 → 免 key (base URL 主机名真判定)' : '声明了远端地址 → 要 key',
311
+ reasoning: cap.reasoning ? '自定义供应商声明 (capabilities.reasoning)' : '未声明 → unknown',
312
+ isLocal: 'model-catalog.isLocalBaseUrl (base URL 主机名真判定)',
313
+ baseUrlEnvVars: '未声明 → 空 (自定义供应商不走内置环境变量覆盖)',
314
+ discovery: '按声明的协议取默认发现方式 (discoveryOfProtocol)',
315
+ auth: spec.authHeader ? '自定义供应商声明 (authHeader)' : '按声明的协议取默认认证方式 (authOfProtocol)',
316
+ toolCalling: '自定义供应商声明 (capabilities.toolCalling)',
317
+ allowsLongRunningExecutor: '本文件判定: 需要声明 toolCalling=yes 且协议是 openai-compatible',
318
+ },
319
+ };
320
+ entry.modelsEndpoint = modelsEndpointOf(entry, spec.modelsEndpoint);
321
+ return entry;
322
+ }
323
+ // ============================================================
324
+ // 目录供应商 → 条目 (origin='catalog'; 内置同名项优先, 这条只在"内置表里没有这个 id"时用)
325
+ // ============================================================
326
+ /**
327
+ * 目录填充点的**范围**: 哪些家归目录层答 (`kind === 'catalog'`), 内置名单是谁。
328
+ * 方向 registry → catalog (单向)。理由见 `provider-catalog.CatalogFillScope` 的注释:
329
+ * 不知道范围就答, 会把内置 13 家与自定义家的 `unknown` 悄悄换成目录里的值 —— 那是改行为。
330
+ */
331
+ function catalogFillScope() {
332
+ return {
333
+ builtinIds: [...Object.keys(DEFAULT_PROVIDER_CONFIGS)],
334
+ kindOf: (id) => {
335
+ if (isBuiltinProvider(id))
336
+ return 'builtin';
337
+ if (customSnapshot[id])
338
+ return 'custom';
339
+ if (getCatalogProvider(id))
340
+ return 'catalog';
341
+ return undefined;
342
+ },
343
+ };
344
+ }
345
+ /** 目录族 → 线上协议 (`special` 族本运行时不发请求, 这个值只是"最贴近的线上形状") */
346
+ const CATALOG_PROTOCOL_BY_FAMILY = {
347
+ 'openai-compatible': 'openai-compatible',
348
+ anthropic: 'anthropic',
349
+ gemini: 'gemini',
350
+ special: 'openai-compatible',
351
+ };
352
+ const CATALOG_TOOL_EVIDENCE_COMPATIBLE = '运行期接到 openai 分支 (runtimeProviderIdOf: 非内置 + openai-compatible → \'openai\'), 该分支发原生 tools'
353
+ + ' —— 这是**运行时口径**, 不等于这家所有模型都支持工具 (模型级看目录里字面声明的 tool_call)';
354
+ const CATALOG_TOOL_EVIDENCE_OTHER = '本运行时的这条协议分支不接收原生 tools (客户端 generateText 路由表) → 工具调用发不出去; '
355
+ + '这是**运行时口径**, 不等于供应商 API 本身不支持工具';
356
+ const CATALOG_TOOL_EVIDENCE_SPECIAL = '本运行时不支持这家的鉴权形状 (需专用鉴权) → 请求根本发不出去, 工具调用无从谈起';
357
+ /** 目录家**供应商级** reasoning: 只有目录里字面声明过才给结论, 没声明就是 `unknown` */
358
+ function catalogProviderReasoning(v) {
359
+ const facts = Object.values(v.models);
360
+ if (!facts.length)
361
+ return 'unknown';
362
+ if (facts.some((f) => f.reasoning === true))
363
+ return 'yes';
364
+ if (facts.every((f) => f.reasoning === false))
365
+ return 'no';
366
+ return 'unknown';
367
+ }
368
+ /**
369
+ * 目录里的一家 → 注册表条目 (目录驱动: 新家只要在目录里就自动有记录, **不改代码**)。
370
+ *
371
+ * 三条诚实边界写在这里:
372
+ * ① `defaultBaseUrl` 只用目录里的 `api`; 目录没给就是**空串** (调用方要如实报"需自定义 baseUrl");
373
+ * ② 鉴权判成 `special` 的家 → `authSupported=false` + `authSupportNote` 里写清为什么, 不许当可用;
374
+ * ③ `toolCalling` 是**运行时口径** (这条分支发不发得出原生 tools), **不是**模型能力数据。
375
+ *
376
+ * 不在这里编: 默认模型 (目录里没有"默认模型"这回事 → 空串), base URL 的环境变量覆盖名 (目录没说 → 空数组)。
377
+ */
378
+ export function catalogProviderEntry(id) {
379
+ const v = getCatalogProvider(id);
380
+ if (!v)
381
+ return undefined;
382
+ ensureProviderCatalogMetadataSource(catalogFillScope());
383
+ const load = catalogLoad();
384
+ const family = v.family;
385
+ const protocol = CATALOG_PROTOCOL_BY_FAMILY[family];
386
+ const auth = family === 'special'
387
+ ? { kind: 'special' }
388
+ : v.auth.kind === 'x-api-key'
389
+ ? { kind: 'x-api-key', header: 'x-api-key' }
390
+ : v.auth.kind === 'x-goog-api-key'
391
+ ? { kind: 'query-key', header: 'x-goog-api-key', query: 'key' }
392
+ : v.auth.kind === 'none'
393
+ ? { kind: 'none' }
394
+ : { kind: 'bearer', header: 'Authorization', scheme: 'Bearer' };
395
+ const toolCalling = family === 'special'
396
+ ? 'no'
397
+ : (family === 'openai-compatible' ? 'yes' : 'no');
398
+ const evidence = family === 'special'
399
+ ? CATALOG_TOOL_EVIDENCE_SPECIAL
400
+ : (family === 'openai-compatible' ? CATALOG_TOOL_EVIDENCE_COMPATIBLE : CATALOG_TOOL_EVIDENCE_OTHER);
401
+ const verdict = longRunningVerdict(toolCalling, evidence, family === 'special' ? v.auth.note : undefined);
402
+ const requiresApiKey = true; // 目录家都有"要一个 key"这件事 (special 家要的是专用凭证)
403
+ const entry = {
404
+ id: v.id,
405
+ displayName: v.name || v.id,
406
+ kind: 'catalog',
407
+ origin: 'catalog',
408
+ protocol,
409
+ defaultBaseUrl: normalizeBaseUrl(v.api),
410
+ defaultModel: '',
411
+ apiKeyEnvVars: [...v.env],
412
+ baseUrlEnvVars: [],
413
+ discovery: family === 'special' ? 'manual' : discoveryOfProtocol(protocol),
414
+ modelsEndpoint: '',
415
+ auth,
416
+ requiresApiKey,
417
+ toolCalling,
418
+ reasoning: catalogProviderReasoning(v),
419
+ declaredModelIds: [...v.modelIds],
420
+ isLocal: isLocalBaseUrl(v.api),
421
+ allowsLongRunningExecutor: verdict.ok,
422
+ longRunningReason: verdict.reason,
423
+ toolCallingEvidence: evidence,
424
+ authSupported: v.auth.supported,
425
+ authSupportNote: v.auth.supported ? null : v.auth.note,
426
+ catalogFamilySignal: v.familySignal,
427
+ provenance: {
428
+ defaultBaseUrl: v.api
429
+ ? `目录 api 基址 (${load.provenance.sourceUrl || '公开源'} 快照, 生成于 ${load.generatedAt || '未知时间'})`
430
+ : '目录里没有 api 基址 → 空 (不许编一个; 需自定义 baseUrl)',
431
+ defaultModel: '目录里没有"默认模型"这回事 → 空 (用 /model <provider> <model> 指定)',
432
+ apiKeyEnvVars: `目录 env 名 (${load.provenance.sourceUrl || '公开源'}; 只存名字, 永不取值)`,
433
+ protocol: `按目录 npm/api 形状推得的协议族 (provider-catalog.familyOfProvider; 信号: ${v.familySignal})`,
434
+ requiresApiKey: '目录给了环境变量名 → 要一个 key (special 家要的是专用凭证)',
435
+ reasoning: '目录里逐模型字面声明的 reasoning (任一 true → yes; 全 false → no; 没声明 → unknown)',
436
+ isLocal: 'model-catalog.isLocalBaseUrl (base URL 主机名真判定)',
437
+ baseUrlEnvVars: '目录没说哪个环境变量能覆盖基址 → 空 (不编; 要用别的地址就 --base-url 或写配置)',
438
+ discovery: '按协议族取默认发现方式 (special 族 → manual: 本运行时不发它的请求)',
439
+ auth: `目录鉴权形状 (${v.auth.kind}): ${v.auth.note}`,
440
+ toolCalling: '本文件: 运行期路由 (runtimeProviderIdOf) + 鉴权是否受支持',
441
+ allowsLongRunningExecutor: '本文件判定: 需要 toolCalling=yes (工具调用发不出去的通道不能跑长期任务)',
442
+ },
443
+ };
444
+ entry.modelsEndpoint = modelsEndpointOf(entry);
445
+ return entry;
446
+ }
447
+ // ============================================================
448
+ // 自定义供应商快照 (纯同步填充点的唯一数据源)
449
+ // ============================================================
450
+ let customSnapshot = {};
451
+ /**
452
+ * 覆盖进程内的自定义供应商快照。
453
+ *
454
+ * 只允许两处推: `config-store.initialize()` (读配置之后) 与 `custom-provider-store` (每次读写之后)。
455
+ * 这样元数据填充点永远是纯同步的 (P2 冻结约定), 又不会出现"注册表里还是上一版"的陈旧结论。
456
+ */
457
+ export function setCustomProviderSnapshot(map) {
458
+ customSnapshot = map && typeof map === 'object' ? map : {};
459
+ }
460
+ /** 当前快照 (排障/展示用; 不含任何网络或磁盘读) */
461
+ export function customProviderSnapshot() {
462
+ return customSnapshot;
463
+ }
464
+ /** 单条自定义供应商的**声明** (拿不到 → undefined; 不编) */
465
+ export function customProviderSpecOf(id) {
466
+ return customSnapshot[id];
467
+ }
468
+ // ============================================================
469
+ // 注册表读口
470
+ // ============================================================
471
+ /** 自定义供应商的一条记录 (`spec` 不在快照里也要能构造 —— 由调用方给声明) */
472
+ export function customProviderEntryOf(spec) {
473
+ return customEntry(spec.providerId, spec);
474
+ }
475
+ /** 全部在册供应商 (内置优先清单在前, 其余内置居中, 自定义在后; 顺序不参与判定) */
476
+ export function listProviderRegistry() {
477
+ const order = [...PRIORITY_BUILTIN_IDS, ...EXTRA_BUILTIN_IDS];
478
+ const builtins = listBuiltinProviderIds();
479
+ const seen = new Set();
480
+ const ids = [];
481
+ for (const id of [...order, ...builtins]) {
482
+ if (!seen.has(id) && isBuiltinProvider(id)) {
483
+ seen.add(id);
484
+ ids.push(id);
485
+ }
486
+ }
487
+ const out = ids.map((id) => builtinProviderEntry(id)).filter(Boolean);
488
+ for (const spec of Object.values(customSnapshot))
489
+ out.push(customEntry(spec.providerId, spec));
490
+ return out;
491
+ }
492
+ /**
493
+ * 一个 id 的记录 (内置 → 自定义快照 → **目录**; 都没有 → `undefined`)。
494
+ *
495
+ * 2026-09-27 (目录驱动): 目录里的家 (223 家) 也在这里回答 —— 于是 `registryEntryOf` /
496
+ * `validateSelection` / `resolveDiscoveryTarget` 不用改就能认识"目录里新出现的一家"。
497
+ * **内置 13 家优先**: 同名目录项永远不会被走到 (内置那条在最前面就返回了)。
498
+ */
499
+ export function getProviderRegistryEntry(id) {
500
+ const bid = builtinProviderEntry(id);
501
+ if (bid)
502
+ return bid;
503
+ const spec = customSnapshot[id];
504
+ if (spec)
505
+ return customEntry(id, spec);
506
+ return catalogProviderEntry(id);
507
+ }
508
+ /**
509
+ * 在册的家 + 目录里的家 (**列表/选择器/文档**读这个: 一条记录都不重复, 内置优先)。
510
+ *
511
+ * 为什么不在 `listProviderRegistry()` 里直接加目录家: 那张表是"**本机在册的**家"
512
+ * (内置 13 + 用户自定义), 有门 (单测 + 真跑门) 钉着它的长度与逐项来源 ——
513
+ * 目录是**另一个数据源** (223 家, 由公开目录决定), 混进同一张表会让"在册"这个说法失去判据。
514
+ * 于是分成两个读口: 在册的 (`listProviderRegistry`) 与 在册+目录 (`listProvidersWithCatalog`)。
515
+ */
516
+ export function listProvidersWithCatalog() {
517
+ const out = listProviderRegistry();
518
+ const seen = new Set(out.map((e) => e.id));
519
+ for (const id of catalogProviderIds()) {
520
+ if (seen.has(id))
521
+ continue; // 内置/自定义优先 (内置同名项按 id 直接跳过)
522
+ const e = catalogProviderEntry(id);
523
+ if (!e)
524
+ continue;
525
+ seen.add(id);
526
+ out.push(e);
527
+ }
528
+ return out;
529
+ }
530
+ /** 这一家是不是**目录里来的** (内置/自定义都不是) */
531
+ export function isCatalogProvider(id) {
532
+ return getCatalogProvider(id) !== undefined && !isBuiltinProvider(id) && !customSnapshot[id];
533
+ }
534
+ /** 这个 id 现在**在册**吗 (内置 / 自定义快照 / 目录里的一家) */
535
+ export function isRegisteredProvider(id) {
536
+ return getProviderRegistryEntry(id) !== undefined;
537
+ }
538
+ /** 协议 (注册表口径; 内置走冻结协议表, 自定义走声明) */
539
+ export function registryProtocolOf(id) {
540
+ return getProviderRegistryEntry(id)?.protocol ?? null;
541
+ }
542
+ /**
543
+ * 运行期 provider id —— **兼容协议的落点**。
544
+ *
545
+ * 自定义供应商不上改联合类型: 它在运行期接到"说同一种协议"的内置分支上 (openai-compatible → openai
546
+ * 那条分支), 于是 `initMinimax({provider: runtimeProviderIdOf(entry), baseUrl, model})` 就能真出网。
547
+ * 内置供应商返回自己 (客户端每条内置 id 都有自己的分支)。
548
+ */
549
+ export function runtimeProviderIdOf(entry) {
550
+ if (entry.kind === 'builtin')
551
+ return entry.id;
552
+ switch (entry.protocol) {
553
+ case 'anthropic': return 'anthropic';
554
+ case 'gemini': return 'gemini';
555
+ case 'ollama': return 'ollama';
556
+ case 'openai-compatible':
557
+ default: return 'openai';
558
+ }
559
+ }
560
+ // ============================================================
561
+ // 端点与鉴权 (调用方拼请求用; **不是**打印用)
562
+ // ============================================================
563
+ /**
564
+ * 模型目录端点 URL。
565
+ *
566
+ * `declaredEndpoint` (自定义供应商声明的 `modelsEndpoint`) 优先: 绝对 URL 原样用, 相对路径拼在
567
+ * `defaultBaseUrl` 后。没有端点可用的 (`discovery='manual'`) 返回空串 —— 不编一个端点出来。
568
+ */
569
+ export function modelsEndpointOf(entry, declaredEndpoint) {
570
+ const base = normalizeBaseUrl(entry.defaultBaseUrl);
571
+ const declared = String(declaredEndpoint || '').trim();
572
+ if (declared) {
573
+ if (/^https?:\/\//i.test(declared))
574
+ return declared;
575
+ if (!base)
576
+ return declared;
577
+ return `${base}/${declared.replace(/^\/+/, '')}`;
578
+ }
579
+ if (!base)
580
+ return '';
581
+ switch (entry.discovery) {
582
+ case 'ollama-tags': return `${base}/api/tags`;
583
+ case 'manual': return '';
584
+ case 'gemini-models':
585
+ case 'openai-models':
586
+ default: return `${base}/models`;
587
+ }
588
+ }
589
+ /**
590
+ * 认证头 / query 参数。
591
+ *
592
+ * 返回值**含凭据**, 只许用于当次请求构造: 不进日志、不进快照、不进报告 (`[REDACTED]`)。
593
+ * `apiKey` 为空 → 返回空 (`kind='none'` 的本地端点本来也不需要)。
594
+ */
595
+ export function authHeadersFor(entry, apiKey) {
596
+ const key = String(apiKey || '').trim();
597
+ const headers = {};
598
+ const query = {};
599
+ if (entry.protocol === 'anthropic')
600
+ headers['anthropic-version'] = '2023-06-01';
601
+ if (!key)
602
+ return { headers, query };
603
+ const auth = entry.auth;
604
+ switch (auth.kind) {
605
+ case 'bearer':
606
+ headers[auth.header || 'Authorization'] = `${auth.scheme || 'Bearer'} ${key}`;
607
+ break;
608
+ case 'x-api-key':
609
+ headers[auth.header || 'x-api-key'] = key;
610
+ break;
611
+ case 'x-goog-api-key':
612
+ headers[auth.header || 'x-goog-api-key'] = key;
613
+ break;
614
+ case 'query-key':
615
+ query[auth.query || 'key'] = key;
616
+ if (auth.header)
617
+ headers[auth.header] = key;
618
+ break;
619
+ case 'custom':
620
+ if (auth.header)
621
+ headers[auth.header] = auth.scheme ? `${auth.scheme} ${key}` : key;
622
+ break;
623
+ case 'none':
624
+ default:
625
+ break;
626
+ }
627
+ return { headers, query };
628
+ }
629
+ // ============================================================
630
+ // 长期任务执行器门
631
+ // ============================================================
632
+ /** 这家现在能不能当长期任务执行器 (后台 Run/Goal 需要真工具调用) */
633
+ export function canServeLongRunningTasks(id) {
634
+ return getProviderRegistryEntry(id)?.allowsLongRunningExecutor === true;
635
+ }
636
+ /** 不能当长期任务执行器的**理由** (可以的时候返回 `null`) */
637
+ export function longRunningRefusalReason(id) {
638
+ const entry = getProviderRegistryEntry(id);
639
+ if (!entry)
640
+ return `不在册的供应商: ${id} (注册表里没有这条)`;
641
+ return entry.allowsLongRunningExecutor ? null : entry.longRunningReason;
642
+ }
643
+ // ============================================================
644
+ // 自定义供应商: 规范化 / 迁移 (纯函数, 不读盘)
645
+ // ============================================================
646
+ const VALID_PROTOCOLS = ['openai-compatible', 'anthropic', 'gemini', 'ollama'];
647
+ /**
648
+ * 从 base URL 的**主机名**推协议 (真判定, 不是白名单)。
649
+ *
650
+ * 用途只有一个: 迁移**旧配置**里那种"只有 baseUrl + model, 没说协议"的自定义端。
651
+ * 判不出来时退到 `openai-compatible` —— 这是绝大多数自建/兼容网关的协议, 且调用方能在
652
+ * `notes` 里看到"协议是推出来的", 不是用户声明的。
653
+ */
654
+ export function inferProtocolFromBaseUrl(raw) {
655
+ const url = normalizeBaseUrl(raw);
656
+ if (!url)
657
+ return 'openai-compatible';
658
+ let host = '';
659
+ let path = '';
660
+ try {
661
+ const u = new URL(url);
662
+ host = u.hostname.toLowerCase();
663
+ path = u.pathname.toLowerCase();
664
+ }
665
+ catch {
666
+ return 'openai-compatible';
667
+ }
668
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0' || host.endsWith('.local')) {
669
+ // 本地端点: 11434 是 ollama 的招牌端口, 其余按兼容网关算
670
+ return /:11434$/.test(url) ? 'ollama' : 'openai-compatible';
671
+ }
672
+ if (host === 'api.anthropic.com' || host.endsWith('.anthropic.com'))
673
+ return 'anthropic';
674
+ if (host === 'generativelanguage.googleapis.com')
675
+ return 'gemini';
676
+ if (path.includes('/api/paas/'))
677
+ return 'openai-compatible'; // 智谱那种 path 后缀不改协议
678
+ return 'openai-compatible';
679
+ }
680
+ /** 是不是合法的自定义供应商 id (不许与内置撞名, 不许空/带斜杠) */
681
+ export function isValidCustomProviderId(id) {
682
+ const v = String(id || '').trim();
683
+ if (!v)
684
+ return false;
685
+ if (isBuiltinProvider(v))
686
+ return false;
687
+ return /^[a-z0-9][a-z0-9._-]*$/i.test(v);
688
+ }
689
+ /**
690
+ * 规范化**一条**自定义供应商声明 (纯函数)。
691
+ *
692
+ * 老形状 (`providerId` / `name` / `url` / `api_key` 之类的别名) 一律在这里收成规范形, 缺必填项
693
+ * 就**明确拒绝**并把缺哪一项说出来 —— 不静默补默认值。
694
+ */
695
+ export function normalizeCustomProvider(raw) {
696
+ if (!raw || typeof raw !== 'object')
697
+ return { ok: false, reason: '不是对象' };
698
+ const r = raw;
699
+ const id = String(r.providerId ?? r.id ?? '').trim();
700
+ if (!id)
701
+ return { ok: false, reason: '缺 providerId' };
702
+ if (!isValidCustomProviderId(id)) {
703
+ return { ok: false, reason: isBuiltinProvider(id) ? `providerId 与内置供应商撞名: ${id}` : `providerId 形状非法: ${id}` };
704
+ }
705
+ const rawUrl = String(r.baseUrl ?? r.base_url ?? r.url ?? '').trim();
706
+ if (!rawUrl)
707
+ return { ok: false, reason: '缺 baseUrl' };
708
+ const baseUrl = normalizeBaseUrl(rawUrl);
709
+ try {
710
+ const u = new URL(baseUrl);
711
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
712
+ return { ok: false, reason: `baseUrl 只支持 http/https, 收到 ${u.protocol}` };
713
+ if (!u.hostname)
714
+ return { ok: false, reason: 'baseUrl 缺主机名' };
715
+ }
716
+ catch {
717
+ return { ok: false, reason: `baseUrl 不是合法 URL: ${rawUrl}` };
718
+ }
719
+ const declaredProtocol = String(r.protocol ?? '').trim();
720
+ const protocol = VALID_PROTOCOLS.includes(declaredProtocol)
721
+ ? declaredProtocol
722
+ // 迁移路径: 老配置没说协议 → 按 base URL 主机名推 (调用方能看到这属于"推出来的")
723
+ : r.protocol ? 'openai-compatible' : inferProtocolFromBaseUrl(baseUrl);
724
+ if (r.protocol && !VALID_PROTOCOLS.includes(declaredProtocol)) {
725
+ return { ok: false, reason: `protocol 不合法: ${String(r.protocol)} (只能是 ${VALID_PROTOCOLS.join('/')})` };
726
+ }
727
+ const value = {
728
+ providerId: id,
729
+ displayName: String(r.displayName ?? r.name ?? id).trim() || id,
730
+ baseUrl,
731
+ protocol,
732
+ };
733
+ const apiKey = r.apiKey ?? r.api_key;
734
+ if (typeof apiKey === 'string' && apiKey.trim())
735
+ value.apiKey = apiKey.trim();
736
+ const model = String(r.model ?? '').trim();
737
+ if (model)
738
+ value.model = model;
739
+ const modelsEndpoint = String(r.modelsEndpoint ?? r.models_endpoint ?? '').trim();
740
+ if (modelsEndpoint)
741
+ value.modelsEndpoint = modelsEndpoint;
742
+ const authHeader = String(r.authHeader ?? r.auth_header ?? '').trim();
743
+ if (authHeader)
744
+ value.authHeader = authHeader;
745
+ const apiKeyEnvVar = String(r.apiKeyEnvVar ?? r.api_key_env ?? '').trim();
746
+ if (apiKeyEnvVar)
747
+ value.apiKeyEnvVar = apiKeyEnvVar;
748
+ const models = Array.isArray(r.models) ? r.models.filter((m) => typeof m === 'string' && m.trim()) : [];
749
+ if (models.length)
750
+ value.models = [...models];
751
+ const cap = r.capabilities;
752
+ if (cap && typeof cap === 'object') {
753
+ const out = {};
754
+ if (cap.toolCalling === 'yes' || cap.toolCalling === 'no' || cap.toolCalling === 'unknown')
755
+ out.toolCalling = cap.toolCalling;
756
+ if (cap.reasoning === 'yes' || cap.reasoning === 'no' || cap.reasoning === 'unknown')
757
+ out.reasoning = cap.reasoning;
758
+ if (typeof cap.contextLength === 'number' && Number.isFinite(cap.contextLength) && cap.contextLength > 0)
759
+ out.contextLength = cap.contextLength;
760
+ if (Object.keys(out).length)
761
+ value.capabilities = out;
762
+ }
763
+ if (typeof r.updatedAt === 'string' && r.updatedAt.trim())
764
+ value.updatedAt = r.updatedAt.trim();
765
+ return { ok: true, value };
766
+ }
767
+ /**
768
+ * 规范化 `config.customProviders` 这一格 (纯函数, **不写盘**)。
769
+ *
770
+ * 支持三种形态, 都是"用户现在真可能有的":
771
+ * 1. 缺省 / `null` → `{}` (旧配置一个字都没写 —— 这是绝大多数旧配置);
772
+ * 2. **map 形** `{ my-gw: {...} }` → 逐条规范化, 坏的**留名**不静默丢;
773
+ * 3. **数组形** `[ {...} ]` (早期形态) → 按 `providerId` 收成 map。
774
+ *
775
+ * 返回值带 `rejected`: 被拒绝的条目与原因 (调用方要如实报出来, 不许"读一读就少了两家")。
776
+ */
777
+ export function normalizeCustomProviders(raw) {
778
+ const providers = {};
779
+ const rejected = [];
780
+ if (raw === undefined || raw === null)
781
+ return { providers, rejected };
782
+ if (typeof raw !== 'object') {
783
+ // 形状本身就不对 → 留名 + 理由 (不静默当空表: 用户会看到"我写的那一格没生效")
784
+ rejected.push({ key: 'customProviders', reason: `形状不是对象/数组 (收到 ${typeof raw}) → 这一格被忽略` });
785
+ return { providers, rejected };
786
+ }
787
+ const items = Array.isArray(raw)
788
+ ? raw.map((item, i) => [String(item?.providerId ?? item?.id ?? `#${i}`), item])
789
+ : Object.entries(raw);
790
+ for (const [key, item] of items) {
791
+ const r = normalizeCustomProvider(item);
792
+ if (r.ok)
793
+ providers[r.value.providerId] = r.value;
794
+ else
795
+ rejected.push({ key, reason: r.reason });
796
+ }
797
+ return { providers, rejected };
798
+ }
799
+ /**
800
+ * 旧版本把自定义端点直接写进 `providers.<id>` (那一格没有 `customProviders`) → **吸收**成自定义供应商。
801
+ *
802
+ * 规矩:
803
+ * - 只吸收**不在内置表**、且 `baseUrl` 非空的键 (没有地址的键不是一个能用的端点, 不猜);
804
+ * - 协议按 base URL 主机名推 (`inferProtocolFromBaseUrl`), 并**如实标** `inferredProtocol=true`;
805
+ * - `known` 里已有的**不覆盖** (显式声明优先于推出来的)。
806
+ *
807
+ * 纯函数: `config-store.initialize()` 与 `custom-provider-store` 共用同一个实现 (不许两处各写一份)。
808
+ */
809
+ export function absorbLegacyProviderEntries(providerMap, known) {
810
+ const providers = { ...known };
811
+ const absorbed = [];
812
+ for (const [id, rawCfg] of Object.entries(providerMap || {})) {
813
+ if (!rawCfg || typeof rawCfg !== 'object')
814
+ continue;
815
+ if (isBuiltinProvider(id) || providers[id])
816
+ continue;
817
+ if (!isValidCustomProviderId(id))
818
+ continue;
819
+ const cfg = rawCfg;
820
+ const baseUrl = String(cfg.baseUrl || '').trim();
821
+ if (!baseUrl)
822
+ continue;
823
+ const protocol = inferProtocolFromBaseUrl(baseUrl);
824
+ const value = {
825
+ providerId: id,
826
+ displayName: String(cfg.displayName || id),
827
+ baseUrl,
828
+ protocol,
829
+ };
830
+ const model = String(cfg.model || '').trim();
831
+ if (model)
832
+ value.model = model;
833
+ const apiKey = String(cfg.apiKey || '').trim();
834
+ if (apiKey)
835
+ value.apiKey = apiKey;
836
+ providers[id] = value;
837
+ absorbed.push({ providerId: id, protocol, inferredProtocol: true });
838
+ }
839
+ return { providers, absorbed };
840
+ }
841
+ // ============================================================
842
+ // 元数据填充点 (P2 冻结接口的**唯一填充方式**)
843
+ // ============================================================
844
+ export const PROVIDER_REGISTRY_SOURCE_ID = 'provider-registry';
845
+ /** 一家登记在册的模型 ID 清单 (自定义=声明, 内置=内置目录; 都没有 → 空数组) */
846
+ function declaredModelIdsOf(provider) {
847
+ const spec = customSnapshot[provider];
848
+ if (spec)
849
+ return Array.isArray(spec.models) ? spec.models.filter((m) => typeof m === 'string' && m) : [];
850
+ const info = PROVIDER_INFO[provider];
851
+ return Array.isArray(info?.models) ? info.models.filter((m) => typeof m === 'string' && m) : [];
852
+ }
853
+ /**
854
+ * 注册表的元数据填充点。
855
+ *
856
+ * 只回答自己有真值的项; 其余项**不出现**在该键上 (= 保持 `unknown`)。逐条理由见文件头。
857
+ */
858
+ export function providerRegistryMetadataSource() {
859
+ return {
860
+ id: PROVIDER_REGISTRY_SOURCE_ID,
861
+ metadataOf: ({ provider, model }) => {
862
+ const entry = getProviderRegistryEntry(provider);
863
+ if (!entry)
864
+ return undefined;
865
+ const facts = { requiresApiKey: entry.requiresApiKey };
866
+ if (entry.kind === 'custom') {
867
+ const spec = customSnapshot[provider];
868
+ const ids = declaredModelIdsOf(provider);
869
+ if (ids.length)
870
+ facts.origin = 'custom';
871
+ const isDeclared = ids.includes(model);
872
+ const cap = spec?.capabilities || {};
873
+ // 只有**用户在声明里写过的**能力才填; 没写就是不出现 (保持 unknown)
874
+ if (isDeclared || !ids.length) {
875
+ if (cap.toolCalling)
876
+ facts.toolCalling = cap.toolCalling;
877
+ if (cap.reasoning)
878
+ facts.reasoning = cap.reasoning;
879
+ if (typeof cap.contextLength === 'number')
880
+ facts.contextLength = cap.contextLength;
881
+ }
882
+ }
883
+ return facts;
884
+ },
885
+ };
886
+ }
887
+ /**
888
+ * 把注册表的填充点接上 (幂等; 同 id 再注册 = 原地替换, 优先级不变)。
889
+ *
890
+ * 显式版: 无条件注册 (测试里 `resetModelMetadataSources()` 之后要靠它接回去)。
891
+ */
892
+ export function registerProviderRegistryMetadataSource() {
893
+ registerModelMetadataSource(providerRegistryMetadataSource());
894
+ metadataSourceWired = true;
895
+ }
896
+ /**
897
+ * 自动接线 (幂等, 只做一次)。
898
+ *
899
+ * **为什么不放在模块体里**: 本模块与 `config-store` / `model-catalog` 之间是**循环 import**,
900
+ * 而模块体的执行时机取决于"谁是入口"—— 实测 `model-catalog` 先被别的入口拉起来时, 模块体里
901
+ * 调 `registerModelMetadataSource()` 会撞上对方模块还没初始化完 (`metadataSources` 的 TDZ) →
902
+ * 整个进程起不来。所以改在**调用时**接线: 任何一次注册表读、任何一次配置读 (`config-store.initialize()`)
903
+ * 都会顺手接上, 于是"真的接线了"依然是可核事实, 但不依赖 import 顺序。
904
+ *
905
+ * 只接一次也意味着: 测试里显式 `resetModelMetadataSources()` 之后它**不会**被偷偷加回来。
906
+ */
907
+ export function ensureProviderRegistryMetadataSource() {
908
+ // 目录的填充点一起接上: 注册表一旦被读, "目录里字面声明过的能力"就该能被问出来。
909
+ // 两个填充点各答各的字段 (注册表答 requiresApiKey/origin-custom; 目录答 origin-catalog/能力),
910
+ // 合并规则是 P2 冻结的"逐字段: 没答案的让下一个答"。
911
+ // **scope 必须给**: 目录层只答 `kind === 'catalog'` 的家 (内置 13 家与自定义家一个字都不许变)。
912
+ ensureProviderCatalogMetadataSource(catalogFillScope());
913
+ if (metadataSourceWired)
914
+ return;
915
+ registerProviderRegistryMetadataSource();
916
+ }
917
+ let metadataSourceWired = false;