dsh-model-doctor 0.3.0

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.
package/lib/index.js ADDED
@@ -0,0 +1,731 @@
1
+ /**
2
+ * dsh-model-doctor — Host half.
3
+ *
4
+ * 为对话输入区模型选择菜单提供三项能力的回环 RPC 通道(挂在前缀路由上,与
5
+ * dsh-reasoning-effort 同一套信封协议):
6
+ *
7
+ * directory 权威模型目录(llm 服务),客户端用它把 DOM 行映射回 provider/model
8
+ * check 对单个模型直连网关发一次 1-token 探测,回报连通性/欠费/限流/鉴权
9
+ * checkAll 并发探测全部已配置模型的连通状况(附带 siliconflow 余额)
10
+ * deleteModel 从 llm-pi-ai 用户配置里移除一个模型(写前自动备份配置文档)
11
+ *
12
+ * 本半永不记录密钥内容;密钥仅按次从凭据 refs(.credentials.yaml)或进程环境解析,
13
+ * 只在内存中拼进 Authorization 头。
14
+ *
15
+ * @module dsh-model-doctor
16
+ */
17
+
18
+ import { copyFileSync, readdirSync, unlinkSync } from 'node:fs'
19
+ import { readFileSync } from 'node:fs'
20
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
21
+ import { basename, dirname, join } from 'node:path'
22
+ import { homedir } from 'node:os'
23
+
24
+ export const name = 'dsh-model-doctor'
25
+
26
+ /**
27
+ * 硬依赖:settings(读/写 llm-pi-ai 配置)与 llm(权威模型目录)。
28
+ * connection/webServer 仅 Web profile 存在,通道经 ctx.inject 挂载,不进这里。
29
+ */
30
+ export const inject = ['settings', 'llm']
31
+
32
+ /** 承载各供应商网关路由的 DSH 配置命名空间。 */
33
+ const LLM_NS = 'llm-pi-ai'
34
+ /** 与浏览器半共享的回环 RPC 前缀路由(保留:信封协议通道)。 */
35
+ const RPC_CHANNEL = '/dsh-model-doctor'
36
+ /**
37
+ * /api 共享通道下的路由基路径。这是桌面端浏览器半实测可达的机制
38
+ * (同 dsh-memory-board:宿主 ctx.connection.fetch.register + 客户端裸 fetch)。
39
+ */
40
+ const API_BASE = '/api/model-doctor'
41
+ /** 单模型探测超时。 */
42
+ const PING_TIMEOUT_MS = 15000
43
+ /** checkAll 并发上限。 */
44
+ const CHECK_ALL_CONCURRENCY = 4
45
+ /** 配置文档备份保留份数。 */
46
+ const BACKUP_KEEP = 5
47
+
48
+ /* ------------------------------------------------------------------ */
49
+ /* 通用小工具 */
50
+ /* ------------------------------------------------------------------ */
51
+
52
+ function isRecord(value) {
53
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
54
+ }
55
+
56
+ function okResult(value) {
57
+ return { ok: true, value }
58
+ }
59
+
60
+ function failResult(code, message) {
61
+ // `details` 是连接信封的一部分:浏览器半会拒绝 details 非对象的失败结果。
62
+ return { ok: false, error: { code, message, details: {} } }
63
+ }
64
+
65
+ /** 深拷贝(结构化克隆语义的朴素实现,配置对象都是 plain JSON)。 */
66
+ function deepClone(value) {
67
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
68
+ }
69
+
70
+ /* ------------------------------------------------------------------ */
71
+ /* 凭据解析:.credentials.yaml refs 段 + 进程环境 */
72
+ /* ------------------------------------------------------------------ */
73
+
74
+ let refsCache = null
75
+ let refsCacheAt = 0
76
+
77
+ /** 解析 refs 段(扁平 KEY: value),缓存 60 秒;只缓存在本进程内存里。 */
78
+ function credentialRefs() {
79
+ if (refsCache !== null && Date.now() - refsCacheAt < 60000) return refsCache
80
+ refsCache = {}
81
+ refsCacheAt = Date.now()
82
+ try {
83
+ const base = process.env.DSH_HOME || join(homedir(), '.dsh')
84
+ const text = readFileSync(join(base, '.credentials.yaml'), 'utf8')
85
+ const start = text.search(/^refs:\s*$/m)
86
+ if (start < 0) return refsCache
87
+ const lines = text.slice(start).split(/\r?\n/)
88
+ for (let i = 1; i < lines.length; i += 1) {
89
+ const line = lines[i]
90
+ if (line.trim().length === 0) continue
91
+ if (!/^\s/.test(line)) break
92
+ const m = /^ {2}([A-Za-z0-9_]+):(?:\s+(.*))?$/u.exec(line)
93
+ if (m && m[2] !== undefined) {
94
+ refsCache[m[1]] = m[2].trim().replace(/^['"]|['"]$/gu, '')
95
+ }
96
+ }
97
+ } catch {
98
+ // 读不到凭据文件时按空表处理,由调用方报「缺少凭据」。
99
+ }
100
+ return refsCache
101
+ }
102
+
103
+ function resolveApiKey(envName) {
104
+ if (typeof envName !== 'string' || envName.length === 0) return undefined
105
+ const refs = credentialRefs()
106
+ if (Object.prototype.hasOwnProperty.call(refs, envName) && refs[envName].length > 0) {
107
+ return refs[envName]
108
+ }
109
+ return process.env[envName]
110
+ }
111
+
112
+ /* ------------------------------------------------------------------ */
113
+ /* llm-pi-ai 配置读取 */
114
+ /* ------------------------------------------------------------------ */
115
+
116
+ function describeRows(settings) {
117
+ try {
118
+ const rows = settings.describe()
119
+ return Array.isArray(rows) ? rows : []
120
+ } catch {
121
+ return []
122
+ }
123
+ }
124
+
125
+ function llmDescriptor(settings) {
126
+ return describeRows(settings).find((row) => isRecord(row) && row.ns === LLM_NS) ?? null
127
+ }
128
+
129
+ /** 合并视图里的 providers 路由表(内置目录 + 用户覆盖)。 */
130
+ function mergedProviders(settings) {
131
+ const value = llmDescriptor(settings)?.value
132
+ return isRecord(value) && isRecord(value.providers) ? value.providers : {}
133
+ }
134
+
135
+ /**
136
+ * 官方内置供应商的兜底路由:它们不出现在 llm-pi-ai 配置里(DeepSeek 官方账号走
137
+ * 平台适配器),但同样可以直连探测——密钥按 refs / 进程环境解析。
138
+ */
139
+ const BUILTIN_ROUTES = [
140
+ {
141
+ match: /deepseek/i,
142
+ route: { baseURL: 'https://api.deepseek.com/v1', apiKeyEnv: 'DEEPSEEK_API_KEY', displayName: 'DeepSeek(官方)' },
143
+ },
144
+ ]
145
+
146
+ /** 解析一个 provider 的网关路由:llm-pi-ai 配置优先,其次官方内置兜底。 */
147
+ function routeFor(settings, providerId) {
148
+ const configured = mergedProviders(settings)[providerId]
149
+ if (isRecord(configured)) return configured
150
+ for (const entry of BUILTIN_ROUTES) {
151
+ if (entry.match.test(String(providerId))) return entry.route
152
+ }
153
+ return undefined
154
+ }
155
+
156
+ /** 用户层(patch 文档)里的 llm-pi-ai 配置。 */
157
+ function userConfig(settings) {
158
+ const user = llmDescriptor(settings)?.user
159
+ return isRecord(user) ? user : {}
160
+ }
161
+
162
+ function modelArrayOf(route) {
163
+ if (!isRecord(route) || !Array.isArray(route.models)) return []
164
+ return route.models
165
+ }
166
+
167
+ function modelIdOf(entry) {
168
+ return isRecord(entry) ? entry.id : entry
169
+ }
170
+
171
+ /* ------------------------------------------------------------------ */
172
+ /* 连通探测 */
173
+ /* ------------------------------------------------------------------ */
174
+
175
+ function classifyStatus(status) {
176
+ if (status === 401 || status === 402 || status === 403) return 'auth'
177
+ if (status === 429) return 'quota'
178
+ if (status === 404) return 'notfound'
179
+ if (status >= 500) return 'server'
180
+ return 'badrequest'
181
+ }
182
+
183
+ function safeJson(text) {
184
+ try {
185
+ return JSON.parse(text)
186
+ } catch {
187
+ return undefined
188
+ }
189
+ }
190
+
191
+ function extractMessage(parsed, text) {
192
+ if (isRecord(parsed)) {
193
+ const error = parsed.error
194
+ if (typeof error === 'string') return error
195
+ if (isRecord(error)) {
196
+ const code = typeof error.code === 'string' || typeof error.code === 'number' ? ` [${error.code}]` : ''
197
+ if (typeof error.message === 'string' && error.message.length > 0) return `${error.message}${code}`
198
+ }
199
+ if (typeof parsed.message === 'string' && parsed.message.length > 0) return parsed.message
200
+ }
201
+ return (text ?? '').slice(0, 200)
202
+ }
203
+
204
+ /**
205
+ * 直连网关发一次最小 chat 请求。200 = 连通;4xx/5xx 的状态码与报文能直接
206
+ * 区分欠费(403/额度类)、限流(429)、鉴权失败(401)、模型不存在(404)。
207
+ */
208
+ async function pingModel(route, model) {
209
+ const started = Date.now()
210
+ const base = (typeof route?.baseURL === 'string' ? route.baseURL : '').replace(/\/+$/u, '')
211
+ if (base.length === 0) {
212
+ return { ok: false, status: 0, kind: 'config', message: '该供应商未配置 baseURL,无法探测', ms: 0 }
213
+ }
214
+ const keyName = typeof route?.apiKeyEnv === 'string' ? route.apiKeyEnv : ''
215
+ const key = resolveApiKey(keyName)
216
+ if (key === undefined) {
217
+ return { ok: false, status: 0, kind: 'auth', message: `缺少凭据 ${keyName || '(未声明 apiKeyEnv)'}`, ms: 0 }
218
+ }
219
+
220
+ const controller = new AbortController()
221
+ const timer = setTimeout(() => controller.abort(new Error('timeout')), PING_TIMEOUT_MS)
222
+ try {
223
+ const response = await fetch(`${base}/chat/completions`, {
224
+ method: 'POST',
225
+ headers: {
226
+ 'content-type': 'application/json',
227
+ authorization: `Bearer ${key}`,
228
+ },
229
+ body: JSON.stringify({
230
+ model,
231
+ messages: [{ role: 'user', content: 'ping' }],
232
+ max_tokens: 1,
233
+ stream: false,
234
+ }),
235
+ signal: controller.signal,
236
+ })
237
+ const ms = Date.now() - started
238
+ const text = await response.text().catch(() => '')
239
+ if (response.ok) {
240
+ return { ok: true, status: response.status, kind: 'ok', message: '', ms }
241
+ }
242
+ return {
243
+ ok: false,
244
+ status: response.status,
245
+ kind: classifyStatus(response.status),
246
+ message: extractMessage(safeJson(text), text).slice(0, 300),
247
+ ms,
248
+ }
249
+ } catch (error) {
250
+ const aborted = error?.name === 'AbortError'
251
+ return {
252
+ ok: false,
253
+ status: 0,
254
+ kind: 'network',
255
+ message: aborted ? `超时(>${Math.round(PING_TIMEOUT_MS / 1000)}s 无响应)` : String(error?.message ?? error).slice(0, 300),
256
+ ms: Date.now() - started,
257
+ }
258
+ } finally {
259
+ clearTimeout(timer)
260
+ }
261
+ }
262
+
263
+ /**
264
+ * 探测账户余额:siliconflow 走 /v1/user/info,DeepSeek 官方走 /user/balance;
265
+ * 其余供应商没有公开余额端点,跳过(返回 undefined)。
266
+ */
267
+ async function probeBalance(route, providerId) {
268
+ try {
269
+ const base = (typeof route?.baseURL === 'string' ? route.baseURL : '').replace(/\/+$/u, '')
270
+ if (base.length === 0) return undefined
271
+ const deepseek = /deepseek/i.test(base) || /deepseek/i.test(String(providerId ?? ''))
272
+ if (!deepseek && !/siliconflow/i.test(base)) return undefined
273
+ const key = resolveApiKey(typeof route?.apiKeyEnv === 'string' ? route.apiKeyEnv : '')
274
+ if (key === undefined) return undefined
275
+ const root = base.replace(/\/v1$/u, '')
276
+ const target = deepseek ? `${root}/user/balance` : `${root}/v1/user/info`
277
+ const controller = new AbortController()
278
+ const timer = setTimeout(() => controller.abort(), 8000)
279
+ try {
280
+ const response = await fetch(target, {
281
+ headers: { authorization: `Bearer ${key}` },
282
+ signal: controller.signal,
283
+ })
284
+ if (!response.ok) return undefined
285
+ const parsed = safeJson(await response.text().catch(() => ''))
286
+ const balance = deepseek
287
+ ? parsed?.balance_infos?.[0]?.total_balance
288
+ : (parsed?.data?.balance ?? parsed?.balance)
289
+ return typeof balance === 'string' || typeof balance === 'number' ? String(balance) : undefined
290
+ } finally {
291
+ clearTimeout(timer)
292
+ }
293
+ } catch {
294
+ return undefined
295
+ }
296
+ }
297
+
298
+ /* ------------------------------------------------------------------ */
299
+ /* 上一次检测结果(跨菜单开关与软件重启保留,覆盖写=只留最近一次) */
300
+ /* ------------------------------------------------------------------ */
301
+
302
+ /** 结果快照文件:DSH_HOME 下(默认 ~/.dsh),单文件覆盖写。 */
303
+ function resultsFile() {
304
+ const base = process.env.DSH_HOME || join(homedir(), '.dsh')
305
+ return join(base, 'model-doctor-results.json')
306
+ }
307
+
308
+ async function readStoredResults() {
309
+ try {
310
+ const parsed = JSON.parse(await readFile(resultsFile(), 'utf8'))
311
+ return isRecord(parsed) ? parsed : null
312
+ } catch {
313
+ return null
314
+ }
315
+ }
316
+
317
+ async function writeStoredResults(snapshot) {
318
+ try {
319
+ const file = resultsFile()
320
+ await mkdir(dirname(file), { recursive: true })
321
+ await writeFile(file, JSON.stringify(snapshot), 'utf8')
322
+ return true
323
+ } catch {
324
+ return false
325
+ }
326
+ }
327
+
328
+ /* ------------------------------------------------------------------ */
329
+ /* 权威模型目录 */
330
+ /* ------------------------------------------------------------------ */
331
+
332
+ async function directoryOf(llm) {
333
+ if (llm === undefined || typeof llm.listProviders !== 'function') {
334
+ return { providers: [] }
335
+ }
336
+ // 注意:listProviders() 同步返回数组(见 llm 服务契约),不能对它挂 .catch。
337
+ let infos = []
338
+ try {
339
+ infos = await llm.listProviders()
340
+ } catch {
341
+ infos = []
342
+ }
343
+ const providers = []
344
+ for (const info of Array.isArray(infos) ? infos : []) {
345
+ if (!isRecord(info) || typeof info.id !== 'string') continue
346
+ let models = []
347
+ try {
348
+ models = await llm.listModels(info.id)
349
+ } catch {
350
+ models = []
351
+ }
352
+ providers.push({
353
+ id: info.id,
354
+ name: typeof info.name === 'string' && info.name.length > 0 ? info.name : info.id,
355
+ models: (Array.isArray(models) ? models : [])
356
+ .filter((m) => isRecord(m) && typeof m.id === 'string')
357
+ .map((m) => ({ id: m.id, name: typeof m.name === 'string' && m.name.length > 0 ? m.name : m.id })),
358
+ })
359
+ }
360
+ return { providers }
361
+ }
362
+
363
+ /* ------------------------------------------------------------------ */
364
+ /* 删除模型(写用户层配置,写前备份配置文档) */
365
+ /* ------------------------------------------------------------------ */
366
+
367
+ async function backupDocument(settings) {
368
+ try {
369
+ const doc = await settings.prepareDocument()
370
+ if (typeof doc !== 'string' || doc.length === 0) return null
371
+ const stamp = new Date().toISOString().replace(/[-:T]/gu, '').slice(0, 14)
372
+ const target = `${doc}.dsh-model-doctor-${stamp}.bak`
373
+ copyFileSync(doc, target)
374
+ try {
375
+ const dir = dirname(doc)
376
+ const head = `${basename(doc)}.dsh-model-doctor-`
377
+ const olds = readdirSync(dir)
378
+ .filter((name) => name.startsWith(head) && name.endsWith('.bak'))
379
+ .sort()
380
+ while (olds.length > BACKUP_KEEP) unlinkSync(join(dir, olds.shift()))
381
+ } catch {
382
+ // 清理旧备份失败不影响本次删除。
383
+ }
384
+ return target
385
+ } catch {
386
+ return null
387
+ }
388
+ }
389
+
390
+ /**
391
+ * 从配置中移除一个模型:
392
+ * - 用户层声明了它 → 原位删除(最后一个模型则连供应商路由一起移除);
393
+ * - 只存在于内置目录 → 用合并视图减去该模型后固化为用户覆盖;
394
+ * - 两边都找不到 → 返回 not-found(无法管理内置深层目录)。
395
+ */
396
+ async function deleteModel(settings, provider, model) {
397
+ const descriptor = llmDescriptor(settings)
398
+ if (descriptor === null) {
399
+ return { ok: false, code: 'no-config', message: '当前配置没有 llm-pi-ai 命名空间,无从删除' }
400
+ }
401
+ const user = deepClone(userConfig(settings))
402
+ const merged = isRecord(descriptor.value) ? descriptor.value : {}
403
+ if (!isRecord(user.providers)) user.providers = {}
404
+
405
+ const userRoute = isRecord(user.providers[provider]) ? user.providers[provider] : undefined
406
+ const mergedRoute = isRecord(merged.providers?.[provider]) ? merged.providers[provider] : undefined
407
+ const inUser = userRoute !== undefined && modelArrayOf(userRoute).some((m) => modelIdOf(m) === model)
408
+ const inMerged = mergedRoute !== undefined && modelArrayOf(mergedRoute).some((m) => modelIdOf(m) === model)
409
+
410
+ if (!inUser && !inMerged) {
411
+ return {
412
+ ok: false,
413
+ code: 'not-found',
414
+ message: `模型 ${model} 不在 llm-pi-ai 的 ${provider} 配置里(可能来自内置目录),无法删除`,
415
+ }
416
+ }
417
+
418
+ const backup = await backupDocument(settings)
419
+
420
+ if (inUser) {
421
+ const rest = modelArrayOf(userRoute).filter((m) => modelIdOf(m) !== model)
422
+ if (rest.length === 0) {
423
+ // 显式声明的路由不允许空 models:删到只剩零个时整条路由一起移除。
424
+ delete user.providers[provider]
425
+ } else {
426
+ userRoute.models = rest
427
+ }
428
+ } else if (mergedRoute !== undefined) {
429
+ const route = deepClone(mergedRoute)
430
+ route.models = modelArrayOf(route).filter((m) => modelIdOf(m) !== model)
431
+ if (route.models.length === 0) {
432
+ return { ok: false, code: 'last-model', message: '该模型是内置目录中此供应商的最后一个,不支持删除' }
433
+ }
434
+ user.providers[provider] = route
435
+ }
436
+
437
+ try {
438
+ await settings.replace(LLM_NS, user)
439
+ } catch (error) {
440
+ return {
441
+ ok: false,
442
+ code: 'write-failed',
443
+ message: `写入配置失败:${error instanceof Error ? error.message : String(error)}`,
444
+ }
445
+ }
446
+ return { ok: true, provider, model, backup }
447
+ }
448
+
449
+ /* ------------------------------------------------------------------ */
450
+ /* 回环 RPC 通道(信封协议与 dsh-reasoning-effort 完全一致) */
451
+ /* ------------------------------------------------------------------ */
452
+
453
+ const MAX_REQUEST_BYTES = 64 * 1024
454
+
455
+ function parseEnvelope(value) {
456
+ if (!isRecord(value) || value.type !== 'client-request') return undefined
457
+ if (typeof value.rpcId !== 'string' || value.rpcId.length === 0) return undefined
458
+ if (typeof value.method !== 'string' || value.method.length === 0) return undefined
459
+ return { rpcId: value.rpcId, method: value.method, payload: value.payload }
460
+ }
461
+
462
+ function envelopeOf(rpcId, result) {
463
+ return JSON.stringify({ type: 'server-response', rpcId, result })
464
+ }
465
+
466
+ function endpointOf(url) {
467
+ if (typeof url !== 'string') return undefined
468
+ const pathname = url.split('?')[0] ?? ''
469
+ const prefix = `${RPC_CHANNEL}/`
470
+ if (!pathname.startsWith(prefix)) return undefined
471
+ const endpoint = pathname.slice(prefix.length)
472
+ return /^[A-Za-z0-9_$.-]+$/u.test(endpoint) ? endpoint : undefined
473
+ }
474
+
475
+ function readJsonBody(request) {
476
+ return new Promise((resolve, reject) => {
477
+ const chunks = []
478
+ const decoder = new TextDecoder()
479
+ let bytes = 0
480
+ request.on('data', (chunk) => {
481
+ bytes += chunk.length
482
+ if (bytes > MAX_REQUEST_BYTES) {
483
+ reject(new Error('body too large'))
484
+ return
485
+ }
486
+ chunks.push(decoder.decode(chunk, { stream: true }))
487
+ })
488
+ request.on('end', () => {
489
+ try {
490
+ chunks.push(decoder.decode())
491
+ resolve(JSON.parse(chunks.join('')))
492
+ } catch (error) {
493
+ reject(error instanceof Error ? error : new Error('body is not JSON'))
494
+ }
495
+ })
496
+ request.on('error', (error) => {
497
+ reject(error instanceof Error ? error : new Error('request stream failed'))
498
+ })
499
+ })
500
+ }
501
+
502
+ /* ------------------------------------------------------------------ */
503
+ /* apply */
504
+ /* ------------------------------------------------------------------ */
505
+
506
+ export function apply(ctx) {
507
+ const settings = ctx.get('settings')
508
+ if (settings === undefined || typeof settings.describe !== 'function') return
509
+ const llm = ctx.get('llm')
510
+
511
+ /** 并发探测菜单里出现的全部模型(权威目录 ∪ 已配置路由)。 */
512
+ async function checkAll() {
513
+ const providers = mergedProviders(settings)
514
+ const tasks = []
515
+ const seen = new Set()
516
+ const push = (providerId, model) => {
517
+ if (typeof providerId !== 'string' || typeof model !== 'string') return
518
+ if (providerId.length === 0 || model.length === 0) return
519
+ const key = `${providerId}::${model}`
520
+ if (seen.has(key)) return
521
+ seen.add(key)
522
+ tasks.push({ provider: providerId, model })
523
+ }
524
+ try {
525
+ const listed = await directoryOf(llm)
526
+ for (const provider of listed.providers) {
527
+ for (const model of provider.models) push(provider.id, model.id)
528
+ }
529
+ } catch {
530
+ // 目录拿不到时退化为只探测配置里声明的模型。
531
+ }
532
+ for (const [providerId, route] of Object.entries(providers)) {
533
+ for (const entry of modelArrayOf(route)) push(providerId, modelIdOf(entry))
534
+ }
535
+ const results = {}
536
+ let index = 0
537
+ const worker = async () => {
538
+ while (index < tasks.length) {
539
+ const task = tasks[index]
540
+ index += 1
541
+ const route = routeFor(settings, task.provider)
542
+ results[`${task.provider}::${task.model}`] = isRecord(route)
543
+ ? await pingModel(route, task.model)
544
+ : { ok: false, status: 0, kind: 'unsupported', message: '该供应商没有可直连的网关路由,无法探测', ms: 0 }
545
+ }
546
+ }
547
+ await Promise.all(
548
+ Array.from({ length: Math.max(1, Math.min(CHECK_ALL_CONCURRENCY, tasks.length)) }, worker),
549
+ )
550
+ const balances = {}
551
+ const providerIds = new Set([...Object.keys(providers), ...tasks.map((task) => task.provider)])
552
+ for (const providerId of providerIds) {
553
+ const route = routeFor(settings, providerId)
554
+ if (!isRecord(route)) continue
555
+ const balance = await probeBalance(route, providerId)
556
+ if (balance !== undefined) balances[providerId] = balance
557
+ }
558
+ return { results, balances }
559
+ }
560
+
561
+ async function answer(endpoint, payload) {
562
+ const request = isRecord(payload) ? payload : {}
563
+ switch (endpoint) {
564
+ case 'directory': {
565
+ try {
566
+ return okResult(await directoryOf(llm))
567
+ } catch (error) {
568
+ return failResult('directory-failed', `目录获取失败:${error instanceof Error ? error.message : String(error)}`)
569
+ }
570
+ }
571
+ case 'check': {
572
+ const provider = typeof request.provider === 'string' ? request.provider : ''
573
+ const model = typeof request.model === 'string' ? request.model : ''
574
+ if (provider.length === 0 || model.length === 0) {
575
+ return failResult('invalid-request', 'provider 和 model 必填')
576
+ }
577
+ const route = routeFor(settings, provider)
578
+ if (!isRecord(route)) {
579
+ return okResult({ ok: false, status: 0, kind: 'unsupported', message: '该供应商没有可直连的网关路由,无法探测', ms: 0 })
580
+ }
581
+ try {
582
+ return okResult(await pingModel(route, model))
583
+ } catch (error) {
584
+ return failResult('check-failed', `探测失败:${error instanceof Error ? error.message : String(error)}`)
585
+ }
586
+ }
587
+ case 'checkAll': {
588
+ try {
589
+ return okResult(await checkAll())
590
+ } catch (error) {
591
+ return failResult('check-all-failed', `批量探测失败:${error instanceof Error ? error.message : String(error)}`)
592
+ }
593
+ }
594
+ case 'deleteModel': {
595
+ const provider = typeof request.provider === 'string' ? request.provider : ''
596
+ const model = typeof request.model === 'string' ? request.model : ''
597
+ if (provider.length === 0 || model.length === 0) {
598
+ return failResult('invalid-request', 'provider 和 model 必填')
599
+ }
600
+ return okResult(await deleteModel(settings, provider, model))
601
+ }
602
+ case 'resultsLoad': {
603
+ return okResult(await readStoredResults())
604
+ }
605
+ case 'resultsSave': {
606
+ if (!isRecord(request.results)) {
607
+ return failResult('invalid-request', 'results 必须是对象')
608
+ }
609
+ const snapshot = {
610
+ results: request.results,
611
+ balances: isRecord(request.balances) ? request.balances : {},
612
+ at: typeof request.at === 'number' && Number.isFinite(request.at) ? request.at : Date.now(),
613
+ }
614
+ const saved = await writeStoredResults(snapshot)
615
+ return okResult({ ok: saved, at: snapshot.at })
616
+ }
617
+ default:
618
+ return failResult('not-found', `未知端点 ${JSON.stringify(endpoint)}`)
619
+ }
620
+ }
621
+
622
+ // ── 主通道:/api 下的显式路由(桌面端浏览器半走这条;逐条注册,不靠 pathname 分派)
623
+ ctx.inject(['connection'], (routeCtx) => {
624
+ const connection = routeCtx.get('connection')
625
+ const fetchRegistry = connection?.fetch
626
+ if (fetchRegistry === undefined || typeof fetchRegistry.register !== 'function') return
627
+ routeCtx.effect(() => {
628
+ const disposers = []
629
+ for (const endpoint of ['directory', 'check', 'checkAll', 'deleteModel', 'resultsLoad', 'resultsSave']) {
630
+ try {
631
+ disposers.push(fetchRegistry.register({
632
+ path: `${API_BASE}/${endpoint}`,
633
+ methods: ['POST'],
634
+ requestBody: 'buffered',
635
+ fetch: async (request) => {
636
+ let payload = {}
637
+ try {
638
+ payload = await request.json()
639
+ } catch {
640
+ payload = {}
641
+ }
642
+ let result
643
+ try {
644
+ result = await answer(endpoint, isRecord(payload) ? payload : {})
645
+ } catch (error) {
646
+ result = failResult(
647
+ 'channel-failed',
648
+ `通道处理失败:${error instanceof Error ? error.message : String(error)}`,
649
+ )
650
+ }
651
+ return new Response(JSON.stringify(result), {
652
+ status: 200,
653
+ headers: {
654
+ 'content-type': 'application/json; charset=utf-8',
655
+ 'cache-control': 'no-store',
656
+ },
657
+ })
658
+ },
659
+ }))
660
+ } catch (error) {
661
+ console.error(
662
+ `[dsh-model-doctor] /api 路由注册失败 ${endpoint}: ${error instanceof Error ? error.message : String(error)}`,
663
+ )
664
+ }
665
+ }
666
+ return () => {
667
+ for (const dispose of disposers) {
668
+ try {
669
+ dispose?.()
670
+ } catch {
671
+ // 注销失败不影响其余路由。
672
+ }
673
+ }
674
+ }
675
+ }, 'model-doctor: /api routes')
676
+ })
677
+
678
+ // ── 备用通道:自建前缀路由 + client-request 信封(保留,不影响主通道)
679
+ ctx.inject(['connection', 'webServer'], (routeCtx) => {
680
+ const connection = routeCtx.get('connection')
681
+ const webServer = routeCtx.get('webServer')
682
+ if (connection === undefined || webServer === undefined || typeof webServer.register !== 'function') return
683
+ routeCtx.effect(() => webServer.register({
684
+ kind: 'prefix',
685
+ path: RPC_CHANNEL,
686
+ handler: async (request, response) => {
687
+ const rejection = connection.requestRejection(request)
688
+ if (rejection !== undefined) {
689
+ response.writeHead(rejection)
690
+ response.end(rejection === 401 ? 'unauthorized' : 'forbidden')
691
+ return
692
+ }
693
+ const endpoint = endpointOf(request.url)
694
+ if (request.method !== 'POST' || endpoint === undefined) {
695
+ response.writeHead(404)
696
+ response.end('not found')
697
+ return
698
+ }
699
+ const mediaType = String(request.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()
700
+ if (mediaType !== 'application/json') {
701
+ response.writeHead(415)
702
+ response.end('content type must be application/json')
703
+ return
704
+ }
705
+ let body
706
+ try {
707
+ body = await readJsonBody(request)
708
+ } catch {
709
+ response.writeHead(400)
710
+ response.end('body is not JSON')
711
+ return
712
+ }
713
+ const message = parseEnvelope(body)
714
+ if (message === undefined || message.method !== endpoint) {
715
+ response.writeHead(400)
716
+ response.end('invalid client-request message')
717
+ return
718
+ }
719
+ response.writeHead(200, { 'content-type': 'application/json' })
720
+ try {
721
+ response.end(envelopeOf(message.rpcId, await answer(endpoint, message.payload)))
722
+ } catch (error) {
723
+ response.end(envelopeOf(message.rpcId, failResult(
724
+ 'channel-failed',
725
+ `通道处理失败:${error instanceof Error ? error.message : String(error)}`,
726
+ )))
727
+ }
728
+ },
729
+ }), 'model-doctor: rpc channel')
730
+ })
731
+ }