@goodandready/dsh-clinebot 0.5.32 → 0.6.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/CHANGELOG.md +31 -0
- package/README.md +63 -36
- package/README.ru.md +3 -1
- package/README.zh.md +3 -1
- package/lib/adapter-messages.js +237 -0
- package/lib/adapter-stream.js +292 -0
- package/lib/adapter.js +616 -0
- package/lib/client.js +2564 -924
- package/lib/cline-client.js +15 -6
- package/lib/config.js +266 -233
- package/lib/credential-refs.js +104 -84
- package/lib/index.js +569 -502
- package/lib/model-prices.js +117 -0
- package/lib/provider-sync.js +404 -387
- package/lib/proxy-token.js +187 -140
- package/lib/routes/accounts.js +523 -403
- package/lib/routes/models.js +2 -1
- package/lib/routes/proxy.js +8 -27
- package/lib/routes/settings.js +63 -2
- package/lib/services.js +55 -0
- package/lib/stats-storage.js +364 -166
- package/lib/transport/cline-transport.js +314 -0
- package/lib/tui-settings.js +158 -0
- package/lib/updater.js +5 -1
- package/package.json +113 -77
package/lib/routes/accounts.js
CHANGED
|
@@ -1,404 +1,524 @@
|
|
|
1
|
-
import { writeJson, readBody } from '../http.js'
|
|
2
|
-
import { discoverLocalClineKeys, consumeDiscoveredKey } from '../key-discovery.js'
|
|
3
|
-
import { assertTrustedSettingsRequest } from '../access.js'
|
|
4
|
-
import { publicConfig, plainConfig, Config } from '../config.js'
|
|
5
|
-
import { upsertPiAiProvider, removePiAiProvider } from '../provider-sync.js'
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
existingAccounts
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (!
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (!
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
body =
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
queryApiKeyEnv = ''
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
retryDelRes =
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
delRes =
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
rollbackSuccess =
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
writeJson(res,
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
...
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
writeJson(res,
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
writeJson(res,
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
writeJson(res,
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
writeJson(res,
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (!
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
1
|
+
import { writeJson, readBody, publicUsage } from '../http.js'
|
|
2
|
+
import { discoverLocalClineKeys, consumeDiscoveredKey } from '../key-discovery.js'
|
|
3
|
+
import { assertTrustedSettingsRequest } from '../access.js'
|
|
4
|
+
import { publicConfig, plainConfig, Config } from '../config.js'
|
|
5
|
+
import { upsertPiAiProvider, removePiAiProvider, resolveActiveAccountKey } from '../provider-sync.js'
|
|
6
|
+
import { resolveAccountPool } from '../account-pool.js'
|
|
7
|
+
import {
|
|
8
|
+
clearUsageCache,
|
|
9
|
+
clearProbeCache,
|
|
10
|
+
saveCredentialKey,
|
|
11
|
+
deleteCredentialKey,
|
|
12
|
+
fetchUsageLimits,
|
|
13
|
+
smokeChat,
|
|
14
|
+
DEFAULT_API_KEY_ENV,
|
|
15
|
+
} from '../cline-client.js'
|
|
16
|
+
|
|
17
|
+
export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettingsApi }) {
|
|
18
|
+
const pendingSecretCleanupAccounts = new Set()
|
|
19
|
+
|
|
20
|
+
async function addAccountInternal({ apiKey, label, apiKeyEnv }) {
|
|
21
|
+
const curr = plainConfig(live())
|
|
22
|
+
const existingAccounts = Array.isArray(curr.accounts) ? [...curr.accounts] : []
|
|
23
|
+
|
|
24
|
+
let targetEnv = String(apiKeyEnv || '').trim()
|
|
25
|
+
if (!targetEnv) {
|
|
26
|
+
let maxN = 1
|
|
27
|
+
for (const acc of existingAccounts) {
|
|
28
|
+
const m = String(acc?.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
|
|
29
|
+
if (m) {
|
|
30
|
+
const num = parseInt(m[1], 10)
|
|
31
|
+
if (num >= maxN) maxN = num + 1
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (curr.apiKeyEnv && curr.apiKeyEnv.startsWith('CLINEBOT_API_KEY_')) {
|
|
35
|
+
const m = curr.apiKeyEnv.match(/^CLINEBOT_API_KEY_(\d+)$/)
|
|
36
|
+
if (m) {
|
|
37
|
+
const num = parseInt(m[1], 10)
|
|
38
|
+
if (num >= maxN) maxN = num + 1
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
targetEnv = `CLINEBOT_API_KEY_${maxN}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)) {
|
|
45
|
+
const err = new Error(`Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$`)
|
|
46
|
+
err.status = 400
|
|
47
|
+
throw err
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const accountLabel = String(label || '').trim() || targetEnv
|
|
51
|
+
|
|
52
|
+
await saveCredentialKey(ctx, targetEnv, apiKey)
|
|
53
|
+
|
|
54
|
+
const idx = existingAccounts.findIndex((a) => a && a.apiKeyEnv === targetEnv)
|
|
55
|
+
const accountEntry = { label: accountLabel, apiKeyEnv: targetEnv }
|
|
56
|
+
if (idx >= 0) {
|
|
57
|
+
existingAccounts[idx] = accountEntry
|
|
58
|
+
} else {
|
|
59
|
+
existingAccounts.push(accountEntry)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const next = plainConfig(Config({ ...curr, accounts: existingAccounts }))
|
|
63
|
+
const settingsApi = getSettingsApi()
|
|
64
|
+
await settingsApi.replace(next)
|
|
65
|
+
await syncProviderState(next)
|
|
66
|
+
|
|
67
|
+
let validation = { ok: false, error: 'Skipped validation: baseUrl is not https' }
|
|
68
|
+
try {
|
|
69
|
+
const parsed = new URL(curr.baseUrl)
|
|
70
|
+
if (parsed.protocol === 'https:') {
|
|
71
|
+
validation = await smokeChat(curr.baseUrl, apiKey, {
|
|
72
|
+
model: curr.defaultModel,
|
|
73
|
+
timeoutMs: 15000,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
validation = { ok: false, error: 'Invalid baseUrl' }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
clearUsageCache()
|
|
81
|
+
clearProbeCache()
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
account: accountEntry,
|
|
85
|
+
validated: validation.ok,
|
|
86
|
+
latencyMs: validation.latencyMs,
|
|
87
|
+
validationError: validation.ok ? null : validation.error,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// POST /dsh-clinebot/accounts — add account endpoint (supports DELETE for backward compat)
|
|
92
|
+
ctx.effect(() => ctx.webServer.register({
|
|
93
|
+
kind: 'exact',
|
|
94
|
+
path: '/dsh-clinebot/accounts',
|
|
95
|
+
handler: async (req, res) => {
|
|
96
|
+
if (req.method === 'DELETE') {
|
|
97
|
+
return handleDeleteAccount(req, res)
|
|
98
|
+
}
|
|
99
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST or DELETE only' })
|
|
100
|
+
if (!assertTrustedSettingsRequest(req, res, ctx)) return
|
|
101
|
+
const settingsApi = getSettingsApi()
|
|
102
|
+
if (!settingsApi || typeof settingsApi.replace !== 'function') {
|
|
103
|
+
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const bodyBuf = await readBody(req)
|
|
107
|
+
let body = {}
|
|
108
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
|
|
109
|
+
|
|
110
|
+
const apiKey = String(body.apiKey || '').trim()
|
|
111
|
+
if (!apiKey) {
|
|
112
|
+
return writeJson(res, 400, { ok: false, error: 'API key is required' })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const result = await addAccountInternal({
|
|
116
|
+
apiKey,
|
|
117
|
+
label: body.label,
|
|
118
|
+
apiKeyEnv: body.apiKeyEnv,
|
|
119
|
+
})
|
|
120
|
+
writeJson(res, 200, { ok: true, ...result })
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const status = typeof err?.status === 'number' ? err.status : 500
|
|
123
|
+
writeJson(res, status, { ok: false, error: String(err?.message || err) })
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
}), 'dsh-clinebot: /accounts')
|
|
127
|
+
|
|
128
|
+
async function handleDeleteAccount(req, res, targetEnvFromUrl = '') {
|
|
129
|
+
if (!assertTrustedSettingsRequest(req, res, ctx)) return
|
|
130
|
+
const settingsApi = getSettingsApi()
|
|
131
|
+
if (!settingsApi || typeof settingsApi.replace !== 'function') {
|
|
132
|
+
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
let body = {}
|
|
136
|
+
if (req.method === 'POST' || req.method === 'DELETE') {
|
|
137
|
+
try {
|
|
138
|
+
const bodyBuf = await readBody(req)
|
|
139
|
+
body = JSON.parse(bodyBuf.toString('utf8'))
|
|
140
|
+
} catch {
|
|
141
|
+
body = {}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
let queryApiKeyEnv = ''
|
|
145
|
+
try {
|
|
146
|
+
const u = new URL(req.url, 'http://127.0.0.1')
|
|
147
|
+
queryApiKeyEnv = u.searchParams.get('apiKeyEnv') || ''
|
|
148
|
+
} catch {
|
|
149
|
+
queryApiKeyEnv = ''
|
|
150
|
+
}
|
|
151
|
+
const targetEnv = String(targetEnvFromUrl || body.apiKeyEnv || queryApiKeyEnv || '').trim()
|
|
152
|
+
if (!targetEnv) {
|
|
153
|
+
return writeJson(res, 400, { ok: false, error: 'apiKeyEnv is required' })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const isDefaultPattern = /^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)
|
|
157
|
+
if (!isDefaultPattern) {
|
|
158
|
+
return writeJson(res, 400, {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: `Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$.`,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const curr = plainConfig(live())
|
|
165
|
+
if (targetEnv === curr.apiKeyEnv || targetEnv === DEFAULT_API_KEY_ENV) {
|
|
166
|
+
return writeJson(res, 400, { ok: false, error: 'Cannot delete the primary account' })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
|
|
170
|
+
const accountToDelete = existingAccounts.find((a) => a && a.apiKeyEnv === targetEnv)
|
|
171
|
+
if (!accountToDelete) {
|
|
172
|
+
if (body.deleteSecret && pendingSecretCleanupAccounts.has(targetEnv) && ctx?.get?.('credentials')) {
|
|
173
|
+
let retryDelRes = null
|
|
174
|
+
try {
|
|
175
|
+
retryDelRes = await deleteCredentialKey(ctx, targetEnv)
|
|
176
|
+
} catch {
|
|
177
|
+
retryDelRes = null
|
|
178
|
+
}
|
|
179
|
+
if (retryDelRes && retryDelRes.ok) {
|
|
180
|
+
pendingSecretCleanupAccounts.delete(targetEnv)
|
|
181
|
+
clearUsageCache()
|
|
182
|
+
clearProbeCache()
|
|
183
|
+
return writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: true })
|
|
184
|
+
}
|
|
185
|
+
return writeJson(res, 500, {
|
|
186
|
+
ok: false,
|
|
187
|
+
partial: true,
|
|
188
|
+
accountRemoved: true,
|
|
189
|
+
secretDeleted: false,
|
|
190
|
+
error: `Failed to delete secret for account "${targetEnv}" from credentials store on retry`,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
return writeJson(res, 404, {
|
|
194
|
+
ok: false,
|
|
195
|
+
error: `Account "${targetEnv}" not found in accounts pool`,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const nextAccounts = existingAccounts.filter((a) => a && a.apiKeyEnv !== targetEnv)
|
|
200
|
+
let activeAccount = curr.activeAccount
|
|
201
|
+
if (activeAccount === targetEnv) {
|
|
202
|
+
activeAccount = ''
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Persist settings changes first before unsetting credentials (#184)
|
|
206
|
+
const next = plainConfig(Config({ ...curr, accounts: nextAccounts, activeAccount }))
|
|
207
|
+
await settingsApi.replace(next)
|
|
208
|
+
await syncProviderState(next)
|
|
209
|
+
|
|
210
|
+
let delRes = null
|
|
211
|
+
if (body.deleteSecret) {
|
|
212
|
+
try {
|
|
213
|
+
delRes = await deleteCredentialKey(ctx, targetEnv)
|
|
214
|
+
} catch {
|
|
215
|
+
delRes = null
|
|
216
|
+
}
|
|
217
|
+
if (!delRes || !delRes.ok) {
|
|
218
|
+
let rollbackSuccess = false
|
|
219
|
+
try {
|
|
220
|
+
const fresh = plainConfig(live())
|
|
221
|
+
const freshAccounts = Array.isArray(fresh.accounts) ? [...fresh.accounts] : []
|
|
222
|
+
if (!freshAccounts.some((a) => a && a.apiKeyEnv === targetEnv)) {
|
|
223
|
+
freshAccounts.push(accountToDelete)
|
|
224
|
+
}
|
|
225
|
+
const restoredConfig = plainConfig(Config({
|
|
226
|
+
...fresh,
|
|
227
|
+
accounts: freshAccounts,
|
|
228
|
+
activeAccount: fresh.activeAccount,
|
|
229
|
+
}))
|
|
230
|
+
await settingsApi.replace(restoredConfig)
|
|
231
|
+
await syncProviderState(restoredConfig)
|
|
232
|
+
rollbackSuccess = true
|
|
233
|
+
} catch {
|
|
234
|
+
rollbackSuccess = false
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (rollbackSuccess) {
|
|
238
|
+
return writeJson(res, 500, {
|
|
239
|
+
ok: false,
|
|
240
|
+
partial: false,
|
|
241
|
+
accountRemoved: false,
|
|
242
|
+
secretDeleted: false,
|
|
243
|
+
error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
pendingSecretCleanupAccounts.add(targetEnv)
|
|
248
|
+
return writeJson(res, 500, {
|
|
249
|
+
ok: false,
|
|
250
|
+
partial: true,
|
|
251
|
+
accountRemoved: true,
|
|
252
|
+
secretDeleted: false,
|
|
253
|
+
error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
pendingSecretCleanupAccounts.delete(targetEnv)
|
|
259
|
+
clearUsageCache()
|
|
260
|
+
clearProbeCache()
|
|
261
|
+
|
|
262
|
+
writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: Boolean(body.deleteSecret && delRes?.ok) })
|
|
263
|
+
} catch (err) {
|
|
264
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// POST /dsh-clinebot/accounts/delete — delete account endpoint
|
|
269
|
+
ctx.effect(() => ctx.webServer.register({
|
|
270
|
+
kind: 'exact',
|
|
271
|
+
path: '/dsh-clinebot/accounts/delete',
|
|
272
|
+
handler: async (req, res) => {
|
|
273
|
+
if (req.method !== 'POST' && req.method !== 'DELETE') {
|
|
274
|
+
return writeJson(res, 405, { ok: false, error: 'POST or DELETE only' })
|
|
275
|
+
}
|
|
276
|
+
return handleDeleteAccount(req, res)
|
|
277
|
+
},
|
|
278
|
+
}), 'dsh-clinebot: /accounts/delete')
|
|
279
|
+
|
|
280
|
+
// POST /dsh-clinebot/accounts/active — switch or pin active account
|
|
281
|
+
ctx.effect(() => ctx.webServer.register({
|
|
282
|
+
kind: 'exact',
|
|
283
|
+
path: '/dsh-clinebot/accounts/active',
|
|
284
|
+
handler: async (req, res) => {
|
|
285
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
286
|
+
if (!assertTrustedSettingsRequest(req, res, ctx)) return
|
|
287
|
+
try {
|
|
288
|
+
const bodyBuf = await readBody(req)
|
|
289
|
+
let body = {}
|
|
290
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
|
|
291
|
+
const account = String(body.account || '').trim()
|
|
292
|
+
|
|
293
|
+
clearUsageCache()
|
|
294
|
+
clearProbeCache()
|
|
295
|
+
const settingsApi = getSettingsApi()
|
|
296
|
+
if (!settingsApi || typeof settingsApi.replace !== 'function') {
|
|
297
|
+
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const currentLive = plainConfig(live())
|
|
301
|
+
const accountChanged = currentLive.activeAccount !== account
|
|
302
|
+
const next = plainConfig(Config({
|
|
303
|
+
...currentLive,
|
|
304
|
+
activeAccount: account,
|
|
305
|
+
...(accountChanged ? { dynamicModels: [], planSyncedAt: 0 } : {}),
|
|
306
|
+
}))
|
|
307
|
+
await settingsApi.replace(next)
|
|
308
|
+
await syncProviderState(next)
|
|
309
|
+
writeJson(res, 200, { ok: true, activeAccount: account })
|
|
310
|
+
} catch (err) {
|
|
311
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
}), 'dsh-clinebot: /accounts/active')
|
|
315
|
+
|
|
316
|
+
// POST /dsh-clinebot/register — upsert into DSH llm-pi-ai
|
|
317
|
+
ctx.effect(() => ctx.webServer.register({
|
|
318
|
+
kind: 'exact',
|
|
319
|
+
path: '/dsh-clinebot/register',
|
|
320
|
+
handler: async (req, res) => {
|
|
321
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
322
|
+
if (!assertTrustedSettingsRequest(req, res, ctx)) return
|
|
323
|
+
try {
|
|
324
|
+
const bodyBuf = await readBody(req)
|
|
325
|
+
let body = {}
|
|
326
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
|
|
327
|
+
const activeModels = body.models || publicConfig(live()).enabledModels
|
|
328
|
+
const result = await upsertPiAiProvider(ctx, live(), activeModels)
|
|
329
|
+
writeJson(res, 200, { ok: true, provider: result })
|
|
330
|
+
} catch (err) {
|
|
331
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
}), 'dsh-clinebot: /register')
|
|
335
|
+
|
|
336
|
+
// POST /dsh-clinebot/unregister — remove from DSH
|
|
337
|
+
ctx.effect(() => ctx.webServer.register({
|
|
338
|
+
kind: 'exact',
|
|
339
|
+
path: '/dsh-clinebot/unregister',
|
|
340
|
+
handler: async (req, res) => {
|
|
341
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
342
|
+
if (!assertTrustedSettingsRequest(req, res, ctx)) return
|
|
343
|
+
try {
|
|
344
|
+
await removePiAiProvider(ctx)
|
|
345
|
+
writeJson(res, 200, { ok: true })
|
|
346
|
+
} catch (err) {
|
|
347
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
}), 'dsh-clinebot: /unregister')
|
|
351
|
+
|
|
352
|
+
// GET /dsh-clinebot/keys/discovered — scan local Cline CLI and VS Code extension keys (#232, #246)
|
|
353
|
+
ctx.effect(() => ctx.webServer.register({
|
|
354
|
+
kind: 'exact',
|
|
355
|
+
path: '/dsh-clinebot/keys/discovered',
|
|
356
|
+
handler: async (req, res) => {
|
|
357
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
358
|
+
if (!assertTrustedSettingsRequest(req, res, ctx)) return
|
|
359
|
+
try {
|
|
360
|
+
const discovered = discoverLocalClineKeys()
|
|
361
|
+
writeJson(res, 200, { ok: true, discovered })
|
|
362
|
+
} catch (err) {
|
|
363
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
}), 'dsh-clinebot: /keys/discovered')
|
|
367
|
+
|
|
368
|
+
// POST /dsh-clinebot/keys/discovered/import — safely import discovered key without exposing secret (#246)
|
|
369
|
+
ctx.effect(() => ctx.webServer.register({
|
|
370
|
+
kind: 'exact',
|
|
371
|
+
path: '/dsh-clinebot/keys/discovered/import',
|
|
372
|
+
handler: async (req, res) => {
|
|
373
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
374
|
+
if (!assertTrustedSettingsRequest(req, res, ctx)) return
|
|
375
|
+
const settingsApi = getSettingsApi()
|
|
376
|
+
if (!settingsApi || typeof settingsApi.replace !== 'function') {
|
|
377
|
+
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
const bodyBuf = await readBody(req)
|
|
381
|
+
let body = {}
|
|
382
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
|
|
383
|
+
|
|
384
|
+
const id = String(body.id || '').trim()
|
|
385
|
+
if (!id) {
|
|
386
|
+
return writeJson(res, 400, { ok: false, error: 'Discovered key id is required' })
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const apiKey = consumeDiscoveredKey(id)
|
|
390
|
+
if (!apiKey) {
|
|
391
|
+
return writeJson(res, 404, { ok: false, error: 'Discovered key not found or expired' })
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const result = await addAccountInternal({
|
|
395
|
+
apiKey,
|
|
396
|
+
label: body.label,
|
|
397
|
+
apiKeyEnv: body.apiKeyEnv,
|
|
398
|
+
})
|
|
399
|
+
writeJson(res, 200, { ok: true, ...result })
|
|
400
|
+
} catch (err) {
|
|
401
|
+
const status = typeof err?.status === 'number' ? err.status : 500
|
|
402
|
+
writeJson(res, status, { ok: false, error: String(err?.message || err) })
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
}), 'dsh-clinebot: /keys/discovered/import')
|
|
406
|
+
|
|
407
|
+
// GET /dsh-clinebot/accounts/usage — per-account live quota report
|
|
408
|
+
ctx.effect(() => ctx.webServer.register({
|
|
409
|
+
kind: 'exact',
|
|
410
|
+
path: '/dsh-clinebot/accounts/usage',
|
|
411
|
+
handler: async (req, res) => {
|
|
412
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
413
|
+
if (!assertTrustedSettingsRequest(req, res, ctx)) return
|
|
414
|
+
try {
|
|
415
|
+
const cfg = live()
|
|
416
|
+
const pub = publicConfig(cfg)
|
|
417
|
+
const pool = await resolveAccountPool(ctx, cfg)
|
|
418
|
+
const active = await resolveActiveAccountKey(ctx, cfg, pool)
|
|
419
|
+
const timeoutMs = Math.min(2500, Number(pub.timeoutMs) || 2500)
|
|
420
|
+
const accounts = await Promise.all(pool.map(async (account) => {
|
|
421
|
+
let usage = null
|
|
422
|
+
let error = null
|
|
423
|
+
if (account.present && account.value) {
|
|
424
|
+
usage = await fetchUsageLimits(pub.baseUrl, account.value, { timeoutMs }).catch((err) => {
|
|
425
|
+
error = String(err?.message || err)
|
|
426
|
+
return null
|
|
427
|
+
})
|
|
428
|
+
if (usage && usage.ok === false) error = usage.error || 'usage unavailable'
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
id: account.id,
|
|
432
|
+
label: account.label,
|
|
433
|
+
apiKeyEnv: account.apiKeyEnv,
|
|
434
|
+
configured: Boolean(account.present && account.value),
|
|
435
|
+
source: account.source,
|
|
436
|
+
active: account.apiKeyEnv === active.apiKeyEnv,
|
|
437
|
+
isPinned: Boolean(account.isPinned),
|
|
438
|
+
usage: usage ? publicUsage(usage) : null,
|
|
439
|
+
error,
|
|
440
|
+
}
|
|
441
|
+
}))
|
|
442
|
+
return writeJson(res, 200, {
|
|
443
|
+
ok: true,
|
|
444
|
+
activeAccount: active.apiKeyEnv,
|
|
445
|
+
accountMode: pub.accountMode,
|
|
446
|
+
checkedAt: Date.now(),
|
|
447
|
+
accounts,
|
|
448
|
+
})
|
|
449
|
+
} catch (err) {
|
|
450
|
+
return writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
}), 'dsh-clinebot: /accounts/usage')
|
|
454
|
+
|
|
455
|
+
// POST /dsh-clinebot/accounts/rename — rename a pooled account
|
|
456
|
+
ctx.effect(() => ctx.webServer.register({
|
|
457
|
+
kind: 'exact',
|
|
458
|
+
path: '/dsh-clinebot/accounts/rename',
|
|
459
|
+
handler: async (req, res) => {
|
|
460
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
461
|
+
if (!assertTrustedSettingsRequest(req, res, ctx)) return
|
|
462
|
+
const settingsApi = getSettingsApi()
|
|
463
|
+
if (!settingsApi || typeof settingsApi.replace !== 'function') {
|
|
464
|
+
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
const bodyBuf = await readBody(req)
|
|
468
|
+
let body = {}
|
|
469
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
|
|
470
|
+
const apiKeyEnv = String(body.apiKeyEnv || '').trim()
|
|
471
|
+
const label = String(body.label || '').trim()
|
|
472
|
+
if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(apiKeyEnv)) {
|
|
473
|
+
return writeJson(res, 400, { ok: false, error: `Disallowed apiKeyEnv: "${apiKeyEnv}"` })
|
|
474
|
+
}
|
|
475
|
+
const curr = plainConfig(live())
|
|
476
|
+
if (apiKeyEnv === curr.apiKeyEnv || apiKeyEnv === DEFAULT_API_KEY_ENV) {
|
|
477
|
+
return writeJson(res, 400, { ok: false, error: 'Cannot rename the primary account' })
|
|
478
|
+
}
|
|
479
|
+
const accounts = Array.isArray(curr.accounts) ? [...curr.accounts] : []
|
|
480
|
+
const index = accounts.findIndex((account) => account && account.apiKeyEnv === apiKeyEnv)
|
|
481
|
+
if (index < 0) return writeJson(res, 404, { ok: false, error: `Account "${apiKeyEnv}" not found` })
|
|
482
|
+
accounts[index] = { ...accounts[index], label: label || apiKeyEnv }
|
|
483
|
+
const next = plainConfig(Config({ ...curr, accounts }))
|
|
484
|
+
await settingsApi.replace(next)
|
|
485
|
+
await syncProviderState(next)
|
|
486
|
+
return writeJson(res, 200, { ok: true, accounts: next.accounts })
|
|
487
|
+
} catch (err) {
|
|
488
|
+
return writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
}), 'dsh-clinebot: /accounts/rename')
|
|
492
|
+
|
|
493
|
+
// POST /dsh-clinebot/accounts/key/clear — remove a stored key but keep the account row
|
|
494
|
+
ctx.effect(() => ctx.webServer.register({
|
|
495
|
+
kind: 'exact',
|
|
496
|
+
path: '/dsh-clinebot/accounts/key/clear',
|
|
497
|
+
handler: async (req, res) => {
|
|
498
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
499
|
+
if (!assertTrustedSettingsRequest(req, res, ctx)) return
|
|
500
|
+
try {
|
|
501
|
+
const bodyBuf = await readBody(req)
|
|
502
|
+
let body = {}
|
|
503
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
|
|
504
|
+
const apiKeyEnv = String(body.apiKeyEnv || '').trim()
|
|
505
|
+
if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(apiKeyEnv)) {
|
|
506
|
+
return writeJson(res, 400, { ok: false, error: `Disallowed apiKeyEnv: "${apiKeyEnv}"` })
|
|
507
|
+
}
|
|
508
|
+
const curr = plainConfig(live())
|
|
509
|
+
const isPrimary = apiKeyEnv === curr.apiKeyEnv || apiKeyEnv === DEFAULT_API_KEY_ENV
|
|
510
|
+
const inPool = Array.isArray(curr.accounts) && curr.accounts.some((account) => account && account.apiKeyEnv === apiKeyEnv)
|
|
511
|
+
if (!isPrimary && !inPool) {
|
|
512
|
+
return writeJson(res, 404, { ok: false, error: `Account "${apiKeyEnv}" not found` })
|
|
513
|
+
}
|
|
514
|
+
const deleted = await deleteCredentialKey(ctx, apiKeyEnv).catch(() => ({ ok: false }))
|
|
515
|
+
clearUsageCache()
|
|
516
|
+
clearProbeCache()
|
|
517
|
+
await syncProviderState(live())
|
|
518
|
+
return writeJson(res, 200, { ok: true, apiKeyEnv, cleared: Boolean(deleted?.ok) })
|
|
519
|
+
} catch (err) {
|
|
520
|
+
return writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
}), 'dsh-clinebot: /accounts/key/clear')
|
|
404
524
|
}
|