@goodandready/dsh-clinebot 0.3.25 → 0.4.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/CHANGELOG.md +32 -0
- package/README.md +21 -5
- package/README.ru.md +21 -5
- package/README.zh.md +21 -5
- package/lib/account-pool.js +59 -4
- package/lib/client.js +596 -200
- package/lib/cline-client.js +45 -45
- package/lib/config.js +25 -4
- package/lib/http.js +15 -2
- package/lib/index.js +117 -50
- package/lib/models.js +57 -25
- package/lib/provider-sync.js +60 -22
- package/lib/routes/accounts.js +178 -10
- package/lib/routes/auth.js +80 -38
- package/lib/routes/models.js +32 -29
- package/lib/routes/settings.js +83 -11
- package/lib/slash-command.js +91 -14
- package/lib/telemetry.js +78 -0
- package/package.json +4 -1
package/lib/provider-sync.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import os from 'node:os'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import { publicConfig, Config, LLM_PI_AI_NS } from './config.js'
|
|
3
|
+
import { publicConfig, plainConfig, Config, LLM_PI_AI_NS } from './config.js'
|
|
4
4
|
import { publicUsage } from './http.js'
|
|
5
5
|
import {
|
|
6
6
|
PROVIDER_ID,
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
fetchUsageLimits,
|
|
17
17
|
buildPiAiProvider,
|
|
18
18
|
sessionStats,
|
|
19
|
+
getLastRotation,
|
|
20
|
+
usageCache,
|
|
19
21
|
} from './cline-client.js'
|
|
20
22
|
|
|
21
23
|
export function resolvePathWithHome(p) {
|
|
@@ -29,13 +31,27 @@ export function resolvePathWithHome(p) {
|
|
|
29
31
|
|
|
30
32
|
export async function checkRegisteredInPiAi(ctx) {
|
|
31
33
|
const settings = ctx?.get?.('settings')
|
|
32
|
-
if (!settings?.get) return false
|
|
33
34
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
if (typeof settings?.describe === 'function') {
|
|
36
|
+
const descriptors = settings.describe()
|
|
37
|
+
const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
|
|
38
|
+
if (piAiDesc?.value?.providers?.[PROVIDER_ID]) return true
|
|
39
|
+
}
|
|
40
|
+
if (typeof settings?.get === 'function') {
|
|
41
|
+
const piAi = settings.get(LLM_PI_AI_NS)
|
|
42
|
+
if (piAi?.providers?.[PROVIDER_ID]) return true
|
|
43
|
+
}
|
|
44
|
+
const llm = ctx?.get?.('llm')
|
|
45
|
+
if (typeof llm?.listProviders === 'function') {
|
|
46
|
+
const list = await llm.listProviders()
|
|
47
|
+
if (Array.isArray(list) && list.some((p) => (p?.id || p) === PROVIDER_ID)) {
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
36
51
|
} catch {
|
|
37
52
|
return false
|
|
38
53
|
}
|
|
54
|
+
return false
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
export async function resolveActiveAccountKey(ctx, cfg, pool = null) {
|
|
@@ -100,19 +116,26 @@ export async function buildStatus(ctx, cfg) {
|
|
|
100
116
|
present: !!(activeAcc.value || key.value),
|
|
101
117
|
source: activeAcc.source || key.source,
|
|
102
118
|
},
|
|
103
|
-
accounts: pool.map((acc) =>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
119
|
+
accounts: pool.map((acc) => {
|
|
120
|
+
const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
|
|
121
|
+
const cached = usageCache.get(cacheKey)?.data
|
|
122
|
+
const pct = cached?.windows?.fiveHour?.percentUsed
|
|
123
|
+
return {
|
|
124
|
+
id: acc.id,
|
|
125
|
+
label: acc.label,
|
|
126
|
+
apiKeyEnv: acc.apiKeyEnv,
|
|
127
|
+
present: acc.present,
|
|
128
|
+
source: acc.source,
|
|
129
|
+
isPinned: acc.isPinned,
|
|
130
|
+
percentUsed: typeof pct === 'number' ? pct : null,
|
|
131
|
+
}
|
|
132
|
+
}),
|
|
111
133
|
activeAccount: activeAcc.apiKeyEnv,
|
|
112
134
|
health,
|
|
113
135
|
usage: publicUsage(usage),
|
|
114
136
|
quotaWarning,
|
|
115
137
|
sessionStats: { ...sessionStats },
|
|
138
|
+
lastRotation: getLastRotation(),
|
|
116
139
|
isRegistered,
|
|
117
140
|
availableModels: allModels,
|
|
118
141
|
}
|
|
@@ -129,9 +152,11 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
|
|
|
129
152
|
const allowedSet = new Set(activeModelIds || pub.enabledModels)
|
|
130
153
|
const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
|
|
131
154
|
|
|
155
|
+
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
156
|
+
|
|
132
157
|
const providerObj = buildPiAiProvider({
|
|
133
158
|
baseUrl: pub.baseUrl,
|
|
134
|
-
apiKeyEnv: pub.apiKeyEnv,
|
|
159
|
+
apiKeyEnv: activeAcc?.apiKeyEnv || pub.apiKeyEnv,
|
|
135
160
|
models: modelsToRegister.length ? modelsToRegister : allModels,
|
|
136
161
|
customModels: pub.dynamicModels,
|
|
137
162
|
displayName: PROVIDER_DISPLAY_NAME,
|
|
@@ -154,12 +179,19 @@ export async function removePiAiProvider(ctx) {
|
|
|
154
179
|
throw new Error('DSH settings service unavailable')
|
|
155
180
|
}
|
|
156
181
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
182
|
+
try {
|
|
183
|
+
await settings.mutate(LLM_PI_AI_NS, [
|
|
184
|
+
{
|
|
185
|
+
op: 'remove',
|
|
186
|
+
path: ['providers', PROVIDER_ID],
|
|
187
|
+
},
|
|
188
|
+
])
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const msg = String(err?.message || err).toLowerCase()
|
|
191
|
+
if (!msg.includes('not found') && !msg.includes('absent') && !msg.includes('enoent') && !msg.includes('does not exist')) {
|
|
192
|
+
throw err
|
|
193
|
+
}
|
|
194
|
+
}
|
|
163
195
|
return { ok: true }
|
|
164
196
|
}
|
|
165
197
|
|
|
@@ -180,7 +212,11 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
|
|
|
180
212
|
if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
|
|
181
213
|
const fromDisk = await loadModelsDiskCache(cacheFile)
|
|
182
214
|
if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
|
|
183
|
-
const next = Config({
|
|
215
|
+
const next = Config({
|
|
216
|
+
...plainConfig(live()),
|
|
217
|
+
dynamicModels: fromDisk,
|
|
218
|
+
planSyncedAt: fromDisk.planSyncedAt || Date.now(),
|
|
219
|
+
})
|
|
184
220
|
await settingsApi.replace(next)
|
|
185
221
|
await syncProviderState(next)
|
|
186
222
|
}
|
|
@@ -200,14 +236,16 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
|
|
|
200
236
|
const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
|
|
201
237
|
|
|
202
238
|
if (hasNew && settingsApi?.replace) {
|
|
239
|
+
const now = Date.now()
|
|
203
240
|
const next = Config({
|
|
204
|
-
...live(),
|
|
241
|
+
...plainConfig(live()),
|
|
205
242
|
dynamicModels: usageData.dynamicModels,
|
|
243
|
+
planSyncedAt: now,
|
|
206
244
|
})
|
|
207
245
|
await settingsApi.replace(next)
|
|
208
246
|
await syncProviderState(next)
|
|
209
247
|
if (cacheFile) {
|
|
210
|
-
await saveModelsDiskCache(cacheFile, usageData.dynamicModels)
|
|
248
|
+
await saveModelsDiskCache(cacheFile, usageData.dynamicModels, now)
|
|
211
249
|
}
|
|
212
250
|
}
|
|
213
251
|
}
|
package/lib/routes/accounts.js
CHANGED
|
@@ -1,10 +1,178 @@
|
|
|
1
1
|
import { writeJson, readBody } from '../http.js'
|
|
2
|
-
import {
|
|
3
|
-
import { publicConfig, Config } from '../config.js'
|
|
2
|
+
import { assertTrustedSettingsRequest } from '../access.js'
|
|
3
|
+
import { publicConfig, plainConfig, Config } from '../config.js'
|
|
4
4
|
import { upsertPiAiProvider, removePiAiProvider } from '../provider-sync.js'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
clearUsageCache,
|
|
7
|
+
clearProbeCache,
|
|
8
|
+
saveCredentialKey,
|
|
9
|
+
deleteCredentialKey,
|
|
10
|
+
smokeChat,
|
|
11
|
+
DEFAULT_API_KEY_ENV,
|
|
12
|
+
} from '../cline-client.js'
|
|
6
13
|
|
|
7
14
|
export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
|
|
15
|
+
// POST /dsh-clinebot/accounts — add or update account in pool
|
|
16
|
+
ctx.effect(() => ctx.webServer.register({
|
|
17
|
+
kind: 'exact',
|
|
18
|
+
path: '/dsh-clinebot/accounts',
|
|
19
|
+
handler: async (req, res) => {
|
|
20
|
+
if (req.method === 'DELETE') {
|
|
21
|
+
return handleDeleteAccount(req, res)
|
|
22
|
+
}
|
|
23
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST or DELETE only' })
|
|
24
|
+
if (!assertTrustedSettingsRequest(req, res)) return
|
|
25
|
+
const settingsApi = getSettingsApi()
|
|
26
|
+
if (!settingsApi || typeof settingsApi.replace !== 'function') {
|
|
27
|
+
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const bodyBuf = await readBody(req)
|
|
31
|
+
let body = {}
|
|
32
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
|
|
33
|
+
|
|
34
|
+
const apiKey = String(body.apiKey || '').trim()
|
|
35
|
+
if (!apiKey) {
|
|
36
|
+
return writeJson(res, 400, { ok: false, error: 'API key is required' })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const curr = plainConfig(live())
|
|
40
|
+
const existingAccounts = Array.isArray(curr.accounts) ? [...curr.accounts] : []
|
|
41
|
+
|
|
42
|
+
let targetEnv = String(body.apiKeyEnv || '').trim()
|
|
43
|
+
if (!targetEnv) {
|
|
44
|
+
let maxN = 1
|
|
45
|
+
for (const acc of existingAccounts) {
|
|
46
|
+
const m = String(acc?.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
|
|
47
|
+
if (m) {
|
|
48
|
+
const num = parseInt(m[1], 10)
|
|
49
|
+
if (num >= maxN) maxN = num + 1
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (curr.apiKeyEnv && curr.apiKeyEnv.startsWith('CLINEBOT_API_KEY_')) {
|
|
53
|
+
const m = curr.apiKeyEnv.match(/^CLINEBOT_API_KEY_(\d+)$/)
|
|
54
|
+
if (m) {
|
|
55
|
+
const num = parseInt(m[1], 10)
|
|
56
|
+
if (num >= maxN) maxN = num + 1
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
targetEnv = `CLINEBOT_API_KEY_${maxN}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)) {
|
|
63
|
+
return writeJson(res, 400, {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: `Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$`,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const label = String(body.label || '').trim() || targetEnv
|
|
70
|
+
|
|
71
|
+
await saveCredentialKey(ctx, targetEnv, apiKey)
|
|
72
|
+
|
|
73
|
+
const idx = existingAccounts.findIndex((a) => a && a.apiKeyEnv === targetEnv)
|
|
74
|
+
const accountEntry = { label, apiKeyEnv: targetEnv }
|
|
75
|
+
if (idx >= 0) {
|
|
76
|
+
existingAccounts[idx] = accountEntry
|
|
77
|
+
} else {
|
|
78
|
+
existingAccounts.push(accountEntry)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const next = Config({ ...curr, accounts: existingAccounts })
|
|
82
|
+
await settingsApi.replace(next)
|
|
83
|
+
await syncProviderState(next)
|
|
84
|
+
|
|
85
|
+
let validation = { ok: false, error: 'Skipped validation: baseUrl is not https' }
|
|
86
|
+
try {
|
|
87
|
+
const parsed = new URL(curr.baseUrl)
|
|
88
|
+
if (parsed.protocol === 'https:') {
|
|
89
|
+
validation = await smokeChat(curr.baseUrl, apiKey, {
|
|
90
|
+
model: curr.defaultModel,
|
|
91
|
+
timeoutMs: 15000,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
validation = { ok: false, error: 'Invalid baseUrl' }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
clearUsageCache()
|
|
99
|
+
clearProbeCache()
|
|
100
|
+
|
|
101
|
+
writeJson(res, 200, {
|
|
102
|
+
ok: true,
|
|
103
|
+
account: accountEntry,
|
|
104
|
+
validated: validation.ok,
|
|
105
|
+
latencyMs: validation.latencyMs,
|
|
106
|
+
validationError: validation.ok ? null : validation.error,
|
|
107
|
+
})
|
|
108
|
+
} catch (err) {
|
|
109
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}), 'dsh-clinebot: /accounts')
|
|
113
|
+
|
|
114
|
+
async function handleDeleteAccount(req, res, targetEnvFromUrl = '') {
|
|
115
|
+
if (!assertTrustedSettingsRequest(req, res)) return
|
|
116
|
+
const settingsApi = getSettingsApi()
|
|
117
|
+
if (!settingsApi || typeof settingsApi.replace !== 'function') {
|
|
118
|
+
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
let body = {}
|
|
122
|
+
if (req.method === 'POST' || req.method === 'DELETE') {
|
|
123
|
+
try {
|
|
124
|
+
const bodyBuf = await readBody(req)
|
|
125
|
+
body = JSON.parse(bodyBuf.toString('utf8'))
|
|
126
|
+
} catch {
|
|
127
|
+
body = {}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const targetEnv = String(targetEnvFromUrl || body.apiKeyEnv || '').trim()
|
|
131
|
+
if (!targetEnv) {
|
|
132
|
+
return writeJson(res, 400, { ok: false, error: 'apiKeyEnv is required' })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const curr = plainConfig(live())
|
|
136
|
+
if (targetEnv === curr.apiKeyEnv || targetEnv === DEFAULT_API_KEY_ENV) {
|
|
137
|
+
return writeJson(res, 400, { ok: false, error: 'Cannot delete the primary account' })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
|
|
141
|
+
const nextAccounts = existingAccounts.filter((a) => a && a.apiKeyEnv !== targetEnv)
|
|
142
|
+
let activeAccount = curr.activeAccount
|
|
143
|
+
if (activeAccount === targetEnv) {
|
|
144
|
+
activeAccount = ''
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const next = Config({ ...curr, accounts: nextAccounts, activeAccount })
|
|
148
|
+
await settingsApi.replace(next)
|
|
149
|
+
await syncProviderState(next)
|
|
150
|
+
|
|
151
|
+
if (body.deleteSecret) {
|
|
152
|
+
await deleteCredentialKey(ctx, targetEnv)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
clearUsageCache()
|
|
156
|
+
clearProbeCache()
|
|
157
|
+
|
|
158
|
+
writeJson(res, 200, { ok: true, removed: targetEnv })
|
|
159
|
+
} catch (err) {
|
|
160
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// POST /dsh-clinebot/accounts/delete — delete account endpoint
|
|
165
|
+
ctx.effect(() => ctx.webServer.register({
|
|
166
|
+
kind: 'exact',
|
|
167
|
+
path: '/dsh-clinebot/accounts/delete',
|
|
168
|
+
handler: async (req, res) => {
|
|
169
|
+
if (req.method !== 'POST' && req.method !== 'DELETE') {
|
|
170
|
+
return writeJson(res, 405, { ok: false, error: 'POST or DELETE only' })
|
|
171
|
+
}
|
|
172
|
+
return handleDeleteAccount(req, res)
|
|
173
|
+
},
|
|
174
|
+
}), 'dsh-clinebot: /accounts/delete')
|
|
175
|
+
|
|
8
176
|
// POST /dsh-clinebot/accounts/active — switch or pin active account
|
|
9
177
|
ctx.effect(() => ctx.webServer.register({
|
|
10
178
|
kind: 'exact',
|
|
@@ -21,14 +189,14 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
21
189
|
clearUsageCache()
|
|
22
190
|
clearProbeCache()
|
|
23
191
|
const settingsApi = getSettingsApi()
|
|
24
|
-
if (settingsApi
|
|
25
|
-
|
|
26
|
-
await settingsApi.replace(next)
|
|
27
|
-
await syncProviderState(next)
|
|
28
|
-
writeJson(res, 200, { ok: true, activeAccount: next.activeAccount })
|
|
29
|
-
} else {
|
|
30
|
-
writeJson(res, 200, { ok: true, activeAccount: account })
|
|
192
|
+
if (!settingsApi || typeof settingsApi.replace !== 'function') {
|
|
193
|
+
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
31
194
|
}
|
|
195
|
+
|
|
196
|
+
const next = Config({ ...plainConfig(live()), activeAccount: account })
|
|
197
|
+
await settingsApi.replace(next)
|
|
198
|
+
await syncProviderState(next)
|
|
199
|
+
writeJson(res, 200, { ok: true, activeAccount: account })
|
|
32
200
|
} catch (err) {
|
|
33
201
|
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
34
202
|
}
|
package/lib/routes/auth.js
CHANGED
|
@@ -1,51 +1,94 @@
|
|
|
1
1
|
import { writeJson, readBody } from '../http.js'
|
|
2
|
-
import {
|
|
2
|
+
import { assertTrustedSettingsRequest } from '../access.js'
|
|
3
3
|
import { publicConfig } from '../config.js'
|
|
4
4
|
import { resolveActiveAccountKey } from '../provider-sync.js'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
smokeChat,
|
|
7
|
+
recordSmokeTest,
|
|
8
|
+
rotateToNextAccount,
|
|
9
|
+
DEFAULT_MODEL_ID,
|
|
10
|
+
normalizeBaseUrl,
|
|
11
|
+
} from '../cline-client.js'
|
|
6
12
|
|
|
7
13
|
export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
// POST /dsh-clinebot/auth/begin — start loopback auth listener
|
|
14
|
+
// POST /dsh-clinebot/key/verify — on-the-fly verification of Cline API key
|
|
11
15
|
ctx.effect(() => ctx.webServer.register({
|
|
12
16
|
kind: 'exact',
|
|
13
|
-
path: '/dsh-clinebot/
|
|
17
|
+
path: '/dsh-clinebot/key/verify',
|
|
14
18
|
handler: async (req, res) => {
|
|
15
|
-
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
16
19
|
if (!assertTrustedSettingsRequest(req, res)) return
|
|
20
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
17
21
|
try {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
const bodyBuf = await readBody(req)
|
|
23
|
+
let body = {}
|
|
24
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
|
|
25
|
+
const key = String(body.key || '').trim()
|
|
26
|
+
if (!key) {
|
|
27
|
+
return writeJson(res, 400, { ok: false, valid: false, error: 'Key is empty' })
|
|
28
|
+
}
|
|
29
|
+
const pub = publicConfig(live())
|
|
30
|
+
const base = normalizeBaseUrl(pub.baseUrl)
|
|
31
|
+
|
|
32
|
+
let parsedBase
|
|
33
|
+
try {
|
|
34
|
+
parsedBase = new URL(base)
|
|
35
|
+
} catch {
|
|
36
|
+
return writeJson(res, 400, { ok: false, valid: false, error: 'Invalid baseUrl' })
|
|
37
|
+
}
|
|
38
|
+
if (parsedBase.protocol !== 'https:' && parsedBase.hostname !== 'localhost' && parsedBase.hostname !== '127.0.0.1') {
|
|
39
|
+
return writeJson(res, 400, {
|
|
40
|
+
ok: false,
|
|
41
|
+
valid: false,
|
|
42
|
+
error: 'Insecure baseUrl: key verification requires https://',
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ac = new AbortController()
|
|
47
|
+
const timer = setTimeout(() => ac.abort(), 6000)
|
|
48
|
+
try {
|
|
49
|
+
const [meRes, planRes] = await Promise.all([
|
|
50
|
+
fetch(`${base}/users/me`, {
|
|
51
|
+
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
|
|
52
|
+
signal: ac.signal,
|
|
53
|
+
}).catch((err) => ({ ok: false, status: 500, error: err })),
|
|
54
|
+
fetch(`${base}/users/me/plan`, {
|
|
55
|
+
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
|
|
56
|
+
signal: ac.signal,
|
|
57
|
+
}).catch(() => null),
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
if (!meRes.ok) {
|
|
61
|
+
return writeJson(res, 200, {
|
|
62
|
+
ok: false,
|
|
63
|
+
valid: false,
|
|
64
|
+
error: meRes.status === 401 ? 'Invalid API key (HTTP 401 Unauthorized)' : `Upstream returned HTTP ${meRes.status}`,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const meData = await meRes.json().catch(() => ({}))
|
|
69
|
+
const email = meData?.data?.email || meData?.email || 'authenticated user'
|
|
70
|
+
|
|
71
|
+
let planName = 'ClinePass'
|
|
72
|
+
if (planRes && planRes.ok) {
|
|
73
|
+
const planData = await planRes.json().catch(() => ({}))
|
|
74
|
+
const plan = planData?.data?.plan || planData?.data || planData?.plan || planData
|
|
75
|
+
planName = plan?.displayName || plan?.title || plan?.name || 'ClinePass'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return writeJson(res, 200, {
|
|
79
|
+
ok: true,
|
|
80
|
+
valid: true,
|
|
81
|
+
email,
|
|
82
|
+
plan: planName,
|
|
83
|
+
})
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(timer)
|
|
23
86
|
}
|
|
24
|
-
writeJson(res, 200, {
|
|
25
|
-
ok: true,
|
|
26
|
-
status: authSession.state,
|
|
27
|
-
authUrl,
|
|
28
|
-
})
|
|
29
87
|
} catch (err) {
|
|
30
|
-
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
88
|
+
return writeJson(res, 500, { ok: false, valid: false, error: String(err?.message || err) })
|
|
31
89
|
}
|
|
32
90
|
},
|
|
33
|
-
}), 'dsh-clinebot: /
|
|
34
|
-
|
|
35
|
-
// GET /dsh-clinebot/auth/status — query current fast auth state
|
|
36
|
-
ctx.effect(() => ctx.webServer.register({
|
|
37
|
-
kind: 'exact',
|
|
38
|
-
path: '/dsh-clinebot/auth/status',
|
|
39
|
-
handler: async (req, res) => {
|
|
40
|
-
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
41
|
-
if (!assertTrustedSettingsRequest(req, res)) return
|
|
42
|
-
writeJson(res, 200, {
|
|
43
|
-
ok: true,
|
|
44
|
-
status: authSession?.state || 'idle',
|
|
45
|
-
authUrl: authSession?.authUrl || 'https://app.cline.bot',
|
|
46
|
-
})
|
|
47
|
-
},
|
|
48
|
-
}), 'dsh-clinebot: /auth/status')
|
|
91
|
+
}), 'dsh-clinebot: /key/verify')
|
|
49
92
|
|
|
50
93
|
// POST /dsh-clinebot/smoke — live ping test
|
|
51
94
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -73,12 +116,11 @@ export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderStat
|
|
|
73
116
|
model: modelToTest,
|
|
74
117
|
timeoutMs: pub.smokeTimeoutMs,
|
|
75
118
|
})
|
|
76
|
-
|
|
119
|
+
recordSmokeTest({
|
|
77
120
|
latencyMs: outcome.latencyMs,
|
|
78
121
|
ok: outcome.ok,
|
|
79
122
|
error: outcome.error,
|
|
80
|
-
|
|
81
|
-
completionTokens: outcome.completionTokens || 10,
|
|
123
|
+
model: modelToTest,
|
|
82
124
|
})
|
|
83
125
|
|
|
84
126
|
let failover = null
|
|
@@ -91,7 +133,7 @@ export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderStat
|
|
|
91
133
|
|
|
92
134
|
writeJson(res, outcome.ok ? 200 : 502, { ...outcome, failover })
|
|
93
135
|
} catch (err) {
|
|
94
|
-
|
|
136
|
+
recordSmokeTest({ ok: false, error: String(err?.message || err) })
|
|
95
137
|
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
96
138
|
}
|
|
97
139
|
},
|
package/lib/routes/models.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { writeJson, readBody } from '../http.js'
|
|
2
|
-
import {
|
|
3
|
-
import { publicConfig, Config } from '../config.js'
|
|
2
|
+
import { assertTrustedSettingsRequest } from '../access.js'
|
|
3
|
+
import { publicConfig, plainConfig, Config } from '../config.js'
|
|
4
4
|
import { resolveActiveAccountKey, resolvePathWithHome } from '../provider-sync.js'
|
|
5
5
|
import { getAllModels, saveModelsDiskCache } from '../models.js'
|
|
6
6
|
import { fetchUsageLimits } from '../cline-client.js'
|
|
@@ -35,18 +35,21 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
35
35
|
const dynamicModels = Array.isArray(usageData.dynamicModels) ? usageData.dynamicModels : []
|
|
36
36
|
const allModels = getAllModels(dynamicModels)
|
|
37
37
|
const settingsApi = getSettingsApi()
|
|
38
|
+
if (!settingsApi || typeof settingsApi.replace !== 'function') {
|
|
39
|
+
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
40
|
+
}
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
const now = Date.now()
|
|
43
|
+
const next = Config({
|
|
44
|
+
...plainConfig(live()),
|
|
45
|
+
dynamicModels,
|
|
46
|
+
planSyncedAt: now,
|
|
47
|
+
})
|
|
48
|
+
await settingsApi.replace(next)
|
|
49
|
+
await syncProviderState(next)
|
|
50
|
+
const cacheFile = resolvePathWithHome(pub.modelsCachePath)
|
|
51
|
+
if (cacheFile) {
|
|
52
|
+
await saveModelsDiskCache(cacheFile, dynamicModels, now)
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
return writeJson(res, 200, {
|
|
@@ -75,23 +78,23 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
75
78
|
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
|
|
76
79
|
|
|
77
80
|
const settingsApi = getSettingsApi()
|
|
78
|
-
if (settingsApi
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
await settingsApi.replace(next)
|
|
90
|
-
await syncProviderState(next)
|
|
91
|
-
writeJson(res, 200, { ok: true, disabledModels: next.disabledModels, enabledModels: publicConfig(next).enabledModels })
|
|
92
|
-
} else {
|
|
93
|
-
writeJson(res, 200, { ok: true })
|
|
81
|
+
if (!settingsApi || typeof settingsApi.replace !== 'function') {
|
|
82
|
+
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const patch = {}
|
|
86
|
+
if (Array.isArray(body.disabledModels)) {
|
|
87
|
+
patch.disabledModels = body.disabledModels
|
|
88
|
+
} else if (Array.isArray(body.enabledModels)) {
|
|
89
|
+
const allModels = getAllModels(publicConfig(live()).dynamicModels)
|
|
90
|
+
const enabledSet = new Set(body.enabledModels)
|
|
91
|
+
patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
|
|
94
92
|
}
|
|
93
|
+
if (body.defaultModel) patch.defaultModel = body.defaultModel
|
|
94
|
+
const next = Config({ ...plainConfig(live()), ...patch })
|
|
95
|
+
await settingsApi.replace(next)
|
|
96
|
+
await syncProviderState(next)
|
|
97
|
+
writeJson(res, 200, { ok: true, disabledModels: next.disabledModels, enabledModels: publicConfig(next).enabledModels })
|
|
95
98
|
} catch (err) {
|
|
96
99
|
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
97
100
|
}
|