@gehennawu/dsh-service 2.0.7 → 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1618 @@
1
+ // Host deep module: model-configuration workbench (v1.10, ported from work/models-runtime).
2
+ //
3
+ // 为什么是独立文件而不是 index.js 里的又一段(2026-09 重实现决定):v0.43 的工作台在旧
4
+ // 分支里是 2750 行宿主代码、与 quota/skill/session 端点交错写在同一个 `apply()` 里;
5
+ // main 之后已把各功能域拆成 `*-routes.js`,工作台按同一惯例成域——`index.js` 只做 RPC
6
+ // 装配与共享工具注入,本文件持有领域逻辑。深模块 seam(与 docs/planning/model-workbench.md
7
+ // 的定稿一致):Catalog / ProfileRepository / 仲裁 / 侧车仓库(Undo+Bindings+Validations+
8
+ // Recovery)/ RouteProvisioner / Validator / Candidates。
9
+ //
10
+ // 与旧分支的差异(重实现时按 0.1.6-alpha.2 装机源码逐条核对,非机械搬运):
11
+ // ① 数据源只剩 pi-ai 内置目录(旧分支头的 product 决定:models.dev 已移除——pi-ai 目录
12
+ // 本就是它的已消化生成物,主流渠道零损失)。遗留的 models-dev 适配器**不再移植**。
13
+ // ② 模型 id 契约放宽:`dsh-llm-pi-ai` 只要求非空字符串,插件仅拒控制字符与超长。
14
+ // ③ 内置渠道的能力字段由目录继承,展示判定走 looseCapabilities(写路径仍全量严格)。
15
+ //
16
+ // 安全教义(每个端点都要过一遍):
17
+ // - 零输入拼接:不接受来自浏览器的 URL、包名、命令、路径。在线发现只请求宿主从
18
+ // settings 读出的 baseURL,经 `probeBaseURLCheck` 公网护栏;模型 id/显示名等字符串
19
+ // 只作为数据写入 settings(走 llm-pi-ai 自身 schema 校验),绝不进 shell/路径/命令。
20
+ // - 客户端只回传服务端自己签发的标识(sid / planId / inspectId / draftId):所有取值
21
+ // 由服务端按清单重放,客户端无法伪造字段值(`applySuggestions` 对未知 sid 整体拒绝)。
22
+ // - 先记录后变更:破坏性写入前把可回滚状态(写前完整 profile 快照)持久化到侧车,
23
+ // 两段式确认由客户端承载,宿主侧以 fingerprint 复检拒绝并发覆盖。
24
+
25
+ import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto'
26
+ import { existsSync } from 'node:fs'
27
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
28
+ import { dirname, join } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+ import { lookup as dnsLookup } from 'node:dns/promises'
31
+ import { brotliDecompress, gunzip } from 'node:zlib'
32
+ import https from 'node:https'
33
+
34
+ /** DSH 包名(目录定位用;DSH 由宿主安装,不作为插件依赖打包)。 */
35
+ const DSH_PACKAGE = '@deepseek-ai/dsh'
36
+ /** pi-ai 内置目录相对 DSH 包根的路径(data/*.json 每文件一个渠道)。 */
37
+ const PI_AI_DATA_SUBPATH = ['node_modules', '@earendil-works', 'pi-ai', 'dist', 'providers', 'data']
38
+
39
+ /** 侧车(写前快照 / 绑定 / 验证摘要)单文件分区;schemaVersion 不匹配即 fail-closed。 */
40
+ const WORKBENCH_SIDECAR_VERSION = 1
41
+ const WORKBENCH_SIDECAR_FILE = 'dsh-service-model-workbench.json'
42
+ /** 计划/检查句柄 TTL:30 分钟无活动过期(与定稿一致,无计时器,按时间戳判定)。 */
43
+ const WORKBENCH_PLAN_TTL_MS = 30 * 60 * 1000
44
+ /** 句柄 Map 上限:超限淘汰最旧一条(内存只放未消费的短命句柄)。 */
45
+ const WORKBENCH_MAX_HANDLES = 32
46
+
47
+ // ── P3.5 补充源:modelparams.dev(仅思考档位与输出上限,不表态容量/视觉)─────────
48
+ // 定位:pi-ai 内置目录是主源(本机 DSH 已消化生成物),modelparams.dev 只补「目录未表态」
49
+ // 的思考档位。它不参与 contextWindow/input 的仲裁——那两处口径以 pi-ai 与运行时实测为准。
50
+ // 网络纪律:快照式、ETag 条件刷新、动作驱动(进页零网络)、8MB 上限、0600 原子写。
51
+ /** 补充目录接口(固定域名常量,浏览器不参与拼装)。 */
52
+ const MODEL_PARAMS_API_URL = 'https://modelparams.dev/api/v1/models.json'
53
+ const MODEL_PARAMS_CATALOG_FILE = 'dsh-service-modelparams.json'
54
+ const MODEL_PARAMS_CATALOG_VERSION = 1
55
+ const MAX_MODELS_CATALOG_BYTES = 8 * 1024 * 1024
56
+ const MODELS_CATALOG_TIMEOUT_MS = 15000
57
+ /** 失败退避:30 分钟内同会话不再自动重试(手动 force 不受限)。 */
58
+ const MODELS_CATALOG_BACKOFF_MS = 30 * 60 * 1000
59
+ /** 会话内单飞的会话槽上限(超出淘汰最旧)。 */
60
+ const MAX_FRESH_SESSIONS = 8
61
+ /** modelparams.dev 中表达「档位式思考」的参数路径(其余 thinking.* 为 toggle/预算制,不表态)。 */
62
+ const MODEL_PARAMS_EFFORT_PATHS = new Set(['reasoning_effort', 'output_config.effort', 'reasoning.effort'])
63
+
64
+ // ── 联网查证:OpenRouter 模型档案查询(唯一源,固定域名,不抓厂商文档)────────────
65
+ // 安全链:只请求固定域名 openrouter.ai(档案端点或目录搜索端点),https.request 直接连,
66
+ // 不跟随重定向(响应 3xx 视为失败)、10s 超时、4MB 上限、解压后 8MB 上限。
67
+ const MODEL_VERIFY_TIMEOUT_MS = 10000
68
+ const MAX_MODEL_VERIFY_BYTES = 4 * 1024 * 1024
69
+ const OPENROUTER_SEARCH_LIMIT = 50
70
+
71
+ /** 在线发现({baseURL}/models)与响应上限。 */
72
+ const MODELS_PROBE_TIMEOUT_MS = 10000
73
+ const MAX_MODELS_PROBE_BYTES = 4 * 1024 * 1024
74
+ /** 解压后上限:与传输上限分开,防压缩炸弹(与目录抓取同一口径)。 */
75
+ const MAX_MODELS_PROBE_INFLATED_BYTES = 8 * 1024 * 1024
76
+ /** 模型表上限与字段上限(写前自检用;超出即拒绝而非静默截断)。 */
77
+ const MAX_MODELS_PROFILE_ROWS = 200
78
+ const MAX_MODEL_ID_LENGTH = 256
79
+ /** 临时密钥上限(新路由事务的 key 只进内存,验证后经凭据事务落盘)。 */
80
+ const MAX_MODELS_KEY_LENGTH = 4096
81
+
82
+ /** DSH 思考等级固定 7 档(pi-ai ThinkingLevel 类型锁死)——超档值只能丢弃。 */
83
+ const THINKING_LEVELS_SET = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
84
+ /** Legacy models2 discovery gates; the models3 path also supports Anthropic. */
85
+ const MODELS_PROBE_PROTOCOLS = new Set(['openai-completions', 'openai-responses'])
86
+ /** 自定义路由可声明的协议白名单(llm-pi-ai supportedProtocols 同款)。 */
87
+ const MODELS_PROTOCOLS = ['openai-completions', 'openai-responses', 'anthropic-messages']
88
+ /** 官方 CustomProviderCard 同款 route 正则。 */
89
+ const MODELS_ROUTE_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
90
+ /** 凭据 ref 文法 = POSIX shell 标识符(credentials 服务同款校验)。 */
91
+ const MODELS_KEY_REF_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/
92
+ /** 模型 id 契约(v0.43 放宽):上游 wire id 原样保留,仅拒控制字符与超长。
93
+ * 旧白名单正则会拒掉 amazon.nova-*:0、@cf/*、*:free 等真实 ID(实测 pi-ai 目录 84 个不匹配)。 */
94
+ const MODEL_ID_RE = /^[^\p{Cc}]{1,256}$/u
95
+
96
+ /** 写路径的模型字段全集(仲裁只对这些字段产出建议)。 */
97
+ const META_FIELDS = ['name', 'contextWindow', 'maxTokens', 'input', 'reasoningEfforts']
98
+ /** 建议条目上的内部键:重放到 models 数组时必须剥掉,绝不写进 settings。 */
99
+ const SUGGESTION_INTERNAL_KEYS = new Set(['inexpressible', 'selected', 'source'])
100
+
101
+ /** 探测 DNS 解析的可替换 holder(默认 node:dns/promises lookup;测试注入假解析,生产不变)。 */
102
+ export const modelsProbeLookup = { impl: dnsLookup }
103
+
104
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
105
+
106
+ // ─────────────────────────────────────────────────────────────────────────────
107
+ // 规范化与指纹
108
+ // ─────────────────────────────────────────────────────────────────────────────
109
+
110
+ /** 键排序的稳定序列化:任何字段漂移都会改变指纹(含未知字段,保真读取的基线)。 */
111
+ export function stableStringify(value) {
112
+ if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`
113
+ if (isPlainObject(value)) {
114
+ const keys = Object.keys(value).sort()
115
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`
116
+ }
117
+ return JSON.stringify(value) ?? 'null'
118
+ }
119
+
120
+ /** 完整 route profile 的规范化 SHA-256(settings revision 缺失时的冲突检测基线)。 */
121
+ export function profileFingerprint(profile) {
122
+ return createHash('sha256').update(stableStringify(profile ?? null)).digest('hex')
123
+ }
124
+
125
+ /** 确定性建议 id:值变则 id 变,服务端重算可复现(客户端只回传 sid,无法伪造取值)。 */
126
+ export function suggestionId({ route, modelId, kind, field, from, to }) {
127
+ const text = stableStringify([route, modelId, kind, field ?? null, from ?? null, to ?? null])
128
+ return createHash('sha256').update(text).digest('hex').slice(0, 16)
129
+ }
130
+
131
+ /** 计划 id 的 HMAC:随机盐进程级,客户端拿到也无法自己造一个合法 planId。 */
132
+ const planSecret = randomBytes(32)
133
+ export function planIdFor(text) {
134
+ return createHmac('sha256', planSecret).update(text).digest('hex').slice(0, 32)
135
+ }
136
+
137
+ // ─────────────────────────────────────────────────────────────────────────────
138
+ // Catalog:settings 读面 + pi-ai 内置目录
139
+ // ─────────────────────────────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * 保真读取 llm-pi-ai.providers(**不做字段裁剪**)。
143
+ *
144
+ * 与 index.js 的 `readLlmProviders`(配额用投影,只留 name/displayName/baseURL/apiKeyEnv)
145
+ * 分开是刻意的:工作台的读-改-写必须拿完整 profile 当基线,任何裁剪都会在下一次写回时
146
+ * 静默抹掉 input/reasoningEfforts/compat/未知键(旧版投影的真实事故)。
147
+ */
148
+ export function readLlmProfiles(settings) {
149
+ let section
150
+ try {
151
+ section = typeof settings?.get === 'function' ? settings.get('llm-pi-ai') : undefined
152
+ } catch (_) {
153
+ return []
154
+ }
155
+ const providers = section?.providers
156
+ if (typeof providers !== 'object' || providers === null) return []
157
+ return Object.entries(providers)
158
+ .filter(([route]) => typeof route === 'string' && route.length > 0 && route.length <= 128)
159
+ .slice(0, 256)
160
+ .filter(([, profile]) => isPlainObject(profile))
161
+ .map(([route, profile]) => ({
162
+ ...structuredClone(profile),
163
+ route,
164
+ displayName: typeof profile?.displayName === 'string' && profile.displayName.length > 0 ? profile.displayName.slice(0, 128) : route,
165
+ api: typeof profile?.api === 'string' ? profile.api.slice(0, 64) : '',
166
+ baseURL: typeof profile?.baseURL === 'string' ? profile.baseURL.trim().slice(0, 2048) : '',
167
+ apiKeyEnv: typeof profile?.apiKeyEnv === 'string' ? profile.apiKeyEnv : '',
168
+ // 模型条目原样浅拷贝(保留 input/reasoningEfforts/compat/未知键),绝不裁剪截断。
169
+ models: Array.isArray(profile?.models)
170
+ ? profile.models.map((model) => (isPlainObject(model) ? { ...model } : model))
171
+ : [],
172
+ }))
173
+ .filter((profile) => profile.route !== '')
174
+ }
175
+
176
+ /** 官方 deriveKeyRef 同款:route 大写化、非字母数字→`_`。 */
177
+ export function deriveKeyRefFor(route) {
178
+ return `${route.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
179
+ }
180
+
181
+ /** 路由查询面 baseURL:settings 显式值优先;空值时回落 pi-ai 内置渠道同名条目的 baseUrl
182
+ * (内置渠道的 baseUrl 常只在注册表数据里,settings 不写)。仍为空 → 上层报 no-base-url。 */
183
+ export function resolveLlmProfileBaseURL(profile, piIndex) {
184
+ const explicit = typeof profile?.baseURL === 'string' ? profile.baseURL.trim() : ''
185
+ if (explicit !== '') return explicit
186
+ const channels = Array.isArray(piIndex?.channels) ? piIndex.channels : []
187
+ const channel = channels.find((candidate) => candidate.id === profile?.route)
188
+ return typeof channel?.baseUrl === 'string' && channel.baseUrl !== '' ? channel.baseUrl : ''
189
+ }
190
+
191
+ /** URL → 规范化 hostname(小写、去尾点);非 HTTPS 或非法返回 undefined。 */
192
+ export function hostnameOf(raw) {
193
+ if (typeof raw !== 'string' || raw.trim() === '') return undefined
194
+ let parsed
195
+ try {
196
+ parsed = new URL(raw.trim())
197
+ } catch (_) {
198
+ return undefined
199
+ }
200
+ if (parsed.protocol !== 'https:') return undefined
201
+ const host = parsed.hostname.toLowerCase().replace(/\.$/, '')
202
+ return host === '' ? undefined : host
203
+ }
204
+
205
+ /** 路由 baseURL 规范化:去尾斜杠、容忍尾部 /v1 差异(匹配用)。 */
206
+ export function normalizeBaseURLForMatch(raw) {
207
+ if (typeof raw !== 'string') return ''
208
+ let value = raw.trim().replace(/\/+$/, '')
209
+ if (/\/v1$/i.test(value)) value = value.replace(/\/v1$/i, '')
210
+ return value.toLowerCase()
211
+ }
212
+
213
+ function hostOf(url) {
214
+ try {
215
+ return new URL(url).hostname.toLowerCase()
216
+ } catch (_) {
217
+ return null
218
+ }
219
+ }
220
+
221
+ function pathOf(url) {
222
+ try {
223
+ return new URL(url).pathname.replace(/\/+$/, '')
224
+ } catch (_) {
225
+ return ''
226
+ }
227
+ }
228
+
229
+ /**
230
+ * pi-ai 的 thinkingLevelMap → DSH 的 reasoningEfforts(零推导,越档丢弃)。
231
+ *
232
+ * `off` 语义坑(pi-ai 目录与 llm-pi-ai 约定不一致):llm-pi-ai 要求 dict 显式写 `off:`
233
+ * (空值/null)才声明「支持关闭」,未声明会被钉成 null(= 不支持关闭);而 pi-ai 目录里
234
+ * off 缺省即「支持关闭」。故只要有任一非 off 档就补 `off: null`。
235
+ */
236
+ export function piAiReasoningEfforts(thinkingLevelMap, reasoning) {
237
+ if (reasoning !== true || !isPlainObject(thinkingLevelMap)) return undefined
238
+ const map = {}
239
+ let hasLevel = false
240
+ for (const level of THINKING_LEVELS_SET) {
241
+ const wire = thinkingLevelMap[level]
242
+ if (wire === undefined) continue
243
+ if (wire === null) {
244
+ if (level === 'off') map.off = null
245
+ continue
246
+ }
247
+ if (typeof wire !== 'string' || wire === '') continue
248
+ map[level] = wire
249
+ if (level !== 'off') hasLevel = true
250
+ }
251
+ if (!hasLevel) return undefined
252
+ if (!('off' in map)) map.off = null
253
+ return map
254
+ }
255
+
256
+ /**
257
+ * pi-ai 内置目录(data/*.json)→ 索引。texts = {渠道名: 文件文本},纯函数便于测试。
258
+ *
259
+ * 产出:channels(去重后的渠道+地址)、channelIds、byHost、byModelId(跨渠道同名模型候选)、
260
+ * whiteHosts。`{location}` 型模板地址不可能是任何路由的事实源,不进 channels/whiteHosts,
261
+ * 但仍保留 channelIds(内置渠道判定与地址无关)。
262
+ */
263
+ export function buildPiAiIndex(texts) {
264
+ const channels = []
265
+ const channelIds = new Set()
266
+ const byHost = new Map()
267
+ const byModelId = new Map()
268
+ const whiteHosts = new Set()
269
+ for (const [channel, text] of Object.entries(texts)) {
270
+ if (channel === '' || channel.startsWith('.')) continue
271
+ let data
272
+ try {
273
+ data = JSON.parse(text)
274
+ } catch (_) {
275
+ continue
276
+ }
277
+ const seenHosts = new Set()
278
+ let channelHasEntry = false
279
+ for (const group of Object.values(data ?? {})) {
280
+ if (!isPlainObject(group)) continue
281
+ for (const entry of Object.values(group)) {
282
+ if (!isPlainObject(entry)) continue
283
+ channelHasEntry = true
284
+ const baseUrl = typeof entry.baseUrl === 'string' && entry.baseUrl.trim() !== '' ? entry.baseUrl.trim() : ''
285
+ const host = hostnameOf(baseUrl)
286
+ if (host !== undefined && !host.includes('{')) {
287
+ whiteHosts.add(host)
288
+ if (!seenHosts.has(host)) {
289
+ seenHosts.add(host)
290
+ channels.push({ id: channel, name: channel, baseUrl, host })
291
+ if (!byHost.has(host)) byHost.set(host, [])
292
+ byHost.get(host).push({ id: channel, name: channel, baseUrl, host })
293
+ }
294
+ }
295
+ const id = typeof entry.id === 'string' && entry.id !== '' ? entry.id : ''
296
+ if (id === '') continue
297
+ const record = {
298
+ id,
299
+ api: typeof entry.api === 'string' ? entry.api : undefined,
300
+ compat: isPlainObject(entry.compat) ? structuredClone(entry.compat) : undefined,
301
+ output: Array.isArray(entry.output) ? [...entry.output] : undefined,
302
+ channel,
303
+ provider: typeof entry.provider === 'string' ? entry.provider : channel,
304
+ name: typeof entry.name === 'string' ? entry.name : id,
305
+ baseUrl,
306
+ reasoning: typeof entry.reasoning === 'boolean' ? entry.reasoning : undefined,
307
+ contextWindow: Number.isInteger(entry.contextWindow) ? entry.contextWindow : undefined,
308
+ maxTokens: Number.isInteger(entry.maxTokens) ? entry.maxTokens : undefined,
309
+ input: Array.isArray(entry.input) && entry.input.length > 0
310
+ ? entry.input.filter((mode) => mode === 'text' || mode === 'image').slice(0, 2)
311
+ : undefined,
312
+ reasoningEfforts: piAiReasoningEfforts(entry.thinkingLevelMap, entry.reasoning),
313
+ }
314
+ if (!byModelId.has(id)) byModelId.set(id, [])
315
+ byModelId.get(id).push(record)
316
+ }
317
+ }
318
+ if (channelHasEntry) channelIds.add(channel)
319
+ }
320
+ return { channels, channelIds, byHost, byModelId, whiteHosts }
321
+ }
322
+
323
+ /** 定位 @deepseek-ai/dsh 包入口:require.resolve → 向上扫 node_modules → 常见全局目录。 */
324
+ export function locateDshPackageJson() {
325
+ try {
326
+ return import.meta.resolve?.(`${DSH_PACKAGE}/package.json`)
327
+ } catch (_) {
328
+ // 继续:import.meta.resolve 在部分宿主不可用
329
+ }
330
+ let dir = dirname(fileURLToPath(import.meta.url))
331
+ for (;;) {
332
+ const candidate = join(dir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
333
+ if (existsSync(candidate)) return candidate
334
+ const parent = dirname(dir)
335
+ if (parent === dir) break
336
+ dir = parent
337
+ }
338
+ for (const base of ['/usr/local/lib/node_modules', '/usr/lib/node_modules']) {
339
+ const candidate = join(base, '@deepseek-ai', 'dsh', 'package.json')
340
+ if (existsSync(candidate)) return candidate
341
+ }
342
+ return null
343
+ }
344
+
345
+ /** 读取 pi-ai 内置目录;布局漂移或未安装时返回空索引(调用方降级,不判失败)。 */
346
+ export async function loadPiAiCatalog() {
347
+ const dshEntry = locateDshPackageJson()
348
+ if (dshEntry === null) return buildPiAiIndex({})
349
+ // import.meta.resolve 可能给 file:// URL,统一成路径再拼子路径。
350
+ const entryPath = typeof dshEntry === 'string' && dshEntry.startsWith('file://') ? fileURLToPath(dshEntry) : dshEntry
351
+ const piDir = join(dirname(entryPath), ...PI_AI_DATA_SUBPATH)
352
+ try {
353
+ const files = await readdir(piDir)
354
+ const texts = {}
355
+ for (const file of files) {
356
+ if (!file.endsWith('.json') || file.startsWith('.')) continue
357
+ const channel = file.slice(0, -5)
358
+ if (channel.includes('.bak')) continue
359
+ try {
360
+ texts[channel] = await readFile(join(piDir, file), 'utf8')
361
+ } catch (_) {
362
+ // 单文件读取失败跳过,其余照常(部分目录可用优于整体失败)
363
+ }
364
+ }
365
+ return buildPiAiIndex(texts)
366
+ } catch (_) {
367
+ return buildPiAiIndex({})
368
+ }
369
+ }
370
+
371
+ /** 目录元数据候选(统一形状,供仲裁):一个模型 id 可能跨多个渠道存在。 */
372
+ export function modelMetaCandidates(piIndex, modelId) {
373
+ const metas = []
374
+ for (const record of piIndex?.byModelId?.get(modelId) ?? []) {
375
+ metas.push({
376
+ source: 'pi-ai',
377
+ providerId: record.channel,
378
+ name: record.name,
379
+ baseUrl: record.baseUrl,
380
+ reasoning: record.reasoning === true,
381
+ contextWindow: record.contextWindow,
382
+ maxTokens: record.maxTokens,
383
+ input: record.input,
384
+ reasoningEfforts: record.reasoningEfforts,
385
+ dropped: [],
386
+ })
387
+ }
388
+ return metas
389
+ }
390
+
391
+ /** 多源共识字段(逐字段收集各来源表态)。 */
392
+ const CONSENSUS_FIELDS = ['name', 'contextWindow', 'maxTokens', 'input', 'reasoningEfforts']
393
+
394
+ /**
395
+ * 字段级多来源共识:对一组带 source/providerId 的候选元数据,逐字段收集「表态」来源。
396
+ * 全部一致 → `{status:'agreed', value, sources}`;存在不一致 → `{status:'split', sources}`
397
+ * (value 缺省,客户端必须逐源选择,不武断给单一建议);无来源表态 → 该字段缺席。
398
+ * 候选顺序即优先级(同渠道 pi-ai → 跨渠道 pi-ai → modelparams → runtime 实测)。
399
+ */
400
+ export function consensusFields(candidates) {
401
+ const result = {}
402
+ for (const field of CONSENSUS_FIELDS) {
403
+ const sources = []
404
+ for (const candidate of Array.isArray(candidates) ? candidates : []) {
405
+ const value = candidate?.[field]
406
+ if (value === undefined) continue
407
+ const entry = {
408
+ source: candidate.source,
409
+ ...(typeof candidate.providerId === 'string' && candidate.providerId !== '' ? { providerId: candidate.providerId } : {}),
410
+ value,
411
+ }
412
+ if (!sources.some((existing) => (
413
+ existing.source === entry.source
414
+ && existing.providerId === entry.providerId
415
+ && stableStringify(existing.value) === stableStringify(entry.value)
416
+ ))) sources.push(entry)
417
+ }
418
+ if (sources.length === 0) continue
419
+ const first = stableStringify(sources[0].value)
420
+ const agreed = sources.every((entry) => stableStringify(entry.value) === first)
421
+ result[field] = agreed ? { status: 'agreed', value: sources[0].value, sources } : { status: 'split', sources }
422
+ }
423
+ return result
424
+ }
425
+
426
+ // ─────────────────────────────────────────────────────────────────────────────
427
+ // 公网护栏与在线发现
428
+ // ─────────────────────────────────────────────────────────────────────────────
429
+
430
+ const PRIVATE_IPV4_CIDRS = [
431
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['127.0.0.0', 8], ['169.254.0.0', 16],
432
+ ['172.16.0.0', 12], ['192.168.0.0', 16], ['100.64.0.0', 10], ['198.18.0.0', 15],
433
+ ['192.0.2.0', 24], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
434
+ ]
435
+
436
+ function ipv4ToInt(address) {
437
+ const parts = address.split('.')
438
+ if (parts.length !== 4) return null
439
+ let value = 0
440
+ for (const part of parts) {
441
+ if (!/^\d{1,3}$/.test(part)) return null
442
+ const octet = Number(part)
443
+ if (octet > 255) return null
444
+ value = (value << 8) | octet
445
+ }
446
+ return value >>> 0
447
+ }
448
+
449
+ /** IPv4 保留段判定(每点分段按十进制解析,`0x`/前导零的十进制解释已在段正则里拒绝)。 */
450
+ export function isPrivateIpv4(address) {
451
+ const value = ipv4ToInt(address)
452
+ if (value === null) return false
453
+ for (const [network, bits] of PRIVATE_IPV4_CIDRS) {
454
+ const base = ipv4ToInt(network)
455
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
456
+ if ((value & mask) === (base & mask)) return true
457
+ }
458
+ return false
459
+ }
460
+
461
+ /** 私网/保留地址判定(IPv4 段 + IPv6 回环/链路本地/唯一本地/组播/文档段)。 */
462
+ export function isPrivateIpAddress(address) {
463
+ if (typeof address !== 'string' || address === '') return false
464
+ const bare = address.startsWith('[') && address.endsWith(']') ? address.slice(1, -1) : address
465
+ // IPv4 映射形态 ::ffff:1.2.3.4 按其 IPv4 部分判定
466
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(bare)
467
+ if (mapped !== null) return isPrivateIpv4(mapped[1])
468
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(bare)) return isPrivateIpv4(bare)
469
+ const lower = bare.toLowerCase()
470
+ if (lower === '::1' || lower === '::') return true
471
+ if (/^fe[89ab][0-9a-f]:/.test(lower)) return true
472
+ if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true
473
+ if (/^ff[0-9a-f]{2}:/.test(lower)) return true
474
+ if (/^2001:db8:/.test(lower)) return true
475
+ return false
476
+ }
477
+
478
+ /** 拒绝 DNS 解析到的私网/保留地址:任一解析结果落在保留段即整体拒绝(防 rebinding)。 */
479
+ export async function assertPublicHost(host) {
480
+ const results = await modelsProbeLookup.impl(host, { all: true })
481
+ const rows = Array.isArray(results) ? results : [results]
482
+ if (rows.length === 0) throw new Error('probe-dns-empty')
483
+ for (const row of rows) {
484
+ const address = typeof row?.address === 'string' ? row.address : ''
485
+ if (address === '' || isPrivateIpAddress(address)) throw new Error('probe-not-public')
486
+ }
487
+ return rows
488
+ }
489
+
490
+ /**
491
+ * 在线发现的地址护栏(v0.43:任意公网 HTTPS,取消 pi-ai 白名单——白名单使任意中转网关
492
+ * 无法在线发现)。SSRF 面由 HTTPS-only + 端口空或 443 + 拒 IP 字面量/模板 + DNS 保留段
493
+ * 拒绝 + 钉住直连 IP 带 Host 头兜住。非公网/HTTP/非 443 地址仍允许保存与手填,只是禁在线发现。
494
+ */
495
+ export function probeBaseURLCheck(baseURL) {
496
+ let parsed
497
+ try {
498
+ parsed = new URL(typeof baseURL === 'string' ? baseURL.trim() : '')
499
+ } catch (_) {
500
+ return { ok: false, code: 'bad-base-url' }
501
+ }
502
+ if (parsed.protocol !== 'https:') return { ok: false, code: 'probe-insecure' }
503
+ if (parsed.username !== '' || parsed.password !== '') return { ok: false, code: 'probe-credentials' }
504
+ if (parsed.port !== '' && parsed.port !== '443') return { ok: false, code: 'probe-bad-port' }
505
+ const host = parsed.hostname.toLowerCase().replace(/\.$/, '')
506
+ if (host === '' || host.includes('{')) return { ok: false, code: 'probe-bad-host' }
507
+ // 拒 IP 字面量:钉住直连的前提是「域名 → DNS → 公网地址」,字面量绕过整条链。
508
+ const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
509
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(bare) || bare.includes(':')) return { ok: false, code: 'probe-ip-literal' }
510
+ return { ok: true, parsed, host }
511
+ }
512
+
513
+ /**
514
+ * 取一次协议对应的模型列表(Anthropic 为 /v1/models):DNS 全地址公网校验 → 钉住首个解析地址直连(带 Host 头,
515
+ * 防 DNS rebinding)→ 不跟随重定向 → 10s 超时 → 传输 ≤4MB 且解压后 ≤8MB。
516
+ * 列表解析产出 {id, name, contextWindow, maxTokens},超长/重复 id 跳过不截断。
517
+ */
518
+ export async function fetchModelsProbe(baseURL, apiKey, api = 'openai-completions') {
519
+ if (!MODELS_PROTOCOLS.includes(api)) throw new Error('probe-protocol-unsupported')
520
+ const checked = probeBaseURLCheck(baseURL)
521
+ if (!checked.ok) throw new Error(checked.code)
522
+ const parsed = checked.parsed
523
+ const addresses = await assertPublicHost(checked.host)
524
+ const pinned = typeof addresses[0]?.address === 'string' ? addresses[0].address : ''
525
+ if (pinned === '') throw new Error('probe-dns-empty')
526
+ const basePath = parsed.pathname.replace(/\/+$/, '')
527
+ const query = new URLSearchParams(parsed.search)
528
+ if (api === 'anthropic-messages') query.set('limit', '1000')
529
+ const listingPath = api === 'anthropic-messages' ? `${basePath.endsWith('/v1') ? basePath.slice(0, -3) : basePath}/v1/models` : `${basePath}/models`
530
+ const path = `${listingPath}${query.size ? '?' + query.toString() : ''}`
531
+ const headers = { host: parsed.hostname, accept: 'application/json' }
532
+ if (api === 'anthropic-messages') {
533
+ headers['anthropic-version'] = '2023-06-01'
534
+ if (typeof apiKey === 'string' && apiKey !== '') headers['x-api-key'] = apiKey
535
+ } else if (typeof apiKey === 'string' && apiKey !== '') headers.authorization = `Bearer ${apiKey}`
536
+ const body = await new Promise((resolve, reject) => {
537
+ const request = https.request(
538
+ {
539
+ host: pinned, port: 443, path, method: 'GET', headers, timeout: MODELS_PROBE_TIMEOUT_MS,
540
+ // 证书按 Host 头校验的目标域名验证,直连 IP 不改变 TLS 身份。
541
+ servername: parsed.hostname,
542
+ },
543
+ (response) => {
544
+ const status = response.statusCode ?? 0
545
+ // 不跟随重定向:3xx 视为失败(拦 SSRF 跳板)。
546
+ if (status >= 300 && status < 400) {
547
+ response.resume()
548
+ reject(new Error(`probe-redirect:${status}`))
549
+ return
550
+ }
551
+ if (status < 200 || status >= 300) {
552
+ response.resume()
553
+ reject(new Error(`probe-status:${status}`))
554
+ return
555
+ }
556
+ const chunks = []
557
+ let received = 0
558
+ response.on('data', (chunk) => {
559
+ received += chunk.length
560
+ if (received > MAX_MODELS_PROBE_BYTES) {
561
+ request.destroy(new Error('probe-too-large'))
562
+ return
563
+ }
564
+ chunks.push(chunk)
565
+ })
566
+ response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
567
+ response.on('error', reject)
568
+ },
569
+ )
570
+ request.on('timeout', () => request.destroy(new Error('probe-timeout')))
571
+ request.on('error', reject)
572
+ request.end()
573
+ })
574
+ if (Buffer.byteLength(body, 'utf8') > MAX_MODELS_PROBE_INFLATED_BYTES) throw new Error('probe-too-large')
575
+ return parseModelListing(body)
576
+ }
577
+
578
+ /** {baseURL}/models 响应 → 规范化候选行(宽容各家字段名,id 必需)。 */
579
+ export function parseModelListing(body) {
580
+ let payload
581
+ try {
582
+ payload = JSON.parse(body)
583
+ } catch (_) {
584
+ throw new Error('probe-bad-json')
585
+ }
586
+ const rows = Array.isArray(payload) ? payload
587
+ : Array.isArray(payload?.data) ? payload.data
588
+ : Array.isArray(payload?.models) ? payload.models
589
+ : []
590
+ const seen = new Set()
591
+ const out = []
592
+ for (const row of rows) {
593
+ if (!isPlainObject(row)) continue
594
+ const rawId = typeof row.id === 'string' ? row.id.trim() : typeof row.name === 'string' ? row.name.trim() : ''
595
+ // 超长/重复 id 跳过而不截断:截断会造成前缀碰撞,把两个模型压成一个。
596
+ if (rawId === '' || rawId.length > MAX_MODEL_ID_LENGTH || seen.has(rawId)) continue
597
+ seen.add(rawId)
598
+ const contextWindow = Number.isInteger(row.context_window) ? row.context_window
599
+ : Number.isInteger(row.context_length) ? row.context_length
600
+ : Number.isInteger(row.contextWindow) ? row.contextWindow : undefined
601
+ const maxTokens = Number.isInteger(row.max_output_tokens) ? row.max_output_tokens
602
+ : Number.isInteger(row.max_tokens) ? row.max_tokens
603
+ : Number.isInteger(row.maxTokens) ? row.maxTokens : undefined
604
+ const entry = { id: rawId }
605
+ const displayName = typeof row.display_name === 'string' && row.display_name.trim() ? row.display_name : row.name
606
+ if (typeof displayName === 'string' && displayName.trim() !== '') entry.name = displayName.trim().slice(0, 128)
607
+ if (contextWindow !== undefined && contextWindow > 0) entry.contextWindow = contextWindow
608
+ if (maxTokens !== undefined && maxTokens > 0) entry.maxTokens = maxTokens
609
+ out.push(entry)
610
+ }
611
+ return out
612
+ }
613
+
614
+ // ─────────────────────────────────────────────────────────────────────────────
615
+ // P3.5 补充源:modelparams.dev 快照、刷新与索引
616
+ // ─────────────────────────────────────────────────────────────────────────────
617
+
618
+ /**
619
+ * 动作驱动的补充源新鲜度(无 TTL + 会话内单飞)。
620
+ * - freshKey 同名(同一客户端会话)共享一次条件请求(在途合并、成功后复用同一 Promise);
621
+ * - 缺失/非法 freshKey 由调用方给唯一键,绝不共享;
622
+ * - 30 分钟失败退避(force=true 不受限,但在途请求仍单飞合并;无快照同样生效);
623
+ * - 失败保留旧快照继续(result.staleError 供调用方提示),refresh 永不 reject 外漏;
624
+ * - 退避状态(lastError/lastAttemptAt)由本管理器写入 state——调用方只读展示。
625
+ */
626
+ export function createFreshnessManager(getState, refresh, { backoffMs = MODELS_CATALOG_BACKOFF_MS, maxSessions = MAX_FRESH_SESSIONS } = {}) {
627
+ const settled = new Map()
628
+ const inFlight = new Map()
629
+ return {
630
+ refreshed(freshKey, force = false) {
631
+ const pending = inFlight.get(freshKey)
632
+ if (pending !== undefined) return pending
633
+ if (!force && settled.has(freshKey)) return settled.get(freshKey)
634
+ const state = getState()
635
+ if (!force && state.lastError !== null && Date.now() - state.lastAttemptAt < backoffMs) {
636
+ const stale = Promise.resolve({ snapshot: state.snapshot, source: 'snapshot', staleError: state.lastError })
637
+ settled.set(freshKey, stale)
638
+ if (settled.size > maxSessions) settled.delete(settled.keys().next().value)
639
+ return stale
640
+ }
641
+ state.lastAttemptAt = Date.now()
642
+ const flight = refresh().then(
643
+ (value) => {
644
+ state.lastError = null
645
+ return value
646
+ },
647
+ (error) => {
648
+ const message = error?.message || String(error)
649
+ state.lastError = message
650
+ return { snapshot: state.snapshot, source: 'snapshot', staleError: message }
651
+ },
652
+ )
653
+ inFlight.set(freshKey, flight)
654
+ flight.then((value) => {
655
+ inFlight.delete(freshKey)
656
+ if (value?.staleError === undefined) {
657
+ settled.set(freshKey, flight)
658
+ if (settled.size > maxSessions) settled.delete(settled.keys().next().value)
659
+ }
660
+ }, () => {})
661
+ return flight
662
+ },
663
+ drop(freshKey) {
664
+ settled.delete(freshKey)
665
+ inFlight.delete(freshKey)
666
+ },
667
+ }
668
+ }
669
+
670
+ /** 读快照:缺文件/非法 JSON/schemaVersion 不匹配/结构非法/非法 fetchedAt → null(fail-closed)。 */
671
+ export async function loadModelParamsSnapshot(dshHome) {
672
+ const target = join(dshHome, MODEL_PARAMS_CATALOG_FILE)
673
+ try {
674
+ const info = await stat(target)
675
+ // 磁盘上限放宽一倍(保存的是已解析结构,可能比传输体略大),过大即判损坏。
676
+ if (info.size > MAX_MODELS_CATALOG_BYTES * 2) return null
677
+ const raw = JSON.parse(await readFile(target, 'utf8'))
678
+ if (raw?.schemaVersion !== MODEL_PARAMS_CATALOG_VERSION) return null
679
+ if (!isPlainObject(raw?.catalog)) return null
680
+ if (typeof raw.fetchedAt !== 'number' || !Number.isFinite(raw.fetchedAt)) return null
681
+ return { fetchedAt: raw.fetchedAt, etag: typeof raw.etag === 'string' ? raw.etag : '', catalog: raw.catalog }
682
+ } catch (_) {
683
+ return null
684
+ }
685
+ }
686
+
687
+ /** 0600 原子写(临时文件 + rename)。 */
688
+ export async function saveModelParamsSnapshot(dshHome, snapshot) {
689
+ await mkdir(dshHome, { recursive: true })
690
+ const target = join(dshHome, MODEL_PARAMS_CATALOG_FILE)
691
+ const temporary = `${target}.${randomUUID()}.tmp`
692
+ try {
693
+ await writeFile(temporary, JSON.stringify({ schemaVersion: MODEL_PARAMS_CATALOG_VERSION, ...snapshot }), { mode: 0o600 })
694
+ await rename(temporary, target)
695
+ } finally {
696
+ await rm(temporary, { force: true })
697
+ }
698
+ }
699
+
700
+ /** 条件请求补充目录:304 续期、200 换内容、传输 ≤8MB 且解压后 ≤8MB、根必须是 object。 */
701
+ export function fetchModelParamsApi(etag, { userAgent = 'dsh-service', timeoutMs = MODELS_CATALOG_TIMEOUT_MS } = {}) {
702
+ return new Promise((resolve, reject) => {
703
+ const request = https.request(MODEL_PARAMS_API_URL, {
704
+ method: 'GET',
705
+ timeout: timeoutMs,
706
+ headers: {
707
+ Accept: 'application/json',
708
+ 'Accept-Encoding': 'gzip, br',
709
+ 'user-agent': userAgent,
710
+ ...(typeof etag === 'string' && etag !== '' ? { 'If-None-Match': etag } : {}),
711
+ },
712
+ }, (response) => {
713
+ const status = response.statusCode || 0
714
+ if (status === 304) {
715
+ response.resume()
716
+ resolve({ status: 304, etag: response.headers.etag || etag || '' })
717
+ return
718
+ }
719
+ if (status < 200 || status >= 300) {
720
+ response.resume()
721
+ reject(new Error(`http-status:${status}`))
722
+ return
723
+ }
724
+ const chunks = []
725
+ let bytes = 0
726
+ response.on('data', (chunk) => {
727
+ bytes += chunk.length
728
+ if (bytes > MAX_MODELS_CATALOG_BYTES) {
729
+ request.destroy()
730
+ reject(new Error('catalog-too-large'))
731
+ return
732
+ }
733
+ chunks.push(chunk)
734
+ })
735
+ response.on('error', () => reject(new Error('network')))
736
+ response.on('end', () => {
737
+ const encoding = String(response.headers['content-encoding'] || '').toLowerCase()
738
+ const body = Buffer.concat(chunks)
739
+ const finish = (text) => {
740
+ try {
741
+ const data = JSON.parse(text)
742
+ // 根必须是普通 object 且含 models 数组;解压后体积同样封顶(防压缩炸弹)。
743
+ if (!isPlainObject(data)) reject(new Error('bad-payload:shape'))
744
+ else if (!Array.isArray(data.models)) reject(new Error('bad-payload:shape'))
745
+ else if (Buffer.byteLength(text, 'utf8') > MAX_MODELS_CATALOG_BYTES) reject(new Error('catalog-too-large'))
746
+ else resolve({ status: 200, etag: response.headers.etag || '', data })
747
+ } catch (_) {
748
+ reject(new Error('bad-payload:json'))
749
+ }
750
+ }
751
+ if (encoding === 'gzip') {
752
+ gunzip(body, (error, decoded) => {
753
+ if (error) reject(new Error('bad-payload:gzip'))
754
+ else finish(decoded.toString('utf8'))
755
+ })
756
+ } else if (encoding === 'br') {
757
+ brotliDecompress(body, (error, decoded) => {
758
+ if (error) reject(new Error('bad-payload:br'))
759
+ else finish(decoded.toString('utf8'))
760
+ })
761
+ } else {
762
+ finish(body.toString('utf8'))
763
+ }
764
+ })
765
+ })
766
+ request.on('error', (error) => {
767
+ reject(new Error(error?.code === 'ABORT_ERR' ? 'cancelled' : 'network'))
768
+ })
769
+ request.on('timeout', () => {
770
+ request.destroy()
771
+ reject(new Error('timeout'))
772
+ })
773
+ request.end()
774
+ })
775
+ }
776
+
777
+ /** 刷新快照:304 只续期 fetchedAt(零等待),200 换内容;保存成功才替换内存快照。 */
778
+ export async function refreshModelParamsSnapshot(dshHome, state, options = {}) {
779
+ if (state.snapshot === null) state.snapshot = await loadModelParamsSnapshot(dshHome)
780
+ const result = await fetchModelParamsApi(state.snapshot?.etag ?? '', options)
781
+ if (result.status === 304) {
782
+ const next = { ...state.snapshot, fetchedAt: Date.now(), etag: result.etag }
783
+ await saveModelParamsSnapshot(dshHome, next)
784
+ state.snapshot = next
785
+ return { snapshot: next, source: 'conditional' }
786
+ }
787
+ const next = { fetchedAt: Date.now(), etag: result.etag, catalog: result.data }
788
+ await saveModelParamsSnapshot(dshHome, next)
789
+ state.snapshot = next
790
+ return { snapshot: next, source: 'network' }
791
+ }
792
+
793
+ /**
794
+ * 补充目录 → byModelId:`{provider, authType, effortValues[], maxTokensMax?}`。
795
+ * 只收 effort 变体参数;`max_tokens` 类的 range.max 作为输出上限参考。形状不合规跳过。
796
+ */
797
+ export function indexModelParamsDev(catalog, { maxModelIdLength = MAX_MODEL_ID_LENGTH } = {}) {
798
+ const byModelId = new Map()
799
+ const models = Array.isArray(catalog?.models) ? catalog.models : []
800
+ for (const entry of models) {
801
+ if (!isPlainObject(entry)) continue
802
+ const id = typeof entry.model === 'string' ? entry.model.trim() : ''
803
+ if (id === '' || id.length > maxModelIdLength || !MODEL_ID_RE.test(id)) continue
804
+ const provider = typeof entry.provider === 'string' ? entry.provider : ''
805
+ const params = Array.isArray(entry.params) ? entry.params : []
806
+ const effortValues = []
807
+ let maxTokensMax
808
+ for (const param of params) {
809
+ if (!isPlainObject(param)) continue
810
+ const path = typeof param.path === 'string' ? param.path : ''
811
+ if (MODEL_PARAMS_EFFORT_PATHS.has(path) && Array.isArray(param.values)) {
812
+ for (const value of param.values) {
813
+ if (typeof value === 'string' && !effortValues.includes(value)) effortValues.push(value)
814
+ }
815
+ }
816
+ if (/^(max_tokens|max_completion_tokens|inferenceConfig\.maxTokens|generationConfig\.maxOutputTokens)$/.test(path)) {
817
+ const max = param?.range?.max
818
+ if (Number.isInteger(max) && max > 0 && (maxTokensMax === undefined || max > maxTokensMax)) maxTokensMax = max
819
+ }
820
+ }
821
+ if (provider === '' || effortValues.length === 0) continue
822
+ if (!byModelId.has(id)) byModelId.set(id, [])
823
+ byModelId.get(id).push({
824
+ provider,
825
+ authType: typeof entry.authType === 'string' ? entry.authType : 'api_key',
826
+ apiSurface: typeof entry.apiSurface === 'string' ? entry.apiSurface : '',
827
+ ...(effortValues.length > 0 ? { effortValues } : {}),
828
+ ...(maxTokensMax !== undefined ? { maxTokensMax } : {}),
829
+ })
830
+ }
831
+ return byModelId
832
+ }
833
+
834
+ /** 补充记录们 → reasoningEfforts:合并全部 effort values,none→off(null),只留 DSH 7 档内档位;
835
+ * 无任何可表达档位返回 undefined(toggle/预算制不表态)。 */
836
+ export function modelParamsDevEfforts(records) {
837
+ const map = {}
838
+ let hasLevel = false
839
+ for (const record of Array.isArray(records) ? records : []) {
840
+ for (const raw of record?.effortValues ?? []) {
841
+ const level = raw === 'none' ? 'off' : raw
842
+ if (!THINKING_LEVELS_SET.has(level)) continue
843
+ if (level === 'off') map.off = null
844
+ else {
845
+ map[level] = level
846
+ hasLevel = true
847
+ }
848
+ }
849
+ }
850
+ if (!hasLevel) return undefined
851
+ if (!('off' in map)) map.off = null
852
+ return map
853
+ }
854
+
855
+ /** 补充源候选项(统一形状,供共识仲裁;只表态 reasoningEfforts 与 maxTokens 上限)。 */
856
+ export function modelParamsCandidates(catalog, modelId) {
857
+ const records = indexModelParamsDev(catalog).get(modelId)
858
+ if (records === undefined || records.length === 0) return []
859
+ const efforts = modelParamsDevEfforts(records)
860
+ const maxTokens = records[0].maxTokensMax
861
+ return [{
862
+ source: 'modelparams',
863
+ providerId: records[0].provider,
864
+ ...(efforts !== undefined ? { reasoningEfforts: efforts } : {}),
865
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
866
+ }]
867
+ }
868
+
869
+ // ─────────────────────────────────────────────────────────────────────────────
870
+ // 联网查证:OpenRouter 模型档案(固定域名,唯一源)
871
+ // ─────────────────────────────────────────────────────────────────────────────
872
+
873
+ /**
874
+ * OpenRouter 模型 ID 是 author/slug 形态;渠道 ID 常不带作者前缀(如 glm-5.3-flash),
875
+ * 无法直接查档案端点。故分两条**固定** URL:ID 含 `/` 直查单模型档案,其余走目录搜索 +
876
+ * `pickOpenRouterMatch` 精确匹配。,浏览器永远不参与 URL 拼装。
877
+ */
878
+ export function openRouterVerifySource(modelId) {
879
+ const encoded = encodeURIComponent(modelId)
880
+ if (modelId.includes('/')) {
881
+ return {
882
+ name: 'OpenRouter',
883
+ kind: 'model',
884
+ url: `https://openrouter.ai/api/v1/model/${encoded}`,
885
+ href: `https://openrouter.ai/models/${encoded}`,
886
+ }
887
+ }
888
+ return {
889
+ name: 'OpenRouter',
890
+ kind: 'search',
891
+ url: `https://openrouter.ai/api/v1/models?q=${encoded}&limit=${OPENROUTER_SEARCH_LIMIT}`,
892
+ }
893
+ }
894
+
895
+ /**
896
+ * 从搜索结果里选与输入 ID 对应的条目(列表项与单模型端点同构):
897
+ * 精确相等(id / canonical_slug / hugging_face_id)→ id 以 `/<ID>` 结尾(排除 :variant 变体
898
+ * 与其它作者的同名项)→ canonical_slug 后缀 → hugging_face_id 大小写不敏感。同名多作者取首条。
899
+ */
900
+ export function pickOpenRouterMatch(modelId, rows) {
901
+ if (!Array.isArray(rows)) return undefined
902
+ for (const row of rows) {
903
+ if (row?.id === modelId || row?.canonical_slug === modelId || row?.hugging_face_id === modelId) return row
904
+ }
905
+ for (const row of rows) {
906
+ if (typeof row?.id === 'string' && row.id.endsWith('/' + modelId)) return row
907
+ }
908
+ for (const row of rows) {
909
+ if (typeof row?.canonical_slug === 'string' && row.canonical_slug.endsWith('/' + modelId)) return row
910
+ }
911
+ for (const row of rows) {
912
+ if (typeof row?.hugging_face_id === 'string' && row.hugging_face_id.toLowerCase() === modelId.toLowerCase()) return row
913
+ }
914
+ return undefined
915
+ }
916
+
917
+ /** 单模型 JSON 的事实抽取:压成人类可读片段(与片段渲染同一通道)。一个字段都没抽到返回 []。 */
918
+ export function openRouterModelFacts(payload) {
919
+ const model = payload?.data ?? payload
920
+ if (!isPlainObject(model)) return []
921
+ const facts = []
922
+ const push = (label, value) => {
923
+ if (value !== undefined && value !== null && value !== '') facts.push(`${label}: ${String(value).trim()}`)
924
+ }
925
+ push('name', model.name)
926
+ if (Number.isFinite(model.context_length)) push('context length', String(model.context_length))
927
+ const architecture = model.architecture
928
+ if (Array.isArray(architecture?.input_modalities) && architecture.input_modalities.length > 0) push('input', architecture.input_modalities.join(', '))
929
+ if (Number.isFinite(model.top_provider?.max_completion_tokens)) push('max output', String(model.top_provider.max_completion_tokens))
930
+ const reasoning = model.reasoning
931
+ if (isPlainObject(reasoning)) {
932
+ if (Array.isArray(reasoning.supported_efforts) && reasoning.supported_efforts.length > 0) push('efforts', reasoning.supported_efforts.join(', '))
933
+ if (typeof reasoning.default_effort === 'string' && reasoning.default_effort !== '') push('default effort', reasoning.default_effort)
934
+ }
935
+ if (Array.isArray(model.supported_parameters) && model.supported_parameters.includes('reasoning_effort')) facts.push('parameter: reasoning_effort')
936
+ return facts
937
+ }
938
+
939
+ /** 抓取查证 URL:HTTPS 直连、禁重定向、10s 超时、传输 ≤4MB 且解压后 ≤8MB。返回纯文本。 */
940
+ export function fetchDocText(url, options = {}) {
941
+ return new Promise((resolve, reject) => {
942
+ let parsed
943
+ try {
944
+ parsed = new URL(url)
945
+ } catch (_) {
946
+ reject(new Error('bad-url'))
947
+ return
948
+ }
949
+ if (parsed.protocol !== 'https:' || parsed.hostname === '') {
950
+ reject(new Error('insecure'))
951
+ return
952
+ }
953
+ const request = https.request(url, {
954
+ method: 'GET',
955
+ timeout: MODEL_VERIFY_TIMEOUT_MS,
956
+ headers: {
957
+ Accept: typeof options.accept === 'string' && options.accept !== '' ? options.accept : 'application/json',
958
+ 'Accept-Encoding': 'gzip, br',
959
+ 'user-agent': typeof options.userAgent === 'string' && options.userAgent !== '' ? options.userAgent : 'dsh-service',
960
+ },
961
+ }, (response) => {
962
+ const status = response.statusCode || 0
963
+ // 禁重定向:不跟随第三方跳转(安全教义)。3xx 先于 4xx 判定,避免被当成「未收录」。
964
+ if (status >= 300 && status < 400) {
965
+ response.resume()
966
+ reject(new Error('redirect'))
967
+ return
968
+ }
969
+ if (status < 200 || status >= 400) {
970
+ response.resume()
971
+ reject(new Error(`http-status:${status}`))
972
+ return
973
+ }
974
+ const chunks = []
975
+ let bytes = 0
976
+ response.on('data', (chunk) => {
977
+ bytes += chunk.length
978
+ if (bytes > MAX_MODEL_VERIFY_BYTES) {
979
+ request.destroy()
980
+ reject(new Error('too-large'))
981
+ return
982
+ }
983
+ chunks.push(chunk)
984
+ })
985
+ response.on('error', () => reject(new Error('network')))
986
+ response.on('end', () => {
987
+ const encoding = String(response.headers['content-encoding'] || '').toLowerCase()
988
+ const body = Buffer.concat(chunks)
989
+ const finish = (text) => {
990
+ if (Buffer.byteLength(text, 'utf8') > MAX_MODELS_CATALOG_BYTES) reject(new Error('too-large'))
991
+ else resolve(text)
992
+ }
993
+ if (encoding === 'gzip') {
994
+ gunzip(body, (error, decoded) => {
995
+ if (error) reject(new Error('bad-gzip'))
996
+ else finish(decoded.toString('utf8'))
997
+ })
998
+ } else if (encoding === 'br') {
999
+ brotliDecompress(body, (error, decoded) => {
1000
+ if (error) reject(new Error('bad-br'))
1001
+ else finish(decoded.toString('utf8'))
1002
+ })
1003
+ } else {
1004
+ finish(body.toString('utf8'))
1005
+ }
1006
+ })
1007
+ })
1008
+ request.on('error', (error) => {
1009
+ reject(new Error(error?.code === 'ABORT_ERR' ? 'cancelled' : 'network'))
1010
+ })
1011
+ request.on('timeout', () => {
1012
+ request.destroy()
1013
+ reject(new Error('timeout'))
1014
+ })
1015
+ request.end()
1016
+ })
1017
+ }
1018
+
1019
+ // ─────────────────────────────────────────────────────────────────────────────
1020
+ // 写前自检
1021
+ // ─────────────────────────────────────────────────────────────────────────────
1022
+
1023
+ /**
1024
+ * 模型表全量自检(与 llm-pi-ai 的 assertServiceable 同向但更早失败,稳定错误码)。
1025
+ *
1026
+ * looseCapabilities:展示判定专用。内置渠道的能力字段由目录继承(官方 resolveRouteModels
1027
+ * 对缺失/空值回落目录 base),空值/null/省略都不构成「配置有误」;写路径一律严格。
1028
+ */
1029
+ export function selfCheckModelsPatch(models, { looseCapabilities = false } = {}) {
1030
+ const errors = []
1031
+ if (!Array.isArray(models) || models.length === 0) return { ok: false, errors: ['empty-models'] }
1032
+ const seen = new Set()
1033
+ for (const [index, model] of models.entries()) {
1034
+ const label = `#${index + 1}`
1035
+ const id = model?.id
1036
+ if (typeof id !== 'string' || id.trim() === '' || id.length > MAX_MODEL_ID_LENGTH || !MODEL_ID_RE.test(id)) {
1037
+ errors.push(`${label}:${model?.id ?? ''}|bad-id`)
1038
+ continue
1039
+ }
1040
+ if (seen.has(id)) {
1041
+ errors.push(`${label}:${id}|duplicate`)
1042
+ continue
1043
+ }
1044
+ seen.add(id)
1045
+ if (model.name !== undefined && (typeof model.name !== 'string' || model.name.length > 128)) errors.push(`${label}:${id}|bad-name`)
1046
+ if (model.contextWindow !== undefined && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) errors.push(`${label}:${id}|bad-context`)
1047
+ if (model.maxTokens !== undefined && (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) errors.push(`${label}:${id}|bad-max-tokens`)
1048
+ if (looseCapabilities) continue
1049
+ if (model.input !== undefined) {
1050
+ if (!Array.isArray(model.input) || model.input.length === 0 || model.input.some((mode) => mode !== 'text' && mode !== 'image')) {
1051
+ errors.push(`${label}:${id}|bad-input`)
1052
+ }
1053
+ }
1054
+ if (model.reasoningEfforts !== undefined && model.reasoningEfforts !== false) {
1055
+ const efforts = model.reasoningEfforts
1056
+ if (!isPlainObject(efforts)) {
1057
+ errors.push(`${label}:${id}|bad-efforts`)
1058
+ } else {
1059
+ let hasLevel = false
1060
+ for (const [level, wire] of Object.entries(efforts)) {
1061
+ if (!THINKING_LEVELS_SET.has(level)) {
1062
+ errors.push(`${label}:${id}|bad-efforts`)
1063
+ break
1064
+ }
1065
+ // off 宽容:null 或空串都表示「支持关闭、不发送参数」(schemastery 归一化可能给空串)。
1066
+ if (wire !== null && !(level === 'off' && wire === '')) {
1067
+ if (typeof wire !== 'string' || wire.length === 0) {
1068
+ errors.push(`${label}:${id}|bad-efforts`)
1069
+ break
1070
+ }
1071
+ if (level !== 'off') hasLevel = true
1072
+ }
1073
+ }
1074
+ if (!hasLevel) errors.push(`${label}:${id}|no-effort-level`)
1075
+ }
1076
+ }
1077
+ }
1078
+ if (models.length > MAX_MODELS_PROFILE_ROWS) errors.push('too-many-models')
1079
+ return { ok: errors.length === 0, errors }
1080
+ }
1081
+
1082
+ /** 目录条目 → settings 模型补丁(零推导)。 */
1083
+ export function modelPatchFromPiAi(record) {
1084
+ return {
1085
+ id: record.id ?? '',
1086
+ ...(typeof record.name === 'string' && record.name !== '' ? { name: record.name } : {}),
1087
+ ...(Number.isInteger(record.contextWindow) ? { contextWindow: record.contextWindow } : {}),
1088
+ ...(Number.isInteger(record.maxTokens) ? { maxTokens: record.maxTokens } : {}),
1089
+ ...(Array.isArray(record.input) && record.input.length > 0 ? { input: [...record.input] } : {}),
1090
+ ...(record.reasoningEfforts !== undefined ? { reasoningEfforts: { ...record.reasoningEfforts } } : {}),
1091
+ }
1092
+ }
1093
+
1094
+ /** 新建路由的 profile(凭据先行:storesKey 为真才写 apiKeyEnv 引用)。 */
1095
+ export function buildRouteCreateProfile({ route, displayName, api, baseURL, models, storesKey }) {
1096
+ return {
1097
+ ...(typeof displayName === 'string' && displayName !== '' ? { displayName: displayName.slice(0, 128) } : {}),
1098
+ ...(storesKey ? { apiKeyEnv: deriveKeyRefFor(route) } : {}),
1099
+ api,
1100
+ baseURL,
1101
+ models,
1102
+ }
1103
+ }
1104
+
1105
+ /** 显示名 → kebab-case route 建议;不合法回落 'provider',与 taken 冲突时追加 -2/-3…。 */
1106
+ export function suggestRouteId(displayName, taken = []) {
1107
+ const takenSet = new Set(Array.isArray(taken) ? taken : [])
1108
+ const baseRaw = String(displayName ?? '')
1109
+ .toLowerCase()
1110
+ .replace(/[^a-z0-9]+/g, '-')
1111
+ .replace(/^-+|-+$/g, '')
1112
+ .slice(0, 64)
1113
+ .replace(/-+$/g, '')
1114
+ const base = MODELS_ROUTE_RE.test(baseRaw) ? baseRaw : 'provider'
1115
+ let candidate = base
1116
+ let counter = 2
1117
+ while (takenSet.has(candidate)) {
1118
+ candidate = `${base}-${counter}`
1119
+ counter += 1
1120
+ }
1121
+ return candidate
1122
+ }
1123
+
1124
+ // ─────────────────────────────────────────────────────────────────────────────
1125
+ // 仲裁:现有配置 × 网关实时列表 × 目录元数据 → 分类建议清单
1126
+ // ─────────────────────────────────────────────────────────────────────────────
1127
+
1128
+ const valueEquals = (a, b) => stableStringify(a) === stableStringify(b)
1129
+
1130
+ function sourceOf(meta) {
1131
+ return {
1132
+ source: meta.source,
1133
+ providerId: meta.providerId,
1134
+ ...(typeof meta.baseUrl === 'string' ? { baseUrl: meta.baseUrl } : {}),
1135
+ confidence: meta.confidence,
1136
+ via: meta.via,
1137
+ }
1138
+ }
1139
+
1140
+ /**
1141
+ * 单路由检查仲裁。
1142
+ * - safeFill:已配置模型缺失字段且有目录值 → 高置信默认勾选、低置信 needsConfirm 不勾;
1143
+ * **同渠道内置目录已提供的字段跳过**——运行时对缺失字段回落目录 base(缺失 ≠ 能力缺失,
1144
+ * 修复 opencode-go 误报「需补思考等级」)。
1145
+ * - conflict:已配置模型与来源值不同 → 默认不勾;歧义匹配(多候选无法精确定位)时只有
1146
+ * 目录达成共识才敢建议(cpa gpt-5.6-sol 曾被空 baseUrl 的 azure 模板渠道建议改成 off/xhigh/max)。
1147
+ * - addition:未配置模型 → 默认不勾;dropped 等级挂 inexpressible。
1148
+ * - unknown:已配置但在线列表未返回 → 保留不动,**绝不建议删除**(缺席 ≠ 不支持)。
1149
+ * 置信度:目录提供方 baseURL 与路由精确同 host+路径 → high;有持久化绑定 → high;
1150
+ * 跨提供方同名且无绑定 → low(needsConfirm)。
1151
+ */
1152
+ export function arbitrateModelSuggestions({ route, routeBaseURL, currentModels, gatewayModels, resolveModelMeta, bindings = {} }) {
1153
+ const suggestions = []
1154
+ const currentById = new Map(
1155
+ (Array.isArray(currentModels) ? currentModels : [])
1156
+ .map((model) => [typeof model?.id === 'string' ? model.id : '', model])
1157
+ .filter(([id]) => id !== ''),
1158
+ )
1159
+ const gatewayIds = new Set(
1160
+ (Array.isArray(gatewayModels) ? gatewayModels : [])
1161
+ .map((row) => (typeof row?.id === 'string' ? row.id : ''))
1162
+ .filter((id) => id !== ''),
1163
+ )
1164
+ const routeHost = hostOf(routeBaseURL)
1165
+ const routePath = pathOf(routeBaseURL)
1166
+
1167
+ /** 带地址候选池:有非空 baseUrl 候选时只留它们(模板渠道不能当路由事实源),全空才回退全部。 */
1168
+ const hostedPool = (candidates) => {
1169
+ const hosted = candidates.filter((candidate) => typeof candidate?.baseUrl === 'string' && candidate.baseUrl !== '')
1170
+ return hosted.length > 0 ? hosted : candidates
1171
+ }
1172
+
1173
+ const pickMeta = (candidates, modelId) => {
1174
+ if (candidates.length === 0) return undefined
1175
+ const binding = bindings[modelId]
1176
+ if (binding !== undefined) {
1177
+ const bound = candidates.find((candidate) => candidate.source === binding.source && candidate.providerId === binding.providerId)
1178
+ if (bound !== undefined) return { ...bound, confidence: 'high', via: 'binding' }
1179
+ }
1180
+ const pool = hostedPool(candidates)
1181
+ const exactPath = pool.filter((candidate) => {
1182
+ if (hostOf(candidate.baseUrl) !== routeHost) return false
1183
+ const candidatePath = pathOf(candidate.baseUrl)
1184
+ return candidatePath !== '' && candidatePath === routePath
1185
+ })
1186
+ if (exactPath.length === 1) return { ...exactPath[0], confidence: 'high', via: 'exact-route' }
1187
+ if (pool.length === 1) {
1188
+ const single = pool[0]
1189
+ const sameHost = hostOf(single.baseUrl) === routeHost
1190
+ return { ...single, confidence: sameHost ? 'high' : 'low', via: sameHost ? 'single-host' : 'single' }
1191
+ }
1192
+ return { ...pool[0], confidence: 'low', via: 'ambiguous' }
1193
+ }
1194
+
1195
+ /** 目录共识:给定字段在所有「定义了该字段」的候选里取值是否唯一一致(未定义的候选不表态)。 */
1196
+ const consensusOn = (candidates, field) => {
1197
+ let agreedValue
1198
+ let any = false
1199
+ for (const candidate of hostedPool(candidates)) {
1200
+ if (candidate[field] === undefined) continue
1201
+ if (!any) {
1202
+ agreedValue = candidate[field]
1203
+ any = true
1204
+ continue
1205
+ }
1206
+ if (!valueEquals(candidate[field], agreedValue)) return undefined
1207
+ }
1208
+ return any ? agreedValue : undefined
1209
+ }
1210
+
1211
+ for (const [modelId, model] of currentById) {
1212
+ const candidates = (resolveModelMeta(modelId) ?? []).filter(isPlainObject)
1213
+ const meta = pickMeta(candidates, modelId)
1214
+ if (meta === undefined) {
1215
+ suggestions.push({ sid: suggestionId({ route, modelId, kind: 'unknown' }), modelId, kind: 'unknown', selected: false })
1216
+ continue
1217
+ }
1218
+ // 同渠道内置目录(providerId === route):运行时对缺失字段回落目录 base——这些字段是
1219
+ // 「继承生效」而非「缺失」。reasoningEfforts 的继承依据是目录记录本身(reasoning:true
1220
+ // 即运行时继承思考能力,无 thinkingLevelMap 也成立)。
1221
+ const inheritedFields = new Set(META_FIELDS.filter((field) => candidates.some((candidate) => (
1222
+ candidate.source === 'pi-ai' && candidate.providerId === route
1223
+ && (candidate[field] !== undefined || (field === 'reasoningEfforts' && candidate.reasoning === true))
1224
+ ))))
1225
+ for (const field of META_FIELDS) {
1226
+ const to = meta[field]
1227
+ if (to === undefined) continue
1228
+ const from = model[field]
1229
+ if (from === undefined || from === null) {
1230
+ if (inheritedFields.has(field)) continue
1231
+ const selected = meta.confidence === 'high'
1232
+ suggestions.push({ sid: suggestionId({ route, modelId, kind: 'safeFill', field, from, to }), modelId, kind: 'safeFill', field, from, to, selected, source: sourceOf(meta) })
1233
+ } else if (!valueEquals(from, to)) {
1234
+ const consensus = meta.via === 'ambiguous' ? consensusOn(candidates, field) : undefined
1235
+ if (meta.via === 'ambiguous' && consensus === undefined) continue
1236
+ const toValue = consensus !== undefined ? consensus : to
1237
+ suggestions.push({ sid: suggestionId({ route, modelId, kind: 'conflict', field, from, to: toValue }), modelId, kind: 'conflict', field, from, to: toValue, selected: false, source: sourceOf(meta) })
1238
+ }
1239
+ }
1240
+ if (!gatewayIds.has(modelId)) {
1241
+ suggestions.push({ sid: suggestionId({ route, modelId, kind: 'unknown' }), modelId, kind: 'unknown', selected: false })
1242
+ }
1243
+ }
1244
+
1245
+ const seenIds = new Set(currentById.keys())
1246
+ for (const row of Array.isArray(gatewayModels) ? gatewayModels : []) {
1247
+ const modelId = typeof row?.id === 'string' ? row.id : ''
1248
+ if (modelId === '' || seenIds.has(modelId)) continue
1249
+ seenIds.add(modelId)
1250
+ const candidates = (resolveModelMeta(modelId) ?? []).filter(isPlainObject)
1251
+ const meta = pickMeta(candidates, modelId)
1252
+ const draft = { id: modelId }
1253
+ if (typeof row.name === 'string' && row.name !== '') draft.name = row.name
1254
+ if (Number.isInteger(row.contextWindow) && row.contextWindow > 0) draft.contextWindow = row.contextWindow
1255
+ if (Number.isInteger(row.maxTokens) && row.maxTokens > 0) draft.maxTokens = row.maxTokens
1256
+ if (meta !== undefined) {
1257
+ if (draft.name === undefined && typeof meta.name === 'string' && meta.name !== '') draft.name = meta.name
1258
+ if (draft.contextWindow === undefined && meta.contextWindow !== undefined) draft.contextWindow = meta.contextWindow
1259
+ if (draft.maxTokens === undefined && meta.maxTokens !== undefined) draft.maxTokens = meta.maxTokens
1260
+ if (meta.input !== undefined) draft.input = meta.input
1261
+ if (meta.reasoningEfforts !== undefined) draft.reasoningEfforts = meta.reasoningEfforts
1262
+ }
1263
+ suggestions.push({
1264
+ sid: suggestionId({ route, modelId, kind: 'addition' }),
1265
+ modelId,
1266
+ kind: 'addition',
1267
+ selected: false,
1268
+ draft,
1269
+ ...(meta !== undefined ? { source: sourceOf(meta) } : {}),
1270
+ ...(Array.isArray(meta?.dropped) && meta.dropped.length > 0 ? { inexpressible: meta.dropped } : {}),
1271
+ })
1272
+ }
1273
+ return suggestions
1274
+ }
1275
+
1276
+ // ─────────────────────────────────────────────────────────────────────────────
1277
+ // 补丁重放与 diff
1278
+ // ─────────────────────────────────────────────────────────────────────────────
1279
+
1280
+ /** 把选中的建议重放到「当前」模型数组:set 只改具名字段(未知键/未选字段原样保留),
1281
+ * addition 追加清洗后的草稿。任何 sid 不在清单中 → 整体拒绝(防伪造)。 */
1282
+ export function applySuggestions(currentModels, suggestions, selectedSids) {
1283
+ const bySid = new Map((Array.isArray(suggestions) ? suggestions : []).map((row) => [row.sid, row]))
1284
+ const selected = (Array.isArray(selectedSids) ? selectedSids : []).map(String)
1285
+ const unknown = selected.filter((sid) => !bySid.has(sid))
1286
+ if (unknown.length > 0) throw new Error(`unknown-suggestion:${unknown.join(',')}`)
1287
+ const next = (Array.isArray(currentModels) ? currentModels : []).map((model) => (isPlainObject(model) ? { ...model } : model))
1288
+ for (const sid of selected) {
1289
+ const suggestion = bySid.get(sid)
1290
+ if (suggestion.kind === 'addition') {
1291
+ const draft = {}
1292
+ for (const [key, value] of Object.entries(suggestion.draft ?? {})) {
1293
+ if (!SUGGESTION_INTERNAL_KEYS.has(key) && value !== undefined) draft[key] = value
1294
+ }
1295
+ if (typeof draft.id !== 'string' || draft.id === '') continue
1296
+ next.push(draft)
1297
+ continue
1298
+ }
1299
+ const target = next.find((model) => model.id === suggestion.modelId)
1300
+ if (target === undefined) continue
1301
+ target[suggestion.field] = suggestion.to
1302
+ }
1303
+ return next
1304
+ }
1305
+
1306
+ /** 两份模型清单的字段级 diff(计划预览用):changes 逐字段、added/removed 按模型。 */
1307
+ export function diffModelLists(beforeModels, afterModels) {
1308
+ const before = new Map((Array.isArray(beforeModels) ? beforeModels : []).map((model) => [model?.id, model]))
1309
+ const after = new Map((Array.isArray(afterModels) ? afterModels : []).map((model) => [model?.id, model]))
1310
+ const changes = []
1311
+ for (const [id, model] of after) {
1312
+ const previous = before.get(id)
1313
+ if (previous === undefined || !isPlainObject(previous)) continue
1314
+ const fields = new Set([...Object.keys(previous), ...Object.keys(model)])
1315
+ for (const field of fields) {
1316
+ if (field === 'id') continue
1317
+ if (!valueEquals(previous[field], model[field])) {
1318
+ changes.push({
1319
+ modelId: id,
1320
+ field,
1321
+ ...(previous[field] === undefined ? {} : { from: previous[field] }),
1322
+ ...(model[field] === undefined ? {} : { to: model[field] }),
1323
+ })
1324
+ }
1325
+ }
1326
+ }
1327
+ const added = [...after.keys()].filter((id) => !before.has(id) && typeof id === 'string')
1328
+ const removed = [...before.keys()].filter((id) => !after.has(id) && typeof id === 'string')
1329
+ return { changes, added, removed }
1330
+ }
1331
+
1332
+ /** 撤销预览:快照(写前状态)vs 当前。restorable = 本次应用造成的字段差异;addedSince/
1333
+ * removedSince = 应用之后发生的增删(默认保留)。 */
1334
+ export function undoPreviewModels(snapshotModels, currentModels) {
1335
+ const snapshot = new Map((Array.isArray(snapshotModels) ? snapshotModels : []).map((model) => [model?.id, model]))
1336
+ const current = new Map((Array.isArray(currentModels) ? currentModels : []).map((model) => [model?.id, model]))
1337
+ const restorable = []
1338
+ for (const [id, model] of current) {
1339
+ const previous = snapshot.get(id)
1340
+ if (previous === undefined || !isPlainObject(previous)) continue
1341
+ const fields = new Set([...Object.keys(previous), ...Object.keys(model)])
1342
+ for (const field of fields) {
1343
+ if (field === 'id') continue
1344
+ if (!valueEquals(previous[field], model[field])) {
1345
+ restorable.push({
1346
+ modelId: id,
1347
+ field,
1348
+ current: model[field],
1349
+ ...(previous[field] === undefined ? { remove: true } : { restoreTo: previous[field] }),
1350
+ })
1351
+ }
1352
+ }
1353
+ }
1354
+ return {
1355
+ restorable,
1356
+ addedSince: [...current.keys()].filter((id) => !snapshot.has(id) && typeof id === 'string'),
1357
+ removedSince: [...snapshot.keys()].filter((id) => !current.has(id) && typeof id === 'string'),
1358
+ }
1359
+ }
1360
+
1361
+ function currentHasId(currentModels, id) {
1362
+ return (Array.isArray(currentModels) ? currentModels : []).some((model) => model?.id === id)
1363
+ }
1364
+
1365
+ /** 撤销重放:从当前配置出发只回滚快照能证明的字段差异;post-apply 增删按旗标处理。
1366
+ * 快照是完整写前状态——两侧都存在的模型精确回到快照形状,应用后新增的字段随恢复移除。 */
1367
+ export function applyUndo(snapshotModels, currentModels, { dropAdded = false, restoreRemoved = false } = {}) {
1368
+ const snapshot = new Map((Array.isArray(snapshotModels) ? snapshotModels : []).map((model) => [model?.id, model]))
1369
+ const kept = []
1370
+ for (const raw of Array.isArray(currentModels) ? currentModels : []) {
1371
+ const previous = snapshot.get(raw?.id)
1372
+ if (previous === undefined || !isPlainObject(previous)) {
1373
+ if (dropAdded) continue
1374
+ kept.push(isPlainObject(raw) ? { ...raw } : raw)
1375
+ continue
1376
+ }
1377
+ kept.push({ ...previous })
1378
+ }
1379
+ if (restoreRemoved) {
1380
+ for (const [, model] of snapshot) {
1381
+ if (!currentHasId(currentModels, model?.id) && isPlainObject(model)) kept.push({ ...model })
1382
+ }
1383
+ }
1384
+ return kept
1385
+ }
1386
+
1387
+ // ─────────────────────────────────────────────────────────────────────────────
1388
+ // 连接字段编辑(地址 / 协议 / 显示名)与渠道快照
1389
+ // ─────────────────────────────────────────────────────────────────────────────
1390
+
1391
+ /** 连接字段写补丁:只产出**被改动**的字段,未触碰的键(含未知高级键/模型数组)不参与写入。
1392
+ * 地址值按数据写入 settings(唯一出网路径见 fetchModelsProbe 的 SSRF 护栏),不进 shell/路径。 */
1393
+ export function connectionPatch(current, next) {
1394
+ const patch = {}
1395
+ for (const field of ['displayName', 'api', 'baseURL']) {
1396
+ if (!(field in (next ?? {}))) continue
1397
+ const raw = next[field]
1398
+ const value = String(raw ?? '').trim().slice(0, field === 'baseURL' ? 2048 : 128)
1399
+ const before = typeof current?.[field] === 'string' ? current[field].trim() : ''
1400
+ if (value === before) continue
1401
+ // 空地址 = 清除该键(回落目录 baseUrl);空协议/空显示名不接受(显示名回落 route 由读取侧负责)。
1402
+ if (value === '') patch[`bad${field[0].toUpperCase()}${field.slice(1)}`] = true
1403
+ else patch[field] = value
1404
+ }
1405
+ return patch
1406
+ }
1407
+
1408
+ /** 连接快照(撤销用):只含承载连接语义的键;凭据只记引用名,密钥值永不入侧车。 */
1409
+ export function connectionSnapshotOf(profile) {
1410
+ const snapshot = { at: Date.now() }
1411
+ for (const field of ['displayName', 'api', 'baseURL', 'apiKeyEnv']) {
1412
+ if (typeof profile?.[field] === 'string' && profile[field] !== '') snapshot[field] = profile[field]
1413
+ }
1414
+ return snapshot
1415
+ }
1416
+
1417
+ /** 连接撤销重放:只回滚快照有记录的键;后续手工添加的未知键/模型数组原样保留。
1418
+ * 快照未记录某键 = 该键在写前不存在 → 恢复为「不存在」(unset),而不是猜一个默认值。 */
1419
+ export function connectionUndoOps(snapshot, current) {
1420
+ const ops = []
1421
+ for (const field of ['displayName', 'api', 'baseURL', 'apiKeyEnv']) {
1422
+ const target = typeof snapshot?.[field] === 'string' && snapshot[field] !== '' ? snapshot[field] : undefined
1423
+ const now = typeof current?.[field] === 'string' && current[field] !== '' ? current[field] : undefined
1424
+ if (target === undefined) {
1425
+ if (now !== undefined) ops.push({ op: 'unset', path: [field] })
1426
+ continue
1427
+ }
1428
+ if (now !== target) ops.push({ op: 'set', path: [field], value: target })
1429
+ }
1430
+ return ops
1431
+ }
1432
+
1433
+ /** 撤销面标记:'full'(连接+模型)/ 'models'(旧模型数组槽)/ 'removed'(渠道删除)/ 'none'。 */
1434
+ export function undoScopeOf(snapshot) {
1435
+ if (!isPlainObject(snapshot)) return 'none'
1436
+ if (snapshot.kind === 'full') return 'full'
1437
+ if (snapshot.kind === 'removed') return 'removed'
1438
+ return Array.isArray(snapshot.models) ? 'models' : 'none'
1439
+ }
1440
+
1441
+ /** 删除快照里的 profile(恢复用):缺字段/非对象即视为不可恢复。 */
1442
+ export function routeRestoreValue(snapshot) {
1443
+ const profile = snapshot?.profile
1444
+ return isPlainObject(profile) ? { ...profile } : undefined
1445
+ }
1446
+
1447
+ /**
1448
+ * 窄化能力补丁(简易添加流):只生成 `input`/`reasoningEfforts`。
1449
+ * `follow`/`undefined` = 跟随 adapter 默认(既不声明 text-only 也不声明不支持推理)——
1450
+ * 省略与显式声明语义不同,绝不能把「未表态」写成 `input:["text"]`。
1451
+ */
1452
+ export function abilityPatch({ image = 'follow', reasoning = 'follow', observed } = {}) {
1453
+ const patch = {}
1454
+ if (image === 'image') patch.input = ['text', 'image']
1455
+ else if (image === 'text') patch.input = ['text']
1456
+ else if (image === 'observed' && Array.isArray(observed?.input) && observed.input.includes('image')) patch.input = ['text', 'image']
1457
+ if (reasoning === 'unsupported') patch.reasoningEfforts = false
1458
+ else if (reasoning === 'observed' && observed?.reasoningEfforts !== undefined) patch.reasoningEfforts = observed.reasoningEfforts
1459
+ // 'observed' 但运行时没报出任何档位(如 llm 服务缺席、adapter 不表态)时**什么都不写**:
1460
+ // 观察不到 ≠ 不支持。缺了这条排除,'observed' 会掉进下面这支被当成 wire 值原样写入,
1461
+ // selfCheck 随即以 bad-efforts 整体拒绝——表现为「添加未验证模型永远失败」。
1462
+ else if (reasoning !== 'follow' && reasoning !== 'observed' && reasoning !== undefined) patch.reasoningEfforts = reasoning
1463
+ return patch
1464
+ }
1465
+
1466
+ /**
1467
+ * 运行时实测能力(`llm.resolveModelInfo` 返回)→ 归一观察值。
1468
+ *
1469
+ * `off` 的 wire 值取决于上游:pi-ai 目录里 off 缺省即「支持关闭」→ 补 `off: null`;
1470
+ * 而运行时上报的档位里 `off` 若带了自己的 wire 串(如 openai 的 `"none"`)必须原样保留,
1471
+ * 否则会把「关闭时发 none」写成「关闭时不发参数」,语义变了。
1472
+ */
1473
+ export function simpleModelObserved(info) {
1474
+ const observed = {}
1475
+ const input = info?.input ?? info?.modalities?.input
1476
+ if (Array.isArray(input) && input.includes('image')) observed.input = ['text', 'image']
1477
+ const efforts = info?.reasoning?.efforts
1478
+ if (Array.isArray(efforts) && efforts.length > 0) {
1479
+ const map = {}
1480
+ for (const effort of efforts) {
1481
+ const id = typeof effort?.id === 'string' ? effort.id : ''
1482
+ if (!THINKING_LEVELS_SET.has(id)) continue
1483
+ if (id === 'off') map.off = null
1484
+ else {
1485
+ const wire = typeof effort?.value === 'string' && effort.value !== '' ? effort.value : id
1486
+ map[id] = wire
1487
+ }
1488
+ }
1489
+ if (Object.keys(map).length > 0) observed.reasoningEfforts = map
1490
+ }
1491
+ return observed
1492
+ }
1493
+
1494
+ /** 目录候选统一选项形状(简易添加流的下拉/候选行)。 */
1495
+ export function simpleModelOption(record, source, providerId) {
1496
+ return {
1497
+ id: record.id,
1498
+ ...(record.name ? { name: record.name } : {}),
1499
+ ...(Array.isArray(record.input) && record.input.includes('image') ? { inputImage: true } : {}),
1500
+ ...(record.reasoningEfforts !== undefined ? { reasoningEfforts: record.reasoningEfforts } : {}),
1501
+ ...(Number.isInteger(record.contextWindow) && record.contextWindow > 0 ? { contextWindow: record.contextWindow } : {}),
1502
+ ...(Number.isInteger(record.maxTokens) && record.maxTokens > 0 ? { maxTokens: record.maxTokens } : {}),
1503
+ source,
1504
+ ...(typeof providerId === 'string' && providerId !== '' ? { providerId } : {}),
1505
+ }
1506
+ }
1507
+
1508
+ /** 该 route 在 pi-ai 内置目录里的模型记录(简易添加流的「内置目录」来源)。 */
1509
+ export function piRecordsForRoute(piIndex, route) {
1510
+ const out = []
1511
+ for (const records of piIndex?.byModelId?.values() ?? []) {
1512
+ for (const record of records) {
1513
+ if (record.channel === route) out.push({ ...record, id: record.id ?? record.modelId })
1514
+ }
1515
+ }
1516
+ return out
1517
+ }
1518
+
1519
+ // ─────────────────────────────────────────────────────────────────────────────
1520
+ // 侧车仓库:providers / bindings / validations / undo / recovery
1521
+ // ─────────────────────────────────────────────────────────────────────────────
1522
+
1523
+ export function emptyWorkbenchSidecar() {
1524
+ return { schemaVersion: WORKBENCH_SIDECAR_VERSION, providers: {}, removed: {}, bindings: {}, validations: {}, undo: {}, recovery: {} }
1525
+ }
1526
+
1527
+ /** 读侧车:缺文件/非法 JSON/schemaVersion 不匹配/分区非对象 → 整体 fail-closed 为空仓。 */
1528
+ export async function loadWorkbenchSidecar(dshHome) {
1529
+ try {
1530
+ const raw = JSON.parse(await readFile(join(dshHome, WORKBENCH_SIDECAR_FILE), 'utf8'))
1531
+ if (!isPlainObject(raw) || raw.schemaVersion !== WORKBENCH_SIDECAR_VERSION) return emptyWorkbenchSidecar()
1532
+ const restored = emptyWorkbenchSidecar()
1533
+ for (const key of ['providers', 'removed', 'bindings', 'validations', 'undo', 'recovery', 'disabledProviders', 'disabledModels']) {
1534
+ if (isPlainObject(raw[key])) restored[key] = raw[key]
1535
+ }
1536
+ return restored
1537
+ } catch (_) {
1538
+ return emptyWorkbenchSidecar()
1539
+ }
1540
+ }
1541
+
1542
+ /** 0600 原子写(临时文件 + rename);失败不留下半套状态。 */
1543
+ export async function saveWorkbenchSidecar(dshHome, sidecar) {
1544
+ await mkdir(dshHome, { recursive: true })
1545
+ const target = join(dshHome, WORKBENCH_SIDECAR_FILE)
1546
+ const temporary = `${target}.${randomUUID()}.tmp`
1547
+ try {
1548
+ await writeFile(temporary, JSON.stringify({ schemaVersion: WORKBENCH_SIDECAR_VERSION, ...sidecar }), { mode: 0o600 })
1549
+ await rename(temporary, target)
1550
+ } finally {
1551
+ await rm(temporary, { force: true })
1552
+ }
1553
+ }
1554
+
1555
+ /** 写串行化容器:所有分区写从同一内存快照复制、保存成功后才替换快照——并发写互不覆盖,
1556
+ * 保存失败时内存与磁盘保持一致(失败即抛,不产生半套状态)。 */
1557
+ export function createWorkbenchStore(dshHome) {
1558
+ let state = null
1559
+ let writes = Promise.resolve()
1560
+ const load = async () => {
1561
+ if (state === null) state = await loadWorkbenchSidecar(dshHome)
1562
+ return state
1563
+ }
1564
+ const mutate = (work) => {
1565
+ const result = writes.then(async () => {
1566
+ const current = structuredClone(await load())
1567
+ const outcome = await work(current)
1568
+ await saveWorkbenchSidecar(dshHome, current)
1569
+ state = current
1570
+ return outcome
1571
+ })
1572
+ writes = result.then(() => undefined, () => undefined)
1573
+ return result
1574
+ }
1575
+ return { load, mutate }
1576
+ }
1577
+
1578
+ // ─────────────────────────────────────────────────────────────────────────────
1579
+ // 句柄表:检查 / 计划 / 草稿(内存短命句柄,超限淘汰最旧)
1580
+ // ─────────────────────────────────────────────────────────────────────────────
1581
+
1582
+ function rememberHandle(map, key, value, max = WORKBENCH_MAX_HANDLES) {
1583
+ map.set(key, value)
1584
+ while (map.size > max) map.delete(map.keys().next().value)
1585
+ }
1586
+
1587
+ /** 只接受携带未过期句柄的请求;取出即消费(一次性,防重放)。 */
1588
+ function takeHandle(map, key) {
1589
+ const handle = map.get(key)
1590
+ if (handle === undefined) return undefined
1591
+ map.delete(key)
1592
+ return handle
1593
+ }
1594
+
1595
+ const handleExpired = (handle, now) => now > handle.expires
1596
+
1597
+ export {
1598
+ WORKBENCH_SIDECAR_VERSION,
1599
+ WORKBENCH_SIDECAR_FILE,
1600
+ WORKBENCH_PLAN_TTL_MS,
1601
+ WORKBENCH_MAX_HANDLES,
1602
+ MODELS_PROBE_TIMEOUT_MS,
1603
+ MAX_MODELS_PROBE_BYTES,
1604
+ MAX_MODELS_PROBE_INFLATED_BYTES,
1605
+ MAX_MODELS_PROFILE_ROWS,
1606
+ MAX_MODEL_ID_LENGTH,
1607
+ MAX_MODELS_KEY_LENGTH,
1608
+ THINKING_LEVELS_SET,
1609
+ MODELS_PROBE_PROTOCOLS,
1610
+ MODELS_PROTOCOLS,
1611
+ MODELS_ROUTE_RE,
1612
+ MODELS_KEY_REF_RE,
1613
+ MODEL_ID_RE,
1614
+ META_FIELDS,
1615
+ rememberHandle,
1616
+ takeHandle,
1617
+ handleExpired,
1618
+ }