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