@menghuan1918/dsh-apollo 0.4.0 → 0.5.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/src/panel.js DELETED
@@ -1,844 +0,0 @@
1
- /**
2
- * dsh-apollo (npm: @menghuan1918/dsh-apollo) —— 宿主半边只读面板(src/panel.js)。
3
- *
4
- * 注册仅限 loopback(或行配置 `trustedHosts` 列出的反向代理权威)+ 同源标志的
5
- * fenced HTTP 路由:
6
- * - `/apollo/api/capabilities`——**能力探针**:两个 CLI 后端配置的可执行文件
7
- * 在 PATH 上的解析结果与版本、以及 Codex 的 profile 名称;
8
- * - `/apollo/api/models?cli=codex|claude`——**模型自动发现**:Codex 读
9
- * `codex debug models` 的 JSON catalog,Claude 读 Anthropic Messages 的
10
- * `GET /v1/models`(凭据来自进程 env 或 Claude 设置文件,只在内存里用);
11
- * - `/apollo/api/routes`——**分档路由**:子代理模型选择白名单里的
12
- * provider/model 及其 reasoning effort(供模型分档下拉框)。
13
- * 三者都供浏览器半边(`exports["./client"]` 的 `plugins.row.config` 页面)读取。
14
- *
15
- * 为什么是「能力」而不是「配置」:设置本身已经是正式 Config(volatile),读写
16
- * 走 DSH 自己的 configForms/行配置页面(`src/settings.js` 只做一次旧文件导入),
17
- * 不再需要本包自有的配置路由。这里只暴露**从本机探测出来的、设置表单无法自己
18
- * 知道**的事实——用户手输的可执行文件名到底解析到了哪里、版本是什么、Codex 有
19
- * 哪些 profile 可选。
20
- *
21
- * 暴露面(写死在契约里,测试守护):
22
- * capabilities:`{ executables: { codex: { executable, path, version }, claude: {...} },
23
- * codexProfiles: string[] }`
24
- * models:`{ cli, models: [{ id, efforts, defaultEffort }], source, error }`
25
- * (`error` 只含 `code` 与可选 `status`)
26
- * **绝不返回配置正文,也绝不返回任何凭据**:只回可执行文件名/路径/版本行、
27
- * profile 名称、模型 id 与 effort;Claude 凭据只在本进程内拼进请求头。
28
- * 只读:GET 之外的方法一律 405,且从不读取请求体。
29
- *
30
- * 安全模型仍是 DNS-rebinding / 跨站防御而非认证(默认只信 loopback;经反向
31
- * 代理域名访问 GUI 时把该权威加入行配置 `trustedHosts`,后果上限是泄露本机
32
- * 上某个可执行文件的路径与版本号、或模型清单)。边界判定与 dsh-better-sidebar 的
33
- * src/trust-fence.ts 同源(其源头为 BSD-3-Clause 的
34
- * @deepseek-ai/dsh-client-connection api-request-trust + loopback-hostname)。
35
- *
36
- * 职责边界:路由的**注册**由 `src/settings.js` 的 `mountSettings` 负责(一处
37
- * 注册,避免 webServer 的重复 (kind,path) 抛错);本文件提供处理器、探针与
38
- * `installOwnsHostTap`(`mountSettings` 会调用它,`mountPanel` 是兼容别名,
39
- * 同一上下文重复调用只装一次)。
40
- *
41
- * 零第三方依赖:只用 node 内置模块。
42
- */
43
-
44
- import { execFile } from 'node:child_process'
45
- import { accessSync, constants, readFileSync, readdirSync, statSync } from 'node:fs'
46
- import { homedir } from 'node:os'
47
- import { delimiter, isAbsolute, join } from 'node:path'
48
- import { currentConfig, normalizeConfig } from './config.js'
49
-
50
- /** 宿主半边模块标识(用于 effect 标签与日志)。 */
51
- export const name = 'apollo-panel'
52
-
53
- /** 能力探针的完整路径。 */
54
- export const CAPABILITIES_PATH = '/apollo/api/capabilities'
55
- /** 注册在 webServer 上的前缀(精确路径由处理器自己校验)。 */
56
- export const CAPABILITIES_PREFIX = '/apollo/api'
57
-
58
- /** Codex 新版「一个 profile 一个文件」的后缀:`<CODEX_HOME>/<name>.config.toml`。 */
59
- export const CODEX_PROFILE_SUFFIX = '.config.toml'
60
-
61
- /** 版本探测的兜底超时(毫秒)。 */
62
- export const VERSION_TIMEOUT_MS = 2000
63
- /** 探针结果缓存时长(毫秒):避免每次打开设置页都 fork 两个 CLI。 */
64
- export const CAPABILITY_TTL_MS = 30_000
65
- /** 版本行最大长度(防止 CLI 打印整段 banner)。 */
66
- const VERSION_MAX_CHARS = 120
67
- /** 合法的 profile 名(同时挡住路径分隔符,名称只会被当显示文本用)。 */
68
- const PROFILE_NAME = /^[A-Za-z0-9._-]+$/
69
-
70
- // ── 模型自动发现(/apollo/api/models)────────────────────────────────────────
71
-
72
- /** 自动发现的完整路径(GET,查询参数 `cli=codex|claude`)。 */
73
- export const MODELS_PATH = '/apollo/api/models'
74
- /** 自动发现接受的 CLI(同时是查询参数取值)。 */
75
- export const DISCOVERY_CLIS = Object.freeze(['codex', 'claude'])
76
- /** 单次自动发现的兜底超时(毫秒)。 */
77
- export const DISCOVERY_TIMEOUT_MS = 10_000
78
- /** Codex 在线目录失败后 `--bundled` 离线兜底的超时(毫秒)。 */
79
- export const DISCOVERY_FALLBACK_TIMEOUT_MS = 3_000
80
- /** Claude `--effort` 的实测取值(`claude --help`;与 src/cli/claude.js 同步)。 */
81
- export const CLAUDE_EFFORTS = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max'])
82
- /** Claude 发现结果的默认 effort:五档中偏上的安全值。 */
83
- const CLAUDE_DEFAULT_EFFORT = 'high'
84
- /** Anthropic Messages 的模型清单协议版本。 */
85
- const ANTHROPIC_VERSION = '2023-06-01'
86
- /** Anthropic 模型清单单页上限(与 DSH pi-ai 的发现实现一致)。 */
87
- const ANTHROPIC_MODEL_LIMIT = 1000
88
- /** 发现结果的数量与长度上限:清单来自本机 CLI 或用户配置的端点,不能无限长。 */
89
- const MAX_MODELS = 200
90
- const MAX_MODEL_ID_CHARS = 120
91
- const MAX_EFFORT_CHARS = 32
92
- const MAX_LISTING_BYTES = 4 * 1024 * 1024
93
- /** 模型 id / effort 的安全字符集:argv 不经 shell,但仍拒绝空白、引号与控制字符。 */
94
- const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/@+\[\]-]*$/
95
- const EFFORT = /^[A-Za-z][A-Za-z0-9._-]*$/
96
-
97
- // ── 子代理分档路由(/apollo/api/routes)──────────────────────────────────────
98
-
99
- /** 分档下拉框数据源的完整路径(GET)。 */
100
- export const ROUTES_PATH = '/apollo/api/routes'
101
-
102
- /** 读一个宿主服务;不可见或抛错返回 undefined。 */
103
- function hostService(ctx, name) {
104
- try { return ctx.get?.(name) ?? undefined } catch { return undefined }
105
- }
106
-
107
- /**
108
- * 列出子代理模型选择白名单里的路由及各自的 reasoning effort。
109
- *
110
- * 白名单来自宿主 `subagentModelSelection.current()`(「插件 → 子代理 → 模型
111
- * 选择」),effort 来自 `llm.resolveModelInfo`;单条解析失败只让该条 efforts
112
- * 为空,不影响其他条目。服务缺失或白名单非法时返回未启用的空清单。
113
- * @param ctx - 插件上下文。
114
- * @returns `{ enabled, routes: [{ provider, model, efforts, defaultEffort }] }`。
115
- */
116
- export async function collectTierRoutes(ctx) {
117
- let selection
118
- try { selection = hostService(ctx, 'subagentModelSelection')?.current() } catch { selection = undefined }
119
- if (!isRecord(selection) || !Array.isArray(selection.allowedModels)) return { enabled: false, routes: [] }
120
- const llm = hostService(ctx, 'llm')
121
- const routes = await Promise.all(selection.allowedModels.map(async ({ provider, model }) => {
122
- try {
123
- const reasoning = (await llm?.resolveModelInfo?.(provider, model))?.reasoning
124
- const efforts = Array.isArray(reasoning?.efforts)
125
- ? reasoning.efforts.map((effort) => effort?.id).filter((id) => typeof id === 'string' && id !== '')
126
- : []
127
- const defaultEffort = efforts.includes(reasoning?.defaultEffort) ? reasoning.defaultEffort : ''
128
- return { provider, model, efforts, defaultEffort }
129
- } catch {
130
- return { provider, model, efforts: [], defaultEffort: '' }
131
- }
132
- }))
133
- return { enabled: selection.enabled === true, routes }
134
- }
135
-
136
- // ── 部署标志 tap ────────────────────────────────────────────────────────────
137
-
138
- /**
139
- * 部署标志 tap:在 `<head>` 起始处注入 boot 全局 `__DSH_TRANSPORT__.ownsHost`,
140
- * 使官方 client 的 `connection.isLoopback` 判定为真。
141
- *
142
- * 背景:client 对「浏览器非 loopback 页面」把 settings 镜像/作用域设为 memory
143
- * (不可用),官方没有受信反代配置口。经反向代理多设备访问时,声明「页面拥有
144
- * 宿主」让设置读写回到 host 持久化——安全边界随反代信任,仅应在受信代理下
145
- * 开启(行配置 `declareOwnsHost: true`,默认关闭)。
146
- * 脚本位于所有 head 注入行之前,先于 boot 执行。
147
- * @param html - 渲染后的 index HTML。
148
- * @returns 插入了传输全局的 HTML。
149
- */
150
- export function declareOwnsHostTap(html) {
151
- const script = '<script>globalThis.__DSH_TRANSPORT__={ownsHost:true}</script>'
152
- return /<head(?:\s[^>]*)?>/i.test(html)
153
- ? html.replace(/<head(?:\s[^>]*)?>/i, (open) => `${open}${script}`)
154
- : `${script}${html}`
155
- }
156
-
157
- /** 已经装过 tap 的插件上下文(防止 mountSettings 与 mountPanel 同时调用时装两次)。 */
158
- const tappedContexts = new WeakSet()
159
-
160
- /**
161
- * 装配 `declareOwnsHost` tap:行配置 `declareOwnsHost: true` 时给 index 注入
162
- * boot 全局(一次性;同一插件上下文重复调用只装一次)。
163
- *
164
- * 由 `src/settings.js` 的 `mountSettings` 调用——宿主行只调 mountSettings 与
165
- * mountSubagents 时 tap 也必须装上,否则反代部署下官方 client 会把设置镜像降级
166
- * 成 memory。
167
- * @param ctx - 插件上下文。
168
- * @param config - 行配置(可能含 volatile 引用)。
169
- */
170
- export function installOwnsHostTap(ctx, config) {
171
- if (normalizeConfig(config).declareOwnsHost !== true) return
172
- if (ctx !== null && typeof ctx === 'object') {
173
- if (tappedContexts.has(ctx)) return
174
- tappedContexts.add(ctx)
175
- }
176
- ctx.inject(['webServer'], (webCtx) => {
177
- webCtx.effect(() => webCtx.webServer.tapIndex(declareOwnsHostTap), 'apollo-panel: declareOwnsHost tap')
178
- })
179
- }
180
-
181
- /**
182
- * 宿主半边装配入口(兼容保留):等价于 {@link installOwnsHostTap}。
183
- *
184
- * 不注册 HTTP 路由——`/apollo/api/capabilities` 由 `src/settings.js` 的
185
- * `mountSettings` 注册(一处注册,headless 组合里没有 webServer 就自然跳过)。
186
- * 宿主行只调 `mountSettings` 即可;同时调用本函数也不会重复注入 tap。
187
- * @param ctx - 插件上下文。
188
- * @param config - 行配置(可能含 volatile 引用)。
189
- */
190
- export function mountPanel(ctx, config) {
191
- installOwnsHostTap(ctx, config)
192
- }
193
-
194
- // ── 探针:PATH 解析 / 版本 / Codex profile 名 ───────────────────────────────
195
-
196
- /** 展开 `~` 前缀(不解析其他 shell 语法)。 */
197
- function expandHome(value, home) {
198
- if (value === '~') return home
199
- if (value.startsWith('~/') || value.startsWith('~\\')) return join(home, value.slice(2))
200
- return value
201
- }
202
-
203
- /** 是否是可执行普通文件(非 win32 要求 X_OK 位)。 */
204
- function isExecutableFile(path) {
205
- try {
206
- if (!statSync(path).isFile()) return false
207
- if (process.platform !== 'win32') accessSync(path, constants.X_OK)
208
- return true
209
- } catch {
210
- return false
211
- }
212
- }
213
-
214
- /**
215
- * 解析可执行文件:绝对/相对路径按路径校验,裸名字按 PATH 查找。
216
- *
217
- * 默认值只写命令名(`codex`/`claude`),**不写死本机路径**;用户可以在设置里
218
- * 手输绝对路径或 `~/...`。
219
- * @param executable - 配置里的可执行文件名或路径。
220
- * @param options - 注入点:`env`(PATH/CODEX_HOME)、`home`(`~` 展开)、`cwd`(相对路径基准)。
221
- * @returns 可执行文件的绝对路径,或 null。
222
- */
223
- export function resolveExecutable(executable, options = {}) {
224
- const env = options.env ?? process.env
225
- const home = options.home ?? homedir()
226
- if (typeof executable !== 'string') return null
227
- const expanded = expandHome(executable.trim(), home)
228
- if (expanded === '') return null
229
- if (expanded.includes('/') || expanded.includes('\\')) {
230
- const candidate = isAbsolute(expanded) ? expanded : join(options.cwd ?? process.cwd(), expanded)
231
- return isExecutableFile(candidate) ? candidate : null
232
- }
233
- const extensions = process.platform === 'win32'
234
- ? String(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter((entry) => entry !== '')
235
- : ['']
236
- for (const dir of String(env.PATH ?? '').split(delimiter)) {
237
- if (dir === '') continue
238
- for (const extension of extensions) {
239
- const candidate = join(dir, expanded + extension)
240
- if (isExecutableFile(candidate)) return candidate
241
- }
242
- }
243
- return null
244
- }
245
-
246
- /** 取第一行非空文本做版本号:剥掉控制字符并截断。 */
247
- function parseVersion(text) {
248
- for (const raw of String(text ?? '').split('\n')) {
249
- const line = raw.replace(/[\u0000-\u001f\u007f]/g, '').trim()
250
- if (line === '') continue
251
- return line.slice(0, VERSION_MAX_CHARS)
252
- }
253
- return null
254
- }
255
-
256
- /**
257
- * 运行 `<path> --version` 取版本行(无 shell 展开,超时即杀)。
258
- *
259
- * 只有进程正常退出且确有输出时才返回版本;启动失败(EACCES/ENOEXEC/超时)
260
- * 返回 null——探针失败不该让整个能力接口失败。
261
- * @param path - 已解析的可执行文件路径。
262
- * @param options - `timeoutMs` 覆盖。
263
- * @returns 版本行,或 null。
264
- */
265
- export function probeVersion(path, options = {}) {
266
- const timeoutMs = options.timeoutMs ?? VERSION_TIMEOUT_MS
267
- return new Promise((resolve) => {
268
- execFile(path, ['--version'], {
269
- timeout: timeoutMs,
270
- killSignal: 'SIGKILL',
271
- maxBuffer: 64 * 1024,
272
- windowsHide: true,
273
- }, (error, stdout, stderr) => {
274
- if (error !== null) {
275
- resolve(null)
276
- return
277
- }
278
- resolve(parseVersion(String(stdout ?? '')) ?? parseVersion(String(stderr ?? '')))
279
- })
280
- })
281
- }
282
-
283
- /** 读取一个文本文件的所有行;缺失或不可读返回空数组。 */
284
- function readLines(path) {
285
- try {
286
- return readFileSync(path, 'utf8').split('\n')
287
- } catch {
288
- return []
289
- }
290
- }
291
-
292
- /** 是否是合法的 Codex profile 名(拒绝 `.`/`..`,名称只会当显示文本用)。 */
293
- function isProfileName(value) {
294
- return typeof value === 'string' && value !== '' && value !== '.' && value !== '..' && PROFILE_NAME.test(value)
295
- }
296
-
297
- /**
298
- * 列出 Codex profile **名称**(不读内容、不回内容)。
299
- *
300
- * 两种来源都兼容:
301
- * - 新版 `<CODEX_HOME>/<name>.config.toml`(一个 profile 一个文件);
302
- * - 旧版 `<CODEX_HOME>/config.toml` 里的 `[profiles.<name>]` 段。
303
- * `CODEX_HOME` 缺省 `<home>/.codex`。
304
- * @param options - 注入点:`env`、`home`。
305
- * @returns 排序去重后的 profile 名。
306
- */
307
- export function listCodexProfiles(options = {}) {
308
- const env = options.env ?? process.env
309
- const home = options.home ?? homedir()
310
- const configured = typeof env.CODEX_HOME === 'string' ? env.CODEX_HOME.trim() : ''
311
- const dir = configured === '' ? join(home, '.codex') : expandHome(configured, home)
312
- const names = new Set()
313
- let entries = []
314
- try {
315
- entries = readdirSync(dir, { withFileTypes: true })
316
- } catch {
317
- entries = []
318
- }
319
- for (const entry of entries) {
320
- if (!entry.isFile() || !entry.name.endsWith(CODEX_PROFILE_SUFFIX)) continue
321
- const candidate = entry.name.slice(0, -CODEX_PROFILE_SUFFIX.length)
322
- if (isProfileName(candidate)) names.add(candidate)
323
- }
324
- for (const line of readLines(join(dir, 'config.toml'))) {
325
- const match = /^\s*\[\s*profiles\s*\.\s*(?:"([^"]+)"|'([^']+)'|([^\]\s]+))\s*\]/.exec(line)
326
- if (match === null) continue
327
- const candidate = match[1] ?? match[2] ?? match[3] ?? ''
328
- if (isProfileName(candidate)) names.add(candidate)
329
- }
330
- return [...names].sort()
331
- }
332
-
333
- /** 同步调用并吞掉异常。 */
334
- function attempt(run) {
335
- try {
336
- return run()
337
- } catch {
338
- return undefined
339
- }
340
- }
341
-
342
- /** 异步调用并吞掉异常(拒绝)。 */
343
- async function attemptAsync(run) {
344
- try {
345
- return await run()
346
- } catch {
347
- return undefined
348
- }
349
- }
350
-
351
- /**
352
- * 采集能力探针数据(无缓存;`createCapabilitiesReader` 负责缓存)。
353
- *
354
- * 探针的每一步都独立失败:解析不到就是 `path: null`,版本取不到就是
355
- * `version: null`,profile 目录不存在就是空列表——接口本身不因本机缺一个 CLI
356
- * 而失败。
357
- * @param config - 当前配置(含 volatile 引用也可)。
358
- * @param deps - 注入点:`env`、`home`、`resolveExecutable`、`probeVersion`、
359
- * `listCodexProfiles`、`versionTimeoutMs`。
360
- * @returns `{ executables, codexProfiles }`(普通 JSON)。
361
- */
362
- export async function collectCapabilities(config, deps = {}) {
363
- const env = deps.env ?? process.env
364
- const home = deps.home ?? homedir()
365
- const resolve = deps.resolveExecutable ?? resolveExecutable
366
- const probe = deps.probeVersion ?? probeVersion
367
- const listProfiles = deps.listCodexProfiles ?? listCodexProfiles
368
- const options = { env, home }
369
- const backends = normalizeConfig(config).subagents
370
- const executables = {}
371
- for (const cli of ['codex', 'claude']) {
372
- const executable = backends[cli].executable
373
- const path = attempt(() => resolve(executable, options)) ?? null
374
- const version = path === null
375
- ? null
376
- : await attemptAsync(() => probe(path, { timeoutMs: deps.versionTimeoutMs ?? VERSION_TIMEOUT_MS })) ?? null
377
- executables[cli] = {
378
- executable,
379
- path: typeof path === 'string' && path !== '' ? path : null,
380
- version: typeof version === 'string' && version !== '' ? version : null,
381
- }
382
- }
383
- const profiles = attempt(() => listProfiles(options))
384
- return {
385
- executables,
386
- codexProfiles: Array.isArray(profiles) ? profiles.filter(isProfileName) : [],
387
- }
388
- }
389
-
390
- /** 探针缓存的键:两个可执行文件名 + PATH/CODEX_HOME/home 的变化都要失效。 */
391
- function capabilityKey(config, deps) {
392
- const env = deps.env ?? process.env
393
- const backends = normalizeConfig(config).subagents
394
- return JSON.stringify([
395
- backends.codex.executable,
396
- backends.claude.executable,
397
- env.PATH ?? '',
398
- env.CODEX_HOME ?? '',
399
- deps.home ?? '',
400
- ])
401
- }
402
-
403
- /**
404
- * 造一个带 TTL 缓存与并发去重的探针读取器。
405
- *
406
- * 设置页每次挂载都会拉一次能力;同键的并发请求共享一次探测,TTL 内的重复
407
- * 请求直接回缓存,避免反复 fork CLI。
408
- * @param deps - 同 {@link collectCapabilities},另加 `ttlMs`、`now`(测试注入)。
409
- * @returns `read(config)` → 探针数据。
410
- */
411
- export function createCapabilitiesReader(deps = {}) {
412
- const ttlMs = deps.ttlMs ?? CAPABILITY_TTL_MS
413
- const now = deps.now ?? Date.now
414
- let entry
415
- let pending
416
- return async function read(config) {
417
- const key = capabilityKey(config, deps)
418
- if (entry !== undefined && entry.key === key && now() - entry.at < ttlMs) return entry.value
419
- if (pending !== undefined && pending.key === key) return pending.promise
420
- const promise = collectCapabilities(config, deps).then((value) => {
421
- entry = { key, at: now(), value }
422
- return value
423
- })
424
- pending = { key, promise }
425
- try {
426
- return await promise
427
- } finally {
428
- if (pending !== undefined && pending.promise === promise) pending = undefined
429
- }
430
- }
431
- }
432
-
433
- // ── 模型自动发现 ────────────────────────────────────────────────────────────
434
-
435
- /** 普通数据对象(数组、null、类实例都不算)。 */
436
- function isRecord(value) {
437
- return typeof value === 'object' && value !== null && !Array.isArray(value)
438
- }
439
-
440
- /** 规整模型 id:去空白、限长、拒绝空白/引号/控制字符。 */
441
- function modelIdOf(value) {
442
- if (typeof value !== 'string') return null
443
- const id = value.trim()
444
- if (id === '' || id.length > MAX_MODEL_ID_CHARS || !MODEL_ID.test(id)) return null
445
- return id
446
- }
447
-
448
- /** 规整 effort 名:去空白、限长、拒绝空白/引号/控制字符。 */
449
- function effortOf(value) {
450
- if (typeof value !== 'string') return null
451
- const effort = value.trim()
452
- if (effort === '' || effort.length > MAX_EFFORT_CHARS || !EFFORT.test(effort)) return null
453
- return effort
454
- }
455
-
456
- /**
457
- * 解析 `codex debug models` 的 JSON catalog。
458
- *
459
- * 只收 `visibility === 'list'` 的模型(隐藏条目是内部/评审模型);id 取 `slug`;
460
- * effort 来自 `supported_reasoning_levels[].effort`,默认 effort 取
461
- * `default_reasoning_level`(不在列表里时退回第一个)。
462
- * @param text - 命令 stdout。
463
- * @returns 模型数组;JSON 不可解析时返回 null(调用方报 parse 错误)。
464
- */
465
- export function parseCodexCatalog(text) {
466
- let parsed
467
- try {
468
- parsed = JSON.parse(String(text))
469
- } catch {
470
- return null
471
- }
472
- const entries = isRecord(parsed) && Array.isArray(parsed.models) ? parsed.models : []
473
- const models = []
474
- for (const entry of entries) {
475
- if (!isRecord(entry)) continue
476
- if (typeof entry.visibility === 'string' && entry.visibility !== 'list') continue
477
- const id = modelIdOf(entry.slug ?? entry.id)
478
- if (id === null || models.some((model) => model.id === id)) continue
479
- const efforts = []
480
- const levels = Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : []
481
- for (const level of levels) {
482
- if (!isRecord(level)) continue
483
- const effort = effortOf(level.effort)
484
- if (effort !== null && !efforts.includes(effort)) efforts.push(effort)
485
- if (efforts.length >= 16) break
486
- }
487
- const preferred = effortOf(entry.default_reasoning_level)
488
- models.push({
489
- id,
490
- efforts,
491
- defaultEffort: preferred !== null && efforts.includes(preferred) ? preferred : (efforts[0] ?? ''),
492
- })
493
- if (models.length >= MAX_MODELS) break
494
- }
495
- return models
496
- }
497
-
498
- /**
499
- * 解析 Anthropic Messages `GET /v1/models` 的响应。
500
- *
501
- * Claude CLI 本身没有模型清单命令,端点只回 id/display_name;effort 取
502
- * `claude --effort` 的固定五档,默认 high。
503
- * @param text - 响应正文。
504
- * @returns 模型数组;JSON 不可解析时返回 null。
505
- */
506
- export function parseClaudeModelList(text) {
507
- let parsed
508
- try {
509
- parsed = JSON.parse(String(text))
510
- } catch {
511
- return null
512
- }
513
- const entries = isRecord(parsed) && Array.isArray(parsed.data) ? parsed.data : []
514
- const models = []
515
- for (const entry of entries) {
516
- if (!isRecord(entry)) continue
517
- const id = modelIdOf(entry.id)
518
- if (id === null || models.some((model) => model.id === id)) continue
519
- models.push({ id, efforts: [...CLAUDE_EFFORTS], defaultEffort: CLAUDE_DEFAULT_EFFORT })
520
- if (models.length >= MAX_MODELS) break
521
- }
522
- return models
523
- }
524
-
525
- /**
526
- * Anthropic 模型清单 URL:根路径去掉末尾 `/` 与一个末尾 `/v1`,再接 `/v1/models`
527
- * (兼容网关文档的两种写法;与 DSH pi-ai 的发现规则一致)。
528
- * @param baseURL - `ANTHROPIC_BASE_URL`(已去空白)。
529
- * @returns 清单 URL。
530
- */
531
- export function anthropicListingUrl(baseURL) {
532
- const base = String(baseURL).trim().replace(/\/+$/, '')
533
- const root = base.endsWith('/v1') ? base.slice(0, -3) : base
534
- return `${root}/v1/models?limit=${String(ANTHROPIC_MODEL_LIMIT)}`
535
- }
536
-
537
- /** 读取 Claude 设置文件(可注入 readFile;缺失/坏 JSON 都当空对象)。 */
538
- function readClaudeSettings(path, readFile) {
539
- try {
540
- const parsed = JSON.parse(String(readFile(path)))
541
- return isRecord(parsed) ? parsed : {}
542
- } catch {
543
- return {}
544
- }
545
- }
546
-
547
- /**
548
- * 读取 Claude 的有效端点与凭据:`ANTHROPIC_BASE_URL` 逐键回退(进程 env →
549
- * Claude 用户设置 `<CLAUDE_CONFIG_DIR 或 ~/.claude>/settings.json` 的 `env`),
550
- * **凭据来源整体优先**——进程 env 里出现任一凭据时整套用 env 的凭据,否则整套
551
- * 用 settings;不把两边的 token/key 各取一半。
552
- *
553
- * **凭据只在内存里用于请求头,绝不进入响应**;本函数导出仅为测试。
554
- * @param options - `env`、`home`、`readFile` 注入点。
555
- * @returns `{ baseURL, authToken, apiKey }`(后两者可能为 undefined)。
556
- */
557
- export function claudeApiConfig(options = {}) {
558
- const env = options.env ?? process.env
559
- const home = options.home ?? homedir()
560
- const readFile = options.readFile ?? ((file) => readFileSync(file, 'utf8'))
561
- const configured = typeof env.CLAUDE_CONFIG_DIR === 'string' && env.CLAUDE_CONFIG_DIR.trim() !== ''
562
- ? expandHome(env.CLAUDE_CONFIG_DIR.trim(), home)
563
- : join(home, '.claude')
564
- const settings = readClaudeSettings(join(configured, 'settings.json'), readFile)
565
- const settingsEnv = isRecord(settings.env) ? settings.env : {}
566
- const read = (source, key) => {
567
- const value = source[key]
568
- return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
569
- }
570
- const baseURL = read(env, 'ANTHROPIC_BASE_URL')
571
- ?? read(settingsEnv, 'ANTHROPIC_BASE_URL')
572
- ?? 'https://api.anthropic.com'
573
- const envToken = read(env, 'ANTHROPIC_AUTH_TOKEN')
574
- const envKey = read(env, 'ANTHROPIC_API_KEY')
575
- if (envToken !== undefined || envKey !== undefined) {
576
- return { baseURL, authToken: envToken, apiKey: envToken === undefined ? envKey : undefined }
577
- }
578
- const settingsToken = read(settingsEnv, 'ANTHROPIC_AUTH_TOKEN')
579
- return {
580
- baseURL,
581
- authToken: settingsToken,
582
- apiKey: settingsToken === undefined ? read(settingsEnv, 'ANTHROPIC_API_KEY') : undefined,
583
- }
584
- }
585
-
586
- /** Claude 请求头:AUTH_TOKEN 走 Bearer,API key 走 x-api-key;都没有返回 null。 */
587
- function claudeAuthHeaders(api) {
588
- const headers = { accept: 'application/json', 'anthropic-version': ANTHROPIC_VERSION }
589
- if (api.authToken !== undefined) headers.authorization = `Bearer ${api.authToken}`
590
- else if (api.apiKey !== undefined) headers['x-api-key'] = api.apiKey
591
- else return null
592
- return headers
593
- }
594
-
595
- /** 统一成功结果。 */
596
- function discovered(cli, models, source) {
597
- return { cli, models, source, error: null }
598
- }
599
-
600
- /** 统一失败结果(models 为空,error 只含 code/status)。 */
601
- function discoveryFailed(cli, code, status) {
602
- return {
603
- cli,
604
- models: [],
605
- source: null,
606
- error: status === undefined ? { code } : { code, status },
607
- }
608
- }
609
-
610
- /**
611
- * 发现 Claude 模型:`GET {ANTHROPIC_BASE_URL}/v1/models`。
612
- *
613
- * 只读凭据来源与 CLI 相同(见 {@link claudeApiConfig}),但不 spawn CLI;
614
- * 响应有字节上限,错误只回 code/status,不回凭据或 URL。
615
- * @param deps - `env`、`home`、`readFile`、`fetch`、`timeoutMs` 注入点。
616
- * @returns `{ cli, models, source, error }`。
617
- */
618
- export async function discoverClaudeModels(deps = {}) {
619
- const api = claudeApiConfig(deps)
620
- const headers = claudeAuthHeaders(api)
621
- if (headers === null) return discoveryFailed('claude', 'no-credentials')
622
- const fetcher = deps.fetch ?? globalThis.fetch
623
- if (typeof fetcher !== 'function') return discoveryFailed('claude', 'network')
624
- const timeoutMs = deps.timeoutMs ?? DISCOVERY_TIMEOUT_MS
625
- let response
626
- try {
627
- response = await fetcher(anthropicListingUrl(api.baseURL), {
628
- method: 'GET',
629
- headers,
630
- // 不跟随重定向:跨域重定向时自定义鉴权头的剥离规则不一致,直接失败更安全。
631
- redirect: 'error',
632
- signal: AbortSignal.timeout(timeoutMs),
633
- })
634
- } catch {
635
- return discoveryFailed('claude', 'network')
636
- }
637
- if (response.status === 401 || response.status === 403) {
638
- return discoveryFailed('claude', 'unauthorized', response.status)
639
- }
640
- if (response.ok !== true) return discoveryFailed('claude', 'http', response.status)
641
- const declared = Number(response.headers?.get?.('content-length') ?? NaN)
642
- if (Number.isFinite(declared) && declared > MAX_LISTING_BYTES) return discoveryFailed('claude', 'too-large')
643
- let text
644
- try {
645
- text = String(await response.text())
646
- } catch {
647
- return discoveryFailed('claude', 'network')
648
- }
649
- if (Buffer.byteLength(text) > MAX_LISTING_BYTES) return discoveryFailed('claude', 'too-large')
650
- const models = parseClaudeModelList(text)
651
- if (models === null) return discoveryFailed('claude', 'parse')
652
- if (models.length === 0) return discoveryFailed('claude', 'empty')
653
- return discovered('claude', models, 'claude-models-api')
654
- }
655
-
656
- /** 运行 `codex debug models`:成功返回 stdout,失败/超时返回 undefined。 */
657
- function runCodexOutput(path, args, options = {}) {
658
- const timeoutMs = options.timeoutMs ?? DISCOVERY_TIMEOUT_MS
659
- return new Promise((resolve) => {
660
- execFile(path, args, {
661
- timeout: timeoutMs,
662
- killSignal: 'SIGKILL',
663
- maxBuffer: MAX_LISTING_BYTES,
664
- windowsHide: true,
665
- }, (error, stdout) => {
666
- if (error !== null) {
667
- resolve(undefined)
668
- return
669
- }
670
- const text = String(stdout ?? '')
671
- resolve(Buffer.byteLength(text) > MAX_LISTING_BYTES ? undefined : text)
672
- })
673
- })
674
- }
675
-
676
- /**
677
- * 发现 Codex 模型:本机 `codex debug models`(在线目录);失败时退回
678
- * `--bundled`(离线内置目录)。
679
- *
680
- * 目录跟随 `~/.codex/config.toml` 的 model_provider,所以填进来的 id 与 CLI
681
- * 实际可用模型一致;stdout 只含 JSON(告警走 stderr)。
682
- * @param config - 行配置(取 codex 的 executable)。
683
- * @param deps - `env`、`home`、`resolveExecutable`、`runCommand`、`timeoutMs`、
684
- * `fallbackTimeoutMs` 注入点。
685
- * @returns `{ cli, models, source, error }`。
686
- */
687
- export async function discoverCodexModels(config, deps = {}) {
688
- const backend = normalizeConfig(config).subagents.codex
689
- const env = deps.env ?? process.env
690
- const home = deps.home ?? homedir()
691
- const resolve = deps.resolveExecutable ?? resolveExecutable
692
- const run = deps.runCommand ?? runCodexOutput
693
- const path = attempt(() => resolve(backend.executable, { env, home }))
694
- if (typeof path !== 'string' || path === '') return discoveryFailed('codex', 'missing')
695
- let text = await run(path, ['debug', 'models'], { timeoutMs: deps.timeoutMs ?? DISCOVERY_TIMEOUT_MS })
696
- if (text === undefined) {
697
- text = await run(path, ['debug', 'models', '--bundled'], {
698
- timeoutMs: deps.fallbackTimeoutMs ?? DISCOVERY_FALLBACK_TIMEOUT_MS,
699
- })
700
- }
701
- if (text === undefined) return discoveryFailed('codex', 'failed')
702
- const models = parseCodexCatalog(text)
703
- if (models === null) return discoveryFailed('codex', 'parse')
704
- if (models.length === 0) return discoveryFailed('codex', 'empty')
705
- return discovered('codex', models, 'codex-catalog')
706
- }
707
-
708
- /**
709
- * 按 CLI 分派模型发现。
710
- * @param cli - `codex` 或 `claude`(调用方已校验)。
711
- * @param config - 行配置。
712
- * @param deps - 两个发现函数的注入点合集。
713
- * @returns `{ cli, models, source, error }`。
714
- */
715
- export async function discoverModels(cli, config, deps = {}) {
716
- if (cli === 'codex') return discoverCodexModels(config, deps)
717
- if (cli === 'claude') return discoverClaudeModels(deps)
718
- throw new Error(`unknown discovery cli: ${String(cli)}`)
719
- }
720
-
721
- // ── 安检(loopback Host + 同源标志)─────────────────────────────────────────
722
- // 改编自 dsh-better-sidebar src/trust-fence.ts(其源头为 BSD-3-Clause 的
723
- // @deepseek-ai/dsh-client-connection api-request-trust + loopback-hostname)。
724
-
725
- function headerOf(req, name) {
726
- const value = req.headers?.[name]
727
- return typeof value === 'string' ? value : undefined
728
- }
729
-
730
- function parseAuthority(authority) {
731
- try {
732
- return new URL(`http://${authority}`)
733
- } catch {
734
- return undefined
735
- }
736
- }
737
-
738
- function isLoopbackHostname(hostname) {
739
- if (hostname === 'localhost' || hostname === '[::1]') return true
740
- const parts = hostname.split('.')
741
- return parts.length === 4
742
- && parts[0] === '127'
743
- && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
744
- }
745
-
746
- /** Host 是 loopback(或配置的受信权威)且浏览器标志同源。 */
747
- function isTrustedApiRequest(req, trustedHosts) {
748
- const host = headerOf(req, 'host')
749
- if (host === undefined) return false
750
- const hostUrl = parseAuthority(host)
751
- if (hostUrl === undefined) return false
752
- const trusted = trustedHosts.some((entry) => {
753
- const entryUrl = parseAuthority(entry)
754
- return entryUrl !== undefined && entryUrl.host === hostUrl.host
755
- })
756
- if (!isLoopbackHostname(hostUrl.hostname) && !trusted) return false
757
- if (headerOf(req, 'sec-fetch-site') === 'cross-site') return false
758
- const origin = headerOf(req, 'origin')
759
- if (origin === undefined) return true
760
- try {
761
- return new URL(origin).host === hostUrl.host
762
- } catch {
763
- return false
764
- }
765
- }
766
-
767
- // ── 路由 ─────────────────────────────────────────────────────────────────────
768
-
769
- function writeJson(res, status, body) {
770
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
771
- res.end(JSON.stringify(body))
772
- }
773
-
774
- /**
775
- * 注册只读 `/apollo/api/capabilities` 与 `/apollo/api/models` 路由
776
- * (由 `src/settings.js` 调用)。
777
- *
778
- * 信任边界按请求重读活配置的 `trustedHosts`(反代部署下改完即时生效),读不到
779
- * 活配置时退回 `configFor` 的规整值。发现接口只回模型 id/efforts,绝不回
780
- * Claude 的端点凭据。
781
- * @param ctx - 已注入 webServer 的插件上下文。
782
- * @param configFor - 返回当前配置的函数(或配置值本身)。
783
- * @param deps - 探针注入点;`read` / `discover` 可直接替换实现(测试用)。
784
- * @returns disposer 由 `ctx.effect` 持有。
785
- */
786
- export function registerCapabilitiesRoute(ctx, configFor, deps = {}) {
787
- const read = deps.read ?? createCapabilitiesReader(deps)
788
- const discover = deps.discover ?? discoverModels
789
- const routes = deps.routes ?? (() => collectTierRoutes(ctx))
790
- const configOf = () => (typeof configFor === 'function' ? attempt(configFor) : configFor)
791
- ctx.effect(() => ctx.webServer.register({
792
- kind: 'prefix',
793
- path: CAPABILITIES_PREFIX,
794
- handler: async (req, res) => {
795
- try {
796
- const config = currentConfig(ctx, configOf())
797
- if (!isTrustedApiRequest(req, config.trustedHosts)) {
798
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
799
- return
800
- }
801
- const url = new URL(req.url ?? '/', 'http://dsh.internal')
802
- if (url.pathname === ROUTES_PATH) {
803
- if (req.method !== 'GET') {
804
- writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
805
- return
806
- }
807
- const value = await routes()
808
- res.setHeader?.('cache-control', 'no-store')
809
- writeJson(res, 200, { ok: true, value })
810
- return
811
- }
812
- if (url.pathname === MODELS_PATH) {
813
- if (req.method !== 'GET') {
814
- writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
815
- return
816
- }
817
- const cli = url.searchParams.get('cli')
818
- if (!DISCOVERY_CLIS.includes(cli)) {
819
- writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'unknown cli' } })
820
- return
821
- }
822
- const value = await discover(cli, config)
823
- // 发现是按需触发的探测,不能被浏览器缓存成旧清单。
824
- res.setHeader?.('cache-control', 'no-store')
825
- writeJson(res, 200, { ok: true, value })
826
- return
827
- }
828
- if (url.pathname !== CAPABILITIES_PATH) {
829
- writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown apollo API path' } })
830
- return
831
- }
832
- if (req.method !== 'GET') {
833
- writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
834
- return
835
- }
836
- writeJson(res, 200, { ok: true, value: await read(config) })
837
- } catch (error) {
838
- // 探针已经各自吞掉失败;这里只剩意外错误——日志留细节,响应只给通用文案。
839
- ctx.logger?.warn?.(`apollo-panel: probe failed: ${error instanceof Error ? error.message : String(error)}`)
840
- writeJson(res, 500, { ok: false, error: { code: 'probe-failed', message: 'capability probe failed' } })
841
- }
842
- },
843
- }), 'apollo-panel: /apollo/api capabilities route')
844
- }