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/LICENSE +21 -0
- package/README.md +92 -2
- package/README.zh.md +93 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +994 -0
- package/lib/index.js +1294 -0
- package/package.json +52 -4
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
|
+
}
|