dsh-workspace-api-key 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1294 @@
1
+ /**
2
+ * dsh-workspace-api-key — host half.
3
+ *
4
+ * 目标:让「某个工作区只用某一把 API key」成为可能,从而让计费可按工作区隔离。
5
+ *
6
+ * 机制(方案 1:包装 `credentials.resolve`):
7
+ * 1. 内置的 `@deepseek-ai/dsh-llm-deepseek-api-key` 每次请求都会拿 provider
8
+ * profile 里的 `apiKeyEnv`(默认 "DEEPSEEK_API_KEY")去调
9
+ * `credentials.resolve(ref)` 取 key;`connection` 里没有任何 per-session
10
+ * 维度(见 dsh-llm-deepseek/lib/index.js:2100-2106、dsh-llm/lib/typert.host.js:1861)。
11
+ * 2. 所以在 `resolve()` 这一层做映射:取「当前正在跑的那个 agent」的
12
+ * `session.header.cwd`,反查工作区,若该工作区配了专属 key,就用专属 ref
13
+ * 去解析;否则原样回退 → 「没特殊设置就用系统默认」。
14
+ * 3. `currentInitiator()` 是官方自己的做法(先例:
15
+ * dsh-web-search-deepseek/lib/index.js:302-325 在请求期用
16
+ * `ctx.get("agents").currentInitiator().session.requestContext()`)。
17
+ *
18
+ * 为什么是 monkey-patch 而不是替换服务:cordis 不允许第二个插件 provide 同名
19
+ * 服务(`service "credentials" has been registered at <...>`,
20
+ * cordis/lib/index.js:813),也不允许跨 fiber `ctx.set`(:786)。而
21
+ * `ctx.get()` 返回的是 traceable proxy,其 `set` trap 是
22
+ * `Reflect.set(target, prop, value, shadow)`、`get` trap 先读 own property
23
+ * (cordis/lib/index.js:123-158),所以在实例上重新赋值 `resolve` 是**所有调用方
24
+ * 都能看到**的,且用 `ctx.effect` 可以干净还原。
25
+ *
26
+ * 安全/稳健性约束:
27
+ * - wrapper 永不抛异常:内部任何失败都回退到原始 resolve。
28
+ * - 只拦「provider 真的会去读的 ref」,其它 ref 直接放行。
29
+ * - 只在 ALS 作用域里有活动 agent 时才做映射;没有就放行(后台请求等)。
30
+ * - 幂等:重复 apply 不会套娃(用 globalThis 上的 Symbol 记录)。
31
+ */
32
+
33
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
34
+ import { createHash } from 'node:crypto'
35
+ import { homedir } from 'node:os'
36
+ import { join, dirname, basename, resolve as pathResolve } from 'node:path'
37
+
38
+ export const name = 'workspace-api-key'
39
+
40
+ // cordis 的 `inject` 只支持「字符串数组」或「服务名 → 拦截配置」的对象映射
41
+ // (cordis/lib/index.js:1491-1499 `Inject.resolve`)。写成 `{required, optional}`
42
+ // 会被当成两个名叫 required/optional 的服务,插件永远停在
43
+ // "pending (waiting for services: required, optional)"。
44
+ // 这里四个都必须就位才激活:credentials 决定包装能否装上(installResolver 里
45
+ // 拿不到就直接 return),webServer 决定配置界面能否注册路由,agents 决定能否
46
+ // 读当前会话的 cwd,workspaceRegistry 决定 cwd 能否反查到工作区。
47
+ export const inject = ['webServer', 'credentials', 'agents', 'workspaceRegistry']
48
+
49
+ const API_BASE = '/wsk-api'
50
+ const TIMEOUT_MS = 20000
51
+ const DEBUG = process.env.WSK_DEBUG === '1'
52
+
53
+ // 幂等标记:同一进程里可能被 apply 多次(热重载),只装一层 wrapper。
54
+ const INSTALL_KEY = Symbol.for('dsh-workspace-api-key/installed')
55
+
56
+ function log(...args) {
57
+ if (DEBUG) console.log('[workspace-api-key]', ...args)
58
+ }
59
+
60
+ /**
61
+ * 运行中的宿主半边版本(读自己的 package.json)。
62
+ * 界面把它显示出来是为了区分「宿主半边是旧版本(例如只刷新了页面而没重启应用)」
63
+ * 和「宿主是新版本、只是拿不到会话列表」——这两种情况的修法完全不同。
64
+ */
65
+ const PLUGIN_VERSION = (() => {
66
+ try {
67
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
68
+ return typeof pkg?.version === 'string' ? pkg.version : undefined
69
+ } catch {
70
+ return undefined
71
+ }
72
+ })()
73
+
74
+ // ─────────────────────────── 路径 / 工作区键 ───────────────────────────
75
+
76
+ /** 统一的路径比较用规范形:绝对化 + 分隔符统一 + Windows 折大小写。 */
77
+ function normPath(p) {
78
+ if (typeof p !== 'string' || p === '') return undefined
79
+ let s = p
80
+ try {
81
+ s = pathResolve(s)
82
+ } catch {
83
+ /* 非法路径:退回原字符串 */
84
+ }
85
+ s = s.replace(/[/\\]+$/, '')
86
+ if (process.platform === 'win32') s = s.toLowerCase()
87
+ return s
88
+ }
89
+
90
+ /**
91
+ * 由「所属目录」派生稳定的凭据 ref。
92
+ * 用「路径」而不是「工作区 id」来派生:未分组会话(没有工作区)也能自然拥有
93
+ * 自己的覆盖项,且工作区被删除重建后仍然认得出同一把 key。
94
+ */
95
+ function derivedRef(dir) {
96
+ const norm = normPath(dir)
97
+ if (norm === undefined) return undefined
98
+ const digest = createHash('sha1').update(norm).digest('hex').slice(0, 16).toUpperCase()
99
+ return `DEEPSEEK_API_KEY_WS_${digest}`
100
+ }
101
+
102
+ /**
103
+ * 由「会话 id」派生稳定的凭据 ref(会话级覆盖用)。
104
+ * 会话 id 里可能含 `-`(ref 名不允许),所以同样取摘要。
105
+ */
106
+ function sessionRef(sessionId) {
107
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
108
+ const digest = createHash('sha1').update(sessionId).digest('hex').slice(0, 16).toUpperCase()
109
+ return `DEEPSEEK_API_KEY_SS_${digest}`
110
+ }
111
+
112
+ /** 当前正在执行的那个 agent(ALS 作用域内可达)。 */
113
+ function activeAgent(ctx) {
114
+ try {
115
+ const agents = ctx.get('agents')
116
+ if (agents === undefined || typeof agents.currentInitiator !== 'function') return undefined
117
+ return agents.currentInitiator() ?? undefined
118
+ } catch {
119
+ return undefined
120
+ }
121
+ }
122
+
123
+ /** 当前请求所属的目录(工作区路径)。 */
124
+ function activeCwd(ctx) {
125
+ const agent = activeAgent(ctx)
126
+ const session = agent?.session
127
+ const cwd = session?.header?.cwd ?? session?.requestHeader?.()?.cwd
128
+ return typeof cwd === 'string' && cwd !== '' ? cwd : undefined
129
+ }
130
+
131
+ /** 当前请求所属的会话 id。 */
132
+ function activeSessionId(ctx) {
133
+ const agent = activeAgent(ctx)
134
+ const id = agent?.session?.id
135
+ return typeof id === 'string' && id !== '' ? id : undefined
136
+ }
137
+
138
+ /** 所有已注册工作区:规范路径 → 展示信息。 */
139
+ function workspaceIndex(ctx) {
140
+ const index = new Map()
141
+ let registry
142
+ try {
143
+ registry = ctx.get('workspaceRegistry')
144
+ } catch {
145
+ registry = undefined
146
+ }
147
+ if (registry === undefined || typeof registry.list !== 'function') return index
148
+ let list = []
149
+ try {
150
+ list = registry.list() ?? []
151
+ } catch {
152
+ return index
153
+ }
154
+ for (const entity of list) {
155
+ let path, title, id
156
+ try {
157
+ path = entity?.path
158
+ title = entity?.title
159
+ id = entity?.id
160
+ } catch {
161
+ continue
162
+ }
163
+ const norm = normPath(path)
164
+ if (norm === undefined) continue
165
+ index.set(norm, {
166
+ id: typeof id === 'string' ? id : undefined,
167
+ path,
168
+ title: typeof title === 'string' && title !== '' ? title : basename(path) || path,
169
+ })
170
+ }
171
+ return index
172
+ }
173
+
174
+ /** cwd → 工作区描述;没有匹配工作区时回退成「以该目录为单位」。 */
175
+ function locateWorkspace(ctx, cwd) {
176
+ if (cwd === undefined) return undefined
177
+ const norm = normPath(cwd)
178
+ if (norm === undefined) return undefined
179
+ const hit = workspaceIndex(ctx).get(norm)
180
+ if (hit !== undefined) return { ...hit, kind: 'workspace' }
181
+ return { id: undefined, path: cwd, title: basename(cwd) || cwd, kind: 'directory' }
182
+ }
183
+
184
+ /**
185
+ * 给定会话/目录,算出当前生效的覆盖层级(纯函数,不读凭据):
186
+ * session → workspace → default。
187
+ */
188
+ function effectiveOverride(ctx, state, { sessionId, cwd }) {
189
+ if (sessionId !== undefined) {
190
+ const sref = sessionRef(sessionId)
191
+ if (sref !== undefined && state.overrides[sref] !== undefined) {
192
+ return { level: 'session', ref: sref, entry: state.overrides[sref] }
193
+ }
194
+ }
195
+ if (cwd !== undefined) {
196
+ const ws = locateWorkspace(ctx, cwd)
197
+ const wref = ws !== undefined ? derivedRef(ws.path) : undefined
198
+ if (wref !== undefined && state.overrides[wref] !== undefined) {
199
+ return { level: 'workspace', ref: wref, entry: state.overrides[wref], workspace: ws }
200
+ }
201
+ }
202
+ return { level: 'default', ref: undefined, entry: undefined }
203
+ }
204
+
205
+ // ───────────────── 会话列表(宿主 sessionController) ─────────────────
206
+
207
+ /** 宿主是否挂载了会话列表 API(决定界面能否列出会话)。 */
208
+ function sessionListAvailable(ctx) {
209
+ try {
210
+ const controller = ctx.get('sessionController')
211
+ return controller !== undefined && typeof controller.list === 'function'
212
+ } catch {
213
+ return false
214
+ }
215
+ }
216
+
217
+ /**
218
+ * 会话摘要 + 诊断信息。界面靠 available / count / error / 样本路径来解释
219
+ * 「为什么面板里看不到会话」:服务没挂载、list() 抛错、行里没有 cwd,还是
220
+ * 会话的 cwd 与工作区路径对不上。
221
+ */
222
+ async function sessionProbe(ctx) {
223
+ let controller
224
+ try {
225
+ controller = ctx.get('sessionController')
226
+ } catch (err) {
227
+ return { available: false, rows: [], count: 0, error: String(err?.message ?? err) }
228
+ }
229
+ if (controller === undefined) return { available: false, rows: [], count: 0 }
230
+ if (typeof controller.list !== 'function') {
231
+ return { available: false, rows: [], count: 0, error: 'sessionController.list is not a function' }
232
+ }
233
+ try {
234
+ const rows = await controller.list()
235
+ const list = Array.isArray(rows) ? rows : []
236
+ const samples = []
237
+ let withoutCwd = 0
238
+ for (const row of list) {
239
+ const cwd = typeof row?.cwd === 'string' && row.cwd !== '' ? row.cwd : undefined
240
+ if (cwd === undefined) {
241
+ withoutCwd += 1
242
+ continue
243
+ }
244
+ if (samples.length < 5 && !samples.includes(cwd)) samples.push(cwd)
245
+ }
246
+ log('sessionController.list ok:', list.length, 'rows,', withoutCwd, 'without cwd')
247
+ return { available: true, rows: list, count: list.length, withoutCwd, samples }
248
+ } catch (err) {
249
+ log('sessionController.list failed:', String(err?.message ?? err))
250
+ return { available: true, rows: [], count: 0, error: String(err?.message ?? err) }
251
+ }
252
+ }
253
+
254
+ /** 只要会话摘要(含冷会话)。服务缺失 / 出错时为空数组,界面退化成只列工作区。 */
255
+ async function sessionSummaries(ctx) {
256
+ return (await sessionProbe(ctx)).rows
257
+ }
258
+
259
+ /** 从摘要里取「当前会话在用的模型选择」,形状 {provider, model}。 */
260
+ function modelSelectionOf(row) {
261
+ const values = row?.projections?.values
262
+ const selection = values?.modelSelection
263
+ if (selection === undefined || selection === null) return undefined
264
+ const pick = selection.next ?? selection.lastUsed ?? (selection.provider !== undefined ? selection : undefined)
265
+ if (pick === undefined || pick === null) return undefined
266
+ const provider = typeof pick.provider === 'string' && pick.provider !== '' ? pick.provider : undefined
267
+ const model = typeof pick.model === 'string' && pick.model !== '' ? pick.model : undefined
268
+ if (provider === undefined && model === undefined) return undefined
269
+ return { provider, model }
270
+ }
271
+
272
+ /** 从摘要里取标题。 */
273
+ function titleOf(row) {
274
+ const values = row?.projections?.values
275
+ const raw = values?.title
276
+ if (typeof raw === 'string' && raw !== '') return raw
277
+ if (raw !== null && typeof raw === 'object') {
278
+ if (typeof raw.title === 'string' && raw.title !== '') return raw.title
279
+ if (typeof raw.value === 'string' && raw.value !== '') return raw.value
280
+ }
281
+ return undefined
282
+ }
283
+
284
+ /** 所有 provider 路由名(用于「这把 key 是给哪个 provider 的」选择项)。 */
285
+ function providerList(ctx) {
286
+ const names = new Set(['deepseek-official'])
287
+ try {
288
+ const llm = ctx.get('llm')
289
+ const adapters = llm?.adapters
290
+ if (adapters !== undefined && typeof adapters.keys === 'function') {
291
+ for (const name of adapters.keys()) {
292
+ if (typeof name === 'string' && name !== '') names.add(name)
293
+ }
294
+ }
295
+ } catch {
296
+ /* ignore */
297
+ }
298
+ return [...names]
299
+ }
300
+
301
+ /** 覆盖项记录的 provider 与「会话实际在用的 provider」不一致 → 不匹配。 */
302
+ function mismatchOf(entry, selection) {
303
+ const keyProvider = typeof entry?.provider === 'string' && entry.provider !== '' ? entry.provider : undefined
304
+ const sessionProvider = selection?.provider
305
+ if (keyProvider === undefined || sessionProvider === undefined) return null
306
+ if (keyProvider === sessionProvider) return null
307
+ return { keyProvider, sessionProvider, keyModel: entry?.model, sessionModel: selection?.model }
308
+ }
309
+
310
+ // ─────────────────────────── 覆盖配置(落盘) ───────────────────────────
311
+
312
+ function stateFilePath() {
313
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
314
+ return join(home, 'storages', 'dsh-workspace-api-key.json')
315
+ }
316
+
317
+ const STATE_VERSION = 2
318
+
319
+ /**
320
+ * v1 的覆盖项没有 scope/sessionId/provider 字段,读进来时补齐:
321
+ * 有 sessionId 的算会话级,其余算工作区级。
322
+ */
323
+ function normalizeState(parsed) {
324
+ const overrides = {}
325
+ const rawOverrides = typeof parsed?.overrides === 'object' && parsed.overrides !== null ? parsed.overrides : {}
326
+ for (const [ref, entry] of Object.entries(rawOverrides)) {
327
+ if (typeof entry !== 'object' || entry === null) continue
328
+ const sessionId = typeof entry.sessionId === 'string' && entry.sessionId !== '' ? entry.sessionId : undefined
329
+ overrides[ref] = {
330
+ ...entry,
331
+ ref,
332
+ scope: entry.scope === 'session' || sessionId !== undefined ? 'session' : 'workspace',
333
+ ...(sessionId === undefined ? {} : { sessionId }),
334
+ }
335
+ }
336
+ const invalid = typeof parsed?.invalid === 'object' && parsed.invalid !== null ? parsed.invalid : {}
337
+ return { version: STATE_VERSION, overrides, invalid }
338
+ }
339
+
340
+ function loadState() {
341
+ const path = stateFilePath()
342
+ try {
343
+ if (!existsSync(path)) return { version: STATE_VERSION, overrides: {}, invalid: {} }
344
+ return normalizeState(JSON.parse(readFileSync(path, 'utf8')))
345
+ } catch (err) {
346
+ console.warn('[workspace-api-key] load state failed:', String(err?.message ?? err))
347
+ return { version: STATE_VERSION, overrides: {}, invalid: {} }
348
+ }
349
+ }
350
+
351
+ function saveState(state) {
352
+ const path = stateFilePath()
353
+ try {
354
+ mkdirSync(dirname(path), { recursive: true })
355
+ writeFileSync(path, JSON.stringify(state, null, 2), 'utf8')
356
+ return true
357
+ } catch (err) {
358
+ console.warn('[workspace-api-key] save state failed:', String(err?.message ?? err))
359
+ return false
360
+ }
361
+ }
362
+
363
+ // ─────────────────────────── 凭据读写 ───────────────────────────
364
+
365
+ async function resolveKey(ctx, ref) {
366
+ if (typeof ref !== 'string' || ref === '') return { value: undefined }
367
+ let credentials
368
+ try {
369
+ credentials = ctx.get('credentials')
370
+ } catch {
371
+ credentials = undefined
372
+ }
373
+ if (credentials === undefined || typeof credentials.resolve !== 'function') return { value: undefined }
374
+ // 插件自己读 key 时必须绕过自己的 wrapper:否则「测试 alpha 的 key」会因为
375
+ // 当前活动会话是 beta 而被重定向成 beta 的 key(本插件 54/55 那条断言抓到的真 bug)。
376
+ const installed = globalThis[INSTALL_KEY]
377
+ const resolve =
378
+ installed !== undefined && installed.credentials === credentials && typeof installed.original === 'function'
379
+ ? installed.original
380
+ : credentials.resolve.bind(credentials)
381
+ try {
382
+ const hit = await resolve(ref)
383
+ return { value: hit?.value, source: hit?.source }
384
+ } catch (err) {
385
+ return { value: undefined, error: String(err?.message ?? err) }
386
+ }
387
+ }
388
+
389
+ async function describeKey(ctx, ref) {
390
+ let credentials
391
+ try {
392
+ credentials = ctx.get('credentials')
393
+ } catch {
394
+ credentials = undefined
395
+ }
396
+ if (credentials === undefined || typeof credentials.describe !== 'function') {
397
+ return { configured: false, error: 'credentials 服务不可用' }
398
+ }
399
+ try {
400
+ const info = await credentials.describe(ref)
401
+ return {
402
+ configured: info?.configured === true,
403
+ source: info?.source,
404
+ writable: info?.writable !== false,
405
+ }
406
+ } catch (err) {
407
+ return { configured: false, error: String(err?.message ?? err) }
408
+ }
409
+ }
410
+
411
+ async function storeKey(ctx, ref, value) {
412
+ const credentials = ctx.get('credentials')
413
+ if (credentials === undefined || typeof credentials.set !== 'function') {
414
+ throw new Error('credentials 服务不可用,无法写入 API key')
415
+ }
416
+ await credentials.set(ref, value)
417
+ }
418
+
419
+ async function clearKey(ctx, ref) {
420
+ const credentials = ctx.get('credentials')
421
+ if (credentials === undefined || typeof credentials.unset !== 'function') return
422
+ try {
423
+ await credentials.unset(ref)
424
+ } catch (err) {
425
+ // unset 对不存在的 ref 可能抛错,这里忽略。
426
+ log('unset failed', ref, String(err?.message ?? err))
427
+ }
428
+ }
429
+
430
+ /** key 的前后遮蔽显示形式。 */
431
+ function maskKey(value) {
432
+ if (typeof value !== 'string' || value === '') return undefined
433
+ if (value.length <= 10) return `${value.slice(0, 2)}…`
434
+ return `${value.slice(0, 6)}…${value.slice(-4)}`
435
+ }
436
+
437
+ /**
438
+ * provider 真正会去读的 ref 集合。
439
+ *
440
+ * 覆盖两层:(1) 全局默认 `DEEPSEEK_API_KEY`;(2) 所有已注册适配器的
441
+ * `dependencies.options().apiKeyEnv`(原生 DeepSeek 适配器与 pi-ai 的每条
442
+ * provider profile 都从这里取 key,见 dsh-llm-deepseek/lib/index.js:2100-2106、
443
+ * dsh-llm-pi-ai/lib/index.js:2557-2563)。另外把所有覆盖 ref 本身也放进去,
444
+ * 保证显式配置过的 ref 一定被拦。
445
+ */
446
+ function interceptableRefs(ctx, state) {
447
+ const refs = new Set(['DEEPSEEK_API_KEY'])
448
+ try {
449
+ const llm = ctx.get('llm')
450
+ const adapters = llm?.adapters
451
+ if (adapters !== undefined && typeof adapters.values === 'function') {
452
+ for (const registration of adapters.values()) {
453
+ let options
454
+ try {
455
+ options = registration?.adapter?.dependencies?.options
456
+ options = typeof options === 'function' ? options() : options
457
+ } catch {
458
+ continue
459
+ }
460
+ const ref = options?.apiKeyEnv
461
+ if (typeof ref === 'string' && ref !== '') refs.add(ref)
462
+ }
463
+ }
464
+ } catch {
465
+ /* llm 不可用时只保留默认 ref */
466
+ }
467
+ for (const entry of Object.values(state.overrides)) {
468
+ if (typeof entry?.ref === 'string' && entry.ref !== '') refs.add(entry.ref)
469
+ }
470
+ return refs
471
+ }
472
+
473
+ // ─────────────────────────── 失效判定 ───────────────────────────
474
+
475
+ function classifyFailure(failure) {
476
+ if (!failure || typeof failure !== 'object') return undefined
477
+ const status = failure.status ?? failure.statusCode
478
+ const code = typeof failure.code === 'string' ? failure.code : ''
479
+ const text = [failure.message, failure.code, failure.requestId]
480
+ .filter((s) => typeof s === 'string')
481
+ .join(' ')
482
+ const causeTexts = []
483
+ let cause = failure.cause
484
+ let guard = 0
485
+ while (cause && guard++ < 6) {
486
+ causeTexts.push(String(cause?.message ?? cause))
487
+ cause = cause?.cause
488
+ }
489
+ const all = `${text} ${causeTexts.join(' ')}`
490
+
491
+ if (status === 401 || status === 403) return 'invalid'
492
+ if (/INVALID_CREDENTIAL|UNAUTHORIZED|AUTHENTICATION|MISSING_CREDENTIAL|PERMISSION_DENIED/i.test(`${code} ${all}`)) {
493
+ return 'invalid'
494
+ }
495
+ if (/invalid[_ -]?api[_ -]?key|authentication fails|incorrect api key|api key is invalid|unauthorized/i.test(all)) {
496
+ return 'invalid'
497
+ }
498
+ if (status === 402 || code === 'QUOTA' || /insufficient[_ -]?(balance|quota)|quota[_ -]?(exceeded|exhausted)|balance/i.test(all)) {
499
+ return 'quota'
500
+ }
501
+ if (status === 429 || code === 'RATE_LIMIT' || /\b429\b|rate[\s_-]?limit/i.test(all)) return 'rate-limit'
502
+ return undefined
503
+ }
504
+
505
+ function maskTail(value) {
506
+ if (typeof value !== 'string' || value === '') return undefined
507
+ return value.length <= 4 ? '****' : value.slice(-4)
508
+ }
509
+
510
+ // ─────────────────────────── 测试连接 ───────────────────────────
511
+
512
+ function sanitizeDetail(text) {
513
+ if (typeof text !== 'string') return undefined
514
+ let out = text
515
+ try {
516
+ const parsed = JSON.parse(text)
517
+ const message = parsed?.error?.message ?? parsed?.message ?? parsed?.error
518
+ if (typeof message === 'string') out = message
519
+ } catch {
520
+ /* 非 JSON:原文 */
521
+ }
522
+ return out
523
+ .replace(/sk-[A-Za-z0-9_-]{8,}/g, 'sk-****')
524
+ .slice(0, 400)
525
+ }
526
+
527
+ const FALLBACK_BASE_URL = 'https://api.deepseek.com/anthropic'
528
+ const FALLBACK_MODEL = 'deepseek-flash'
529
+
530
+ /**
531
+ * 从已注册适配器里读出真实端点信息。
532
+ *
533
+ * 这是唯一权威来源:`registerDeepSeekProvider` 的 options() 已经合并了 settings
534
+ * 与启动环境变量(见 dsh-llm-deepseek-api-key/lib/index.js 的
535
+ * `resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx))`),
536
+ * 而 `baseURL` 在那边是**未加 `/v1` 的根**(`DeepSeekAdapter` 构造器才做
537
+ * `messagesApiRoot`,见 dsh-llm-deepseek/lib/index.js:650)。
538
+ */
539
+ function adapterEndpoint(ctx, preferredProvider) {
540
+ const found = []
541
+ try {
542
+ const llm = ctx.get('llm')
543
+ const adapters = llm?.adapters
544
+ if (adapters !== undefined && typeof adapters.values === 'function') {
545
+ const entries =
546
+ typeof adapters.entries === 'function'
547
+ ? [...adapters.entries()]
548
+ : [...adapters.values()].map((registration) => [undefined, registration])
549
+ for (const [provider, registration] of entries) {
550
+ let options
551
+ try {
552
+ options = registration?.adapter?.dependencies?.options
553
+ options = typeof options === 'function' ? options() : options
554
+ } catch {
555
+ continue
556
+ }
557
+ if (options === undefined || options === null) continue
558
+ const baseURL = typeof options.baseURL === 'string' && options.baseURL !== '' ? options.baseURL : undefined
559
+ const apiKeyEnv = typeof options.apiKeyEnv === 'string' && options.apiKeyEnv !== '' ? options.apiKeyEnv : undefined
560
+ const models = Array.isArray(options.models) ? options.models.filter((m) => typeof m === 'string' && m !== '') : []
561
+ found.push({ provider, baseURL, apiKeyEnv, model: models[0] })
562
+ }
563
+ }
564
+ } catch {
565
+ /* llm 不可用:调用方走兜底 */
566
+ }
567
+ if (preferredProvider !== undefined) {
568
+ const hit = found.find((entry) => entry.provider === preferredProvider)
569
+ if (hit !== undefined && (hit.baseURL !== undefined || hit.apiKeyEnv !== undefined)) return hit
570
+ }
571
+ return found.find((entry) => entry.baseURL !== undefined || entry.apiKeyEnv !== undefined)
572
+ }
573
+
574
+ /** 与 dsh-llm-deepseek/lib/index.js:557-560 逐字同构:缺 `/v1` 就补上。 */
575
+ function messagesApiRoot(baseURL) {
576
+ const fallback = FALLBACK_BASE_URL
577
+ if (typeof baseURL !== 'string' || baseURL === '') return fallback
578
+ try {
579
+ const base = baseURL.replace(/\/+$/u, '')
580
+ return new URL(base).pathname.endsWith('/v1') ? base : `${base}/v1`
581
+ } catch {
582
+ return fallback
583
+ }
584
+ }
585
+
586
+ function readBaseURL(ctx) {
587
+ const adapter = adapterEndpoint(ctx, 'deepseek-official')
588
+ if (adapter?.baseURL !== undefined) return adapter.baseURL
589
+ try {
590
+ const settings = ctx.get('settings')
591
+ const value = settings?.get?.('llm-deepseek')?.baseURL ?? settings?.get?.('llm-deepseek-api-key')?.baseURL
592
+ if (typeof value === 'string' && value !== '') return value
593
+ } catch {
594
+ /* 忽略:用默认 */
595
+ }
596
+ return FALLBACK_BASE_URL
597
+ }
598
+
599
+ function readDefaultModel(ctx) {
600
+ try {
601
+ const settings = ctx.get('settings')
602
+ const value = settings?.get?.('agent-default-model')?.model
603
+ if (typeof value === 'string' && value !== '') return value
604
+ } catch {
605
+ /* 忽略 */
606
+ }
607
+ const adapter = adapterEndpoint(ctx, 'deepseek-official')
608
+ if (adapter?.model !== undefined) return adapter.model
609
+ return FALLBACK_MODEL
610
+ }
611
+
612
+ /** 真实打一次最小请求来验证 key。 */
613
+ async function probeKey(ctx, key) {
614
+ const root = messagesApiRoot(readBaseURL(ctx))
615
+ const model = readDefaultModel(ctx)
616
+ const started = Date.now()
617
+ let res
618
+ try {
619
+ res = await fetch(`${root}/messages`, {
620
+ method: 'POST',
621
+ redirect: 'error',
622
+ headers: {
623
+ 'content-type': 'application/json',
624
+ accept: 'application/json',
625
+ 'x-api-key': key,
626
+ 'anthropic-version': '2023-06-01',
627
+ },
628
+ body: JSON.stringify({
629
+ model,
630
+ max_tokens: 1,
631
+ messages: [{ role: 'user', content: 'ping' }],
632
+ }),
633
+ signal: AbortSignal.timeout(TIMEOUT_MS),
634
+ })
635
+ } catch (err) {
636
+ const message = String(err?.message ?? err)
637
+ const timedOut = err?.name === 'TimeoutError' || err?.name === 'AbortError'
638
+ return {
639
+ ok: false,
640
+ verdict: timedOut ? 'unreachable' : 'unreachable',
641
+ status: 0,
642
+ latencyMs: Date.now() - started,
643
+ detail: timedOut ? `请求超时(${TIMEOUT_MS}ms):${root}` : `${message}(${root})`,
644
+ }
645
+ }
646
+
647
+ const latencyMs = Date.now() - started
648
+ const raw = await res.text().catch(() => '')
649
+ if (res.ok) {
650
+ return { ok: true, verdict: 'ok', status: res.status, latencyMs, detail: '连接正常,key 可用' }
651
+ }
652
+ if (res.status === 401 || res.status === 403) {
653
+ return { ok: false, verdict: 'invalid', status: res.status, latencyMs, detail: sanitizeDetail(raw) ?? 'key 无效(401/403)' }
654
+ }
655
+ if (res.status === 429) {
656
+ return { ok: false, verdict: 'rate-limit', status: res.status, latencyMs, detail: sanitizeDetail(raw) ?? '被限流(429),但 key 本身可能有效' }
657
+ }
658
+ if (res.status === 402) {
659
+ return { ok: false, verdict: 'quota', status: res.status, latencyMs, detail: sanitizeDetail(raw) ?? '余额/配额不足(402)' }
660
+ }
661
+ if (res.status === 400) {
662
+ // 400 通常是请求参数问题(例如模型名),说明鉴权已经通过。
663
+ return { ok: true, verdict: 'ok', status: res.status, latencyMs, detail: `鉴权通过(HTTP 400:${sanitizeDetail(raw) ?? '请求参数被拒'})` }
664
+ }
665
+ const endpoint = `${root}/messages`
666
+ const hint = res.status === 404 ? `(404:端点 ${endpoint} 不存在,检查 baseURL)` : `(${endpoint})`
667
+ return { ok: false, verdict: 'error', status: res.status, latencyMs, detail: `${sanitizeDetail(raw) ?? `HTTP ${res.status}`}${hint}` }
668
+ }
669
+
670
+ // ─────────────────────────── 插件主体 ───────────────────────────
671
+
672
+ export function apply(ctx) {
673
+ const state = loadState()
674
+
675
+ // ── 1. 包装 credentials.resolve ──────────────────────────────────────
676
+ installResolver(ctx, state)
677
+
678
+ // ── 2. REST API(loopback 可信)──────────────────────────────────────
679
+ let webServer
680
+ try {
681
+ webServer = ctx.get('webServer')
682
+ } catch {
683
+ webServer = undefined
684
+ }
685
+ if (webServer !== undefined && typeof webServer.register === 'function') {
686
+ ctx.effect(() =>
687
+ webServer.register({ kind: 'prefix', path: API_BASE, handler: (req, res) => handle(ctx, state, req, res) }),
688
+ )
689
+ log(`${API_BASE} routes registered`)
690
+ } else {
691
+ console.warn('[workspace-api-key] webServer 服务不可用:配置界面无法工作(key 映射仍然生效)')
692
+ }
693
+
694
+ // ── 3. 失效检测:监听请求错误,标记「哪个目录的 key 失效」─────────────
695
+ ctx.effect(() =>
696
+ ctx.on('agent/request-error', async (payload, next) => {
697
+ try {
698
+ const kind = classifyFailure(payload?.failure)
699
+ if (kind === 'invalid') {
700
+ markInvalid(
701
+ ctx,
702
+ state,
703
+ { cwd: payload?.agent?.session?.header?.cwd, sessionId: payload?.agent?.session?.id },
704
+ payload?.failure,
705
+ )
706
+ }
707
+ } catch (err) {
708
+ log('request-error handler failed', String(err?.message ?? err))
709
+ }
710
+ return next()
711
+ }),
712
+ )
713
+
714
+ ctx.effect(() => () => {
715
+ saveState(state)
716
+ }, 'workspace-api-key: flush state')
717
+
718
+ // 无条件打一行激活日志:桌面端把宿主 console 收进
719
+ // %APPDATA%\DSH Desktop\logs\host\dsh-YYYY-MM-DD.log,
720
+ // 这样「插件到底有没有装上」可以直接从日志判定,不用靠猜。
721
+ console.log(
722
+ `[workspace-api-key] active: routes=${webServer === undefined ? 'unavailable' : API_BASE} overrides=${Object.keys(state.overrides).length} state=${stateFilePath()}`,
723
+ )
724
+ }
725
+
726
+ /**
727
+ * 装上 resolve wrapper。装不上也不能影响宿主:任何异常都吞掉并退回原行为。
728
+ */
729
+ function installResolver(ctx, state) {
730
+ if (globalThis[INSTALL_KEY] !== undefined) {
731
+ log('resolver already installed; skipping')
732
+ return
733
+ }
734
+ let credentials
735
+ try {
736
+ credentials = ctx.get('credentials')
737
+ } catch {
738
+ credentials = undefined
739
+ }
740
+ if (credentials === undefined || typeof credentials.resolve !== 'function') {
741
+ console.warn('[workspace-api-key] credentials 服务不可用:无法按工作区切换 key')
742
+ return
743
+ }
744
+
745
+ const original = credentials.resolve.bind(credentials)
746
+
747
+ const wrapped = async (ref /*, ...rest */) => {
748
+ try {
749
+ if (!interceptableRefs(ctx, state).has(ref)) return await original(ref)
750
+ // 1) 会话级覆盖优先,2) 再工作区级,3) 都没有就用系统默认(原 ref)。
751
+ const hit = effectiveOverride(ctx, state, { sessionId: activeSessionId(ctx), cwd: activeCwd(ctx) })
752
+ if (hit.ref !== undefined && hit.level !== 'default') {
753
+ const value = await original(hit.ref)
754
+ if (value?.value !== undefined && value.value !== '') {
755
+ log(`ref ${ref} → ${hit.ref} (${hit.level})`)
756
+ return value
757
+ }
758
+ log(`${hit.level} override ${hit.ref} configured but resolved empty; falling back to ${ref}`)
759
+ }
760
+ return await original(ref)
761
+ } catch (err) {
762
+ log('wrapper fallback:', String(err?.message ?? err))
763
+ try {
764
+ return await original(ref)
765
+ } catch {
766
+ return undefined
767
+ }
768
+ }
769
+ }
770
+
771
+ try {
772
+ credentials.resolve = wrapped
773
+ globalThis[INSTALL_KEY] = { wrapped, original, credentials }
774
+ ctx.effect(
775
+ () => () => {
776
+ try {
777
+ credentials.resolve = original
778
+ } catch {
779
+ /* ignore */
780
+ }
781
+ delete globalThis[INSTALL_KEY]
782
+ },
783
+ 'workspace-api-key: restore credentials.resolve',
784
+ )
785
+ log('credentials.resolve wrapped')
786
+ } catch (err) {
787
+ console.warn('[workspace-api-key] 包装 credentials.resolve 失败:', String(err?.message ?? err))
788
+ }
789
+ }
790
+
791
+ function markInvalid(ctx, state, { cwd, sessionId }, failure) {
792
+ const hit = effectiveOverride(ctx, state, { sessionId, cwd })
793
+ if (hit.ref === undefined) return
794
+ const ref = hit.ref
795
+ const previous = state.invalid[ref]
796
+ if (previous !== undefined && previous.status === 'invalid' && previous.code === failure?.code) return
797
+ state.invalid[ref] = {
798
+ at: new Date().toISOString(),
799
+ status: 'invalid',
800
+ level: hit.level,
801
+ message: String(failure?.message ?? '').slice(0, 500),
802
+ code: failure?.code,
803
+ httpStatus: failure?.status,
804
+ workspacePath: hit.workspace?.path ?? cwd,
805
+ ...(hit.level === 'session' ? { sessionId } : {}),
806
+ }
807
+ saveState(state)
808
+ log('marked invalid', ref, hit.level, hit.entry?.title)
809
+ }
810
+
811
+ // ─────────────────────────── HTTP 处理 ───────────────────────────
812
+
813
+ function isTrusted(req) {
814
+ const ip = req.socket?.remoteAddress
815
+ if (!(ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1')) return false
816
+ const host = req.headers?.host
817
+ if (typeof host !== 'string' || host === '') return false
818
+ let hostUrl
819
+ try {
820
+ hostUrl = new URL(`http://${host}`)
821
+ } catch {
822
+ return false
823
+ }
824
+ const origin = req.headers?.origin
825
+ if (origin === undefined) return true
826
+ try {
827
+ return new URL(origin).host === hostUrl.host
828
+ } catch {
829
+ return false
830
+ }
831
+ }
832
+
833
+ function sendJson(res, code, obj) {
834
+ const body = JSON.stringify(obj)
835
+ res.writeHead(code, {
836
+ 'content-type': 'application/json; charset=utf-8',
837
+ 'cache-control': 'no-store',
838
+ })
839
+ res.end(body)
840
+ }
841
+
842
+ function readBody(req, limit = 65536) {
843
+ return new Promise((resolve, reject) => {
844
+ let size = 0
845
+ const chunks = []
846
+ req.on('data', (chunk) => {
847
+ size += chunk.length
848
+ if (size > limit) {
849
+ reject(new Error('body too large'))
850
+ req.destroy()
851
+ return
852
+ }
853
+ chunks.push(chunk)
854
+ })
855
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
856
+ req.on('error', reject)
857
+ })
858
+ }
859
+
860
+ async function readJson(req) {
861
+ const body = await readBody(req)
862
+ if (body === '') return {}
863
+ return JSON.parse(body)
864
+ }
865
+
866
+ /** 一个会话的展示 + 生效情况(供面板与会话进入提醒使用)。 */
867
+ async function sessionRow(ctx, state, row, fallbackCwd) {
868
+ const sessionId = typeof row?.sessionId === 'string' && row.sessionId !== '' ? row.sessionId : undefined
869
+ if (sessionId === undefined) return undefined
870
+ const selection = modelSelectionOf(row)
871
+ const cwd = typeof row?.cwd === 'string' && row.cwd !== '' ? row.cwd : fallbackCwd
872
+ const ref = sessionRef(sessionId)
873
+ const entry = ref !== undefined ? state.overrides[ref] : undefined
874
+ const directHit = entry !== undefined ? await resolveKey(ctx, ref) : undefined
875
+ const eff = effectiveOverride(ctx, state, { sessionId, cwd })
876
+ const effHit = eff.ref !== undefined ? await resolveKey(ctx, eff.ref) : undefined
877
+ return {
878
+ sessionId,
879
+ title: titleOf(row),
880
+ updatedAt: row?.updatedAt,
881
+ running: row?.running === true,
882
+ blank: row?.blank === true,
883
+ parentSessionId: typeof row?.parentSessionId === 'string' ? row.parentSessionId : undefined,
884
+ cwd,
885
+ provider: selection?.provider,
886
+ model: selection?.model,
887
+ ref,
888
+ configured: entry !== undefined,
889
+ keyMasked: maskKey(directHit?.value),
890
+ overrideProvider: entry?.provider,
891
+ overrideModel: entry?.model,
892
+ level: eff.level,
893
+ effectiveRef: eff.ref,
894
+ effectiveKeyMasked: maskKey(effHit?.value),
895
+ effectiveProvider: eff.entry?.provider,
896
+ effectiveModel: eff.entry?.model,
897
+ effectiveTitle: eff.entry?.title,
898
+ invalid: eff.ref !== undefined ? state.invalid[eff.ref] ?? null : null,
899
+ mismatch: mismatchOf(eff.entry, selection),
900
+ }
901
+ }
902
+
903
+ async function workspaceRow(ctx, state, { id, path: dir, title, kind }, rows, claimed) {
904
+ const ref = derivedRef(dir)
905
+ const configured = ref !== undefined && state.overrides[ref] !== undefined
906
+ const desc = configured ? await describeKey(ctx, ref) : undefined
907
+ const hit = configured ? await resolveKey(ctx, ref) : undefined
908
+ const sessions = []
909
+ for (const row of rows) {
910
+ const item = await sessionRow(ctx, state, row, dir)
911
+ if (item !== undefined) sessions.push(item)
912
+ }
913
+ sessions.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
914
+ if (claimed !== undefined) claimed.add(normPath(dir))
915
+ return {
916
+ id,
917
+ path: dir,
918
+ title: typeof title === 'string' && title !== '' ? title : basename(dir) || dir,
919
+ kind,
920
+ ref,
921
+ configured,
922
+ source: desc?.source,
923
+ writable: desc?.writable !== false,
924
+ keyMasked: maskKey(hit?.value),
925
+ overrideProvider: configured ? state.overrides[ref]?.provider : undefined,
926
+ invalid: ref !== undefined ? state.invalid[ref] ?? null : null,
927
+ exists: typeof dir === 'string' ? existsSync(dir) : false,
928
+ sessions,
929
+ }
930
+ }
931
+
932
+ async function snapshot(ctx, state) {
933
+ const credentials = ctx.get('credentials')
934
+ const defaultRefName = 'DEEPSEEK_API_KEY'
935
+ const defaultInfo = await describeKey(ctx, defaultRefName)
936
+ const defaultHit = await resolveKey(ctx, defaultRefName)
937
+ const probe = await sessionProbe(ctx)
938
+ const rows = probe.rows
939
+ const byCwd = new Map()
940
+ for (const row of rows) {
941
+ const norm = normPath(row?.cwd)
942
+ if (norm === undefined) continue
943
+ const list = byCwd.get(norm) ?? []
944
+ list.push(row)
945
+ byCwd.set(norm, list)
946
+ }
947
+
948
+ const claimed = new Set()
949
+ const workspaces = []
950
+ for (const entity of safeList(ctx)) {
951
+ let id, dir, title
952
+ try {
953
+ id = entity?.id
954
+ dir = entity?.path
955
+ title = entity?.title
956
+ } catch {
957
+ continue
958
+ }
959
+ if (typeof dir !== 'string' || dir === '') continue
960
+ const norm = normPath(dir)
961
+ workspaces.push(await workspaceRow(ctx, state, { id, path: dir, title, kind: 'workspace' }, byCwd.get(norm) ?? [], claimed))
962
+ }
963
+
964
+ // 有覆盖记录、但对应工作区已不在注册表里的(被删掉的工作区)也列出来,方便清理。
965
+ const knownRefs = new Set(workspaces.map((ws) => ws.ref))
966
+ for (const [ref, entry] of Object.entries(state.overrides)) {
967
+ if (knownRefs.has(ref)) continue
968
+ if (entry?.scope === 'session') continue
969
+ const desc = await describeKey(ctx, ref)
970
+ const hit = await resolveKey(ctx, ref)
971
+ workspaces.push({
972
+ id: undefined,
973
+ path: entry.path,
974
+ title: entry.title ?? basename(entry.path ?? '') ?? ref,
975
+ kind: 'orphan',
976
+ ref,
977
+ configured: true,
978
+ source: desc?.source,
979
+ writable: desc?.writable !== false,
980
+ keyMasked: maskKey(hit?.value),
981
+ overrideProvider: entry.provider,
982
+ invalid: state.invalid[ref] ?? null,
983
+ exists: typeof entry.path === 'string' ? existsSync(entry.path) : false,
984
+ sessions: [],
985
+ })
986
+ }
987
+
988
+ // 未注册成工作区、但确实有会话的目录(侧栏的「未分组」):也能单独给会话配 key。
989
+ const ungrouped = []
990
+ for (const [norm, list] of byCwd) {
991
+ if (claimed.has(norm)) continue
992
+ const dir = typeof list[0]?.cwd === 'string' ? list[0].cwd : norm
993
+ ungrouped.push(await workspaceRow(ctx, state, { id: undefined, path: dir, title: basename(dir) || dir, kind: 'directory' }, list))
994
+ }
995
+
996
+ // 会话级覆盖总览(含已不在列表里的会话,便于清理)。
997
+ const seenSessions = new Set()
998
+ for (const group of [...workspaces, ...ungrouped]) {
999
+ for (const session of group.sessions) seenSessions.add(session.sessionId)
1000
+ }
1001
+ const sessionOverrides = []
1002
+ for (const [ref, entry] of Object.entries(state.overrides)) {
1003
+ if (entry?.scope !== 'session') continue
1004
+ const hit = await resolveKey(ctx, ref)
1005
+ sessionOverrides.push({
1006
+ ref,
1007
+ sessionId: entry.sessionId,
1008
+ title: entry.title,
1009
+ keyMasked: maskKey(hit?.value),
1010
+ provider: entry.provider,
1011
+ model: entry.model,
1012
+ at: entry.at,
1013
+ invalid: state.invalid[ref] ?? null,
1014
+ visible: seenSessions.has(entry.sessionId),
1015
+ })
1016
+ }
1017
+
1018
+ return {
1019
+ ok: true,
1020
+ pluginVersion: PLUGIN_VERSION,
1021
+ storeVersion: STATE_VERSION,
1022
+ sessionProbe: {
1023
+ available: probe.available,
1024
+ count: probe.count,
1025
+ withoutCwd: probe.withoutCwd ?? 0,
1026
+ error: probe.error,
1027
+ samples: probe.samples ?? [],
1028
+ },
1029
+ workspacePathSample: workspaces.slice(0, 5).map((ws) => ws.path),
1030
+ defaultRef: {
1031
+ ref: defaultRefName,
1032
+ configured: defaultInfo.configured === true,
1033
+ source: defaultInfo.source,
1034
+ writable: defaultInfo.writable !== false,
1035
+ keyMasked: maskKey(defaultHit?.value),
1036
+ error: defaultInfo.error,
1037
+ },
1038
+ providers: providerList(ctx),
1039
+ interceptableRefs: [...interceptableRefs(ctx, state)],
1040
+ workspaceCount: workspaceIndex(ctx).size,
1041
+ credentialsAvailable: credentials !== undefined,
1042
+ sessionListAvailable: sessionListAvailable(ctx),
1043
+ statePath: stateFilePath(),
1044
+ workspaces,
1045
+ ungrouped,
1046
+ sessionOverrides,
1047
+ }
1048
+ }
1049
+
1050
+ function safeList(ctx) {
1051
+ try {
1052
+ const registry = ctx.get('workspaceRegistry')
1053
+ if (registry === undefined || typeof registry.list !== 'function') return []
1054
+ return registry.list() ?? []
1055
+ } catch {
1056
+ return []
1057
+ }
1058
+ }
1059
+
1060
+ /** 解析请求里的配置目标(会话级 or 工作区级)→ 稳定的凭据 ref。 */
1061
+ function targetOf(body) {
1062
+ const scope = body?.scope === 'session' ? 'session' : 'workspace'
1063
+ if (scope === 'session') {
1064
+ const sessionId = typeof body?.sessionId === 'string' && body.sessionId !== '' ? body.sessionId : undefined
1065
+ if (sessionId === undefined) return { error: '缺少 sessionId' }
1066
+ const ref = sessionRef(sessionId)
1067
+ if (ref === undefined) return { error: '无法为该会话生成凭据引用' }
1068
+ return {
1069
+ scope,
1070
+ sessionId,
1071
+ ref,
1072
+ path: typeof body?.path === 'string' && body.path !== '' ? body.path : undefined,
1073
+ }
1074
+ }
1075
+ const dir = typeof body?.path === 'string' ? body.path : undefined
1076
+ if (dir === undefined || dir === '') return { error: '缺少 path' }
1077
+ const ref = derivedRef(dir)
1078
+ if (ref === undefined) return { error: '无法为该路径生成凭据引用' }
1079
+ return { scope, ref, path: dir }
1080
+ }
1081
+
1082
+ /**
1083
+ * 「点进某个会话」时的判定:算出该会话当前生效的那把 key,
1084
+ * 以及它是否失效 / 是否与当前会话的 provider 不匹配。
1085
+ * 只报告,绝不修改任何配置——决定权留给用户。
1086
+ */
1087
+ async function checkSession(ctx, state, params) {
1088
+ const sessionId = params.get('sessionId') ?? undefined
1089
+ const activeSid = activeSessionId(ctx)
1090
+ const sid = sessionId !== undefined && sessionId !== '' ? sessionId : activeSid
1091
+ let cwd = params.get('path') ?? undefined
1092
+ let selection
1093
+ let title
1094
+ if (sid !== undefined) {
1095
+ const rows = await sessionSummaries(ctx)
1096
+ const row = rows.find((item) => item?.sessionId === sid)
1097
+ if (row !== undefined) {
1098
+ if (typeof row.cwd === 'string' && row.cwd !== '') cwd = row.cwd
1099
+ selection = modelSelectionOf(row)
1100
+ title = titleOf(row)
1101
+ }
1102
+ }
1103
+ if (cwd === undefined) cwd = activeCwd(ctx)
1104
+ const eff = effectiveOverride(ctx, state, { sessionId: sid, cwd })
1105
+ const hit = eff.ref !== undefined ? await resolveKey(ctx, eff.ref) : await resolveKey(ctx, 'DEEPSEEK_API_KEY')
1106
+ const invalid = eff.ref !== undefined ? state.invalid[eff.ref] ?? null : null
1107
+ const mismatch = mismatchOf(eff.entry, selection)
1108
+ const defaultRefName = 'DEEPSEEK_API_KEY'
1109
+ const defaultInfo = await describeKey(ctx, defaultRefName)
1110
+ const sessionEntry = sid !== undefined ? state.overrides[sessionRef(sid)] : undefined
1111
+ const ws = locateWorkspace(ctx, cwd)
1112
+ const wsRef = ws !== undefined ? derivedRef(ws.path) : undefined
1113
+ return {
1114
+ ok: true,
1115
+ sessionId: sid ?? null,
1116
+ title: title ?? null,
1117
+ cwd: cwd ?? null,
1118
+ provider: selection?.provider ?? null,
1119
+ model: selection?.model ?? null,
1120
+ level: eff.level,
1121
+ ref: eff.ref ?? null,
1122
+ overrideTitle: eff.entry?.title ?? null,
1123
+ keyMasked: maskKey(hit?.value),
1124
+ keyConfigured: hit?.value !== undefined && hit.value !== '',
1125
+ overrideProvider: eff.entry?.provider ?? null,
1126
+ overrideModel: eff.entry?.model ?? null,
1127
+ invalid,
1128
+ mismatch,
1129
+ needsAttention: invalid !== null || mismatch !== null,
1130
+ sessionScope: {
1131
+ configured: sessionEntry !== undefined,
1132
+ ref: sid !== undefined ? sessionRef(sid) : null,
1133
+ },
1134
+ workspaceScope: {
1135
+ configured: wsRef !== undefined && state.overrides[wsRef] !== undefined,
1136
+ ref: wsRef ?? null,
1137
+ path: ws?.path ?? cwd ?? null,
1138
+ title: ws?.title ?? null,
1139
+ },
1140
+ defaultRef: { ref: defaultRefName, configured: defaultInfo.configured === true },
1141
+ providers: providerList(ctx),
1142
+ }
1143
+ }
1144
+
1145
+ async function handle(ctx, state, req, res) {
1146
+ if (!isTrusted(req)) {
1147
+ res.writeHead(403)
1148
+ res.end('forbidden')
1149
+ return
1150
+ }
1151
+ let url
1152
+ try {
1153
+ url = new URL(req.url, 'http://localhost')
1154
+ } catch {
1155
+ res.writeHead(400)
1156
+ res.end('bad request')
1157
+ return
1158
+ }
1159
+ const pathname = url.pathname
1160
+ const method = req.method ?? 'GET'
1161
+
1162
+ try {
1163
+ if (pathname === `${API_BASE}/state` && method === 'GET') {
1164
+ sendJson(res, 200, await snapshot(ctx, state))
1165
+ return
1166
+ }
1167
+
1168
+ if (pathname === `${API_BASE}/resolve` && method === 'GET') {
1169
+ const cwd = activeCwd(ctx)
1170
+ const sessionId = activeSessionId(ctx)
1171
+ const eff = effectiveOverride(ctx, state, { sessionId, cwd })
1172
+ const ws = locateWorkspace(ctx, cwd)
1173
+ sendJson(res, 200, {
1174
+ ok: true,
1175
+ activeCwd: cwd ?? null,
1176
+ activeSessionId: sessionId ?? null,
1177
+ workspace: ws ?? null,
1178
+ sessionRef: sessionId !== undefined ? sessionRef(sessionId) : null,
1179
+ level: eff.level,
1180
+ overrideRef: eff.ref ?? null,
1181
+ overrideTitle: eff.entry?.title ?? null,
1182
+ overrideActive: eff.ref !== undefined,
1183
+ interceptableRefs: [...interceptableRefs(ctx, state)],
1184
+ })
1185
+ return
1186
+ }
1187
+
1188
+ // 客户端「点进某个会话」时问一次:这把 key 需要提醒吗?(绝不改 key)
1189
+ if (pathname === `${API_BASE}/check` && method === 'GET') {
1190
+ sendJson(res, 200, await checkSession(ctx, state, url.searchParams))
1191
+ return
1192
+ }
1193
+
1194
+ if (pathname === `${API_BASE}/set` && method === 'POST') {
1195
+ const body = await readJson(req)
1196
+ const target = targetOf(body)
1197
+ if (target.error !== undefined) {
1198
+ sendJson(res, 400, { ok: false, error: target.error })
1199
+ return
1200
+ }
1201
+ const key = typeof body.key === 'string' ? body.key.trim() : ''
1202
+ if (body.useDefault === true || key === '') {
1203
+ await clearKey(ctx, target.ref)
1204
+ delete state.overrides[target.ref]
1205
+ delete state.invalid[target.ref]
1206
+ saveState(state)
1207
+ sendJson(res, 200, { ok: true, cleared: true, ...(await snapshot(ctx, state)) })
1208
+ return
1209
+ }
1210
+ await storeKey(ctx, target.ref, key)
1211
+ state.overrides[target.ref] = {
1212
+ ref: target.ref,
1213
+ scope: target.scope,
1214
+ path: target.path,
1215
+ ...(target.sessionId === undefined ? {} : { sessionId: target.sessionId }),
1216
+ title: typeof body.title === 'string' && body.title !== '' ? body.title : undefined,
1217
+ ...(typeof body.provider === 'string' && body.provider !== '' ? { provider: body.provider } : {}),
1218
+ ...(typeof body.model === 'string' && body.model !== '' ? { model: body.model } : {}),
1219
+ at: new Date().toISOString(),
1220
+ }
1221
+ delete state.invalid[target.ref]
1222
+ saveState(state)
1223
+ sendJson(res, 200, { ok: true, ...(await snapshot(ctx, state)) })
1224
+ return
1225
+ }
1226
+
1227
+ if (pathname === `${API_BASE}/test` && method === 'POST') {
1228
+ const body = await readJson(req)
1229
+ let key = typeof body.key === 'string' && body.key.trim() !== '' ? body.key.trim() : undefined
1230
+ let sourceRef = typeof body.ref === 'string' && body.ref !== '' ? body.ref : undefined
1231
+ if (key === undefined) {
1232
+ if (sourceRef === undefined) {
1233
+ if (body.scope === 'session') {
1234
+ sourceRef = typeof body.sessionId === 'string' && body.sessionId !== '' ? sessionRef(body.sessionId) : undefined
1235
+ } else {
1236
+ const dir = typeof body.path === 'string' ? body.path : undefined
1237
+ const overrideRef = dir !== undefined && dir !== '' ? derivedRef(dir) : undefined
1238
+ sourceRef = overrideRef !== undefined && state.overrides[overrideRef] !== undefined ? overrideRef : undefined
1239
+ }
1240
+ }
1241
+ if (sourceRef === undefined) sourceRef = 'DEEPSEEK_API_KEY'
1242
+ const hit = await resolveKey(ctx, sourceRef)
1243
+ key = hit?.value
1244
+ }
1245
+ if (typeof key !== 'string' || key === '') {
1246
+ sendJson(res, 400, { ok: false, error: `没有可测试的 key(ref=${sourceRef ?? '未知'})` })
1247
+ return
1248
+ }
1249
+ const result = await probeKey(ctx, key)
1250
+ const isOverrideRef = sourceRef !== undefined && state.overrides[sourceRef] !== undefined
1251
+ if (result.verdict === 'invalid') {
1252
+ if (isOverrideRef) {
1253
+ state.invalid[sourceRef] = {
1254
+ at: new Date().toISOString(),
1255
+ status: 'invalid',
1256
+ level: state.overrides[sourceRef]?.scope ?? 'workspace',
1257
+ message: result.detail,
1258
+ workspacePath: state.overrides[sourceRef]?.path,
1259
+ ...(state.overrides[sourceRef]?.sessionId === undefined ? {} : { sessionId: state.overrides[sourceRef].sessionId }),
1260
+ }
1261
+ saveState(state)
1262
+ result.marked = sourceRef
1263
+ }
1264
+ } else if (result.verdict === 'ok' && isOverrideRef && state.invalid[sourceRef] !== undefined) {
1265
+ // 测试通过 = 这把 key 现在可用,顺手把失效标记摘掉。
1266
+ delete state.invalid[sourceRef]
1267
+ saveState(state)
1268
+ result.unmarked = sourceRef
1269
+ }
1270
+ sendJson(res, 200, { ok: true, test: result, keyMasked: maskKey(key), keyTail: maskTail(key) })
1271
+ return
1272
+ }
1273
+
1274
+ if (pathname === `${API_BASE}/clear-flag` && method === 'POST') {
1275
+ const body = await readJson(req)
1276
+ let ref = typeof body.ref === 'string' && body.ref !== '' ? body.ref : undefined
1277
+ if (ref === undefined) {
1278
+ const target = targetOf(body)
1279
+ if (target.error === undefined) ref = target.ref
1280
+ }
1281
+ if (ref !== undefined) {
1282
+ delete state.invalid[ref]
1283
+ saveState(state)
1284
+ }
1285
+ sendJson(res, 200, { ok: true, ...(await snapshot(ctx, state)) })
1286
+ return
1287
+ }
1288
+
1289
+ res.writeHead(404)
1290
+ res.end('not found')
1291
+ } catch (err) {
1292
+ sendJson(res, 500, { ok: false, error: String(err?.message ?? err) })
1293
+ }
1294
+ }