@goodandready/dsh-clinebot 0.3.25 → 0.4.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 +17 -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 +182 -64
- package/lib/cline-client.js +25 -44
- package/lib/config.js +25 -4
- package/lib/http.js +15 -2
- package/lib/index.js +116 -47
- package/lib/models.js +47 -22
- package/lib/provider-sync.js +15 -5
- package/lib/routes/accounts.js +8 -8
- package/lib/routes/auth.js +65 -37
- package/lib/routes/models.js +31 -28
- package/lib/routes/settings.js +51 -11
- package/lib/slash-command.js +51 -9
- package/lib/telemetry.js +78 -0
- package/package.json +4 -1
package/lib/index.js
CHANGED
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
resolveKeyValue,
|
|
9
9
|
smokeChat,
|
|
10
10
|
fetchUsageLimits,
|
|
11
|
+
isQuotaExceededError,
|
|
11
12
|
} from './cline-client.js'
|
|
13
|
+
import { PROVIDER_ID } from './models.js'
|
|
12
14
|
import {
|
|
13
15
|
checkRegisteredInPiAi,
|
|
14
16
|
upsertPiAiProvider,
|
|
@@ -43,8 +45,8 @@ export function apply(ctx, config) {
|
|
|
43
45
|
}
|
|
44
46
|
return
|
|
45
47
|
}
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
+
const activeKey = await resolveActiveAccountKey(ctx, cfg)
|
|
49
|
+
if (activeKey.value) {
|
|
48
50
|
await upsertPiAiProvider(ctx, cfg, pub.enabledModels)
|
|
49
51
|
}
|
|
50
52
|
} catch {
|
|
@@ -116,20 +118,6 @@ export function apply(ctx, config) {
|
|
|
116
118
|
settingsApi = undefined
|
|
117
119
|
})
|
|
118
120
|
})
|
|
119
|
-
} else {
|
|
120
|
-
const settingsService = (ctx?.get && ctx.get('settings')) || ctx?.settings
|
|
121
|
-
if (typeof settingsService?.register === 'function') {
|
|
122
|
-
const scope = settingsService.register(NS, Config, { base: config })
|
|
123
|
-
settingsApi = scope
|
|
124
|
-
getConfig = () => (scope?.get?.() ?? config) ?? config
|
|
125
|
-
if (typeof ctx.effect === 'function') {
|
|
126
|
-
ctx.effect(() => scope.watch((next) => {
|
|
127
|
-
syncProviderState(live())
|
|
128
|
-
}), 'dsh-clinebot: settings')
|
|
129
|
-
}
|
|
130
|
-
} else {
|
|
131
|
-
settingsApi = createSettingsAdapter(settingsService)
|
|
132
|
-
}
|
|
133
121
|
}
|
|
134
122
|
|
|
135
123
|
syncProviderState(live())
|
|
@@ -138,8 +126,6 @@ export function apply(ctx, config) {
|
|
|
138
126
|
const timer = setTimeout(triggerAutoDiscover, 500)
|
|
139
127
|
return () => clearTimeout(timer)
|
|
140
128
|
}, 'dsh-clinebot: auto-discover')
|
|
141
|
-
} else {
|
|
142
|
-
setTimeout(triggerAutoDiscover, 500)
|
|
143
129
|
}
|
|
144
130
|
|
|
145
131
|
if (ctx.webServer?.register) {
|
|
@@ -171,35 +157,118 @@ export function apply(ctx, config) {
|
|
|
171
157
|
syncProviderState,
|
|
172
158
|
})
|
|
173
159
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
188
|
-
const res = await smokeChat(pub.baseUrl, activeKey.value, { model: model || pub.defaultModel })
|
|
189
|
-
recordSessionRequest({
|
|
190
|
-
latencyMs: res.latencyMs,
|
|
191
|
-
ok: res.ok,
|
|
192
|
-
error: res.error,
|
|
193
|
-
promptTokens: res.promptTokens || 5,
|
|
194
|
-
completionTokens: res.completionTokens || 10,
|
|
195
|
-
})
|
|
196
|
-
if (res.status === 429) {
|
|
197
|
-
const failover = await rotateToNextAccount(ctx, live(), 'service_429', settingsApi)
|
|
198
|
-
if (failover.rotated) {
|
|
199
|
-
await syncProviderState(live())
|
|
200
|
-
}
|
|
160
|
+
let lastFailoverAt = 0
|
|
161
|
+
|
|
162
|
+
const handleStream429 = async () => {
|
|
163
|
+
const now = Date.now()
|
|
164
|
+
if (now - lastFailoverAt < 30000) {
|
|
165
|
+
ctx?.logger?.warn?.('[dsh-clinebot] Suppressing rapid failover rotation within 30s storm window')
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
lastFailoverAt = now
|
|
169
|
+
try {
|
|
170
|
+
const failover = await rotateToNextAccount(ctx, live(), 'stream_429', settingsApi)
|
|
171
|
+
if (failover?.rotated) {
|
|
172
|
+
await syncProviderState(live())
|
|
201
173
|
}
|
|
202
|
-
|
|
203
|
-
|
|
174
|
+
} catch (err) {
|
|
175
|
+
ctx?.logger?.warn?.('[dsh-clinebot] Auto-failover rotation failed: ' + (err?.message || err))
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (typeof ctx.on === 'function') {
|
|
180
|
+
const unlisten = ctx.on('llm/stream', (options, next) => {
|
|
181
|
+
const isCline = options?.provider === PROVIDER_ID
|
|
182
|
+
const stream = next()
|
|
183
|
+
const startTime = Date.now()
|
|
184
|
+
let promptTokens = 0
|
|
185
|
+
let completionTokens = 0
|
|
186
|
+
let finished = false
|
|
187
|
+
|
|
188
|
+
return (async function* () {
|
|
189
|
+
try {
|
|
190
|
+
for await (const chunk of stream) {
|
|
191
|
+
if (isCline && chunk?.type === 'usage' && chunk?.usage) {
|
|
192
|
+
const u = chunk.usage
|
|
193
|
+
promptTokens += (Number(u.inputTokens) || 0) + (Number(u.cacheReadTokens) || 0) + (Number(u.cacheWriteTokens) || 0)
|
|
194
|
+
completionTokens += Number(u.outputTokens) || 0
|
|
195
|
+
}
|
|
196
|
+
if (isCline && chunk?.type === 'finish' && chunk?.reason) {
|
|
197
|
+
finished = true
|
|
198
|
+
const latencyMs = Date.now() - startTime
|
|
199
|
+
const reason = chunk.reason
|
|
200
|
+
const failure = reason.failure || {}
|
|
201
|
+
const isError = reason.kind === 'error'
|
|
202
|
+
const isAborted = reason.kind === 'aborted'
|
|
203
|
+
const is429 = failure.status === 429 || failure.code === 429 || failure.code === 'rate_limit_exceeded'
|
|
204
|
+
const isExceeded = isQuotaExceededError(`${failure.message || ''} ${failure.code || ''}`)
|
|
205
|
+
|
|
206
|
+
if (isError) {
|
|
207
|
+
recordSessionRequest({
|
|
208
|
+
latencyMs,
|
|
209
|
+
ok: false,
|
|
210
|
+
error: failure.message || 'Stream error',
|
|
211
|
+
promptTokens,
|
|
212
|
+
completionTokens,
|
|
213
|
+
model: options?.model,
|
|
214
|
+
})
|
|
215
|
+
if (is429 || isExceeded) {
|
|
216
|
+
handleStream429()
|
|
217
|
+
}
|
|
218
|
+
} else if (isAborted) {
|
|
219
|
+
recordSessionRequest({
|
|
220
|
+
latencyMs,
|
|
221
|
+
ok: true,
|
|
222
|
+
aborted: true,
|
|
223
|
+
promptTokens,
|
|
224
|
+
completionTokens,
|
|
225
|
+
model: options?.model,
|
|
226
|
+
})
|
|
227
|
+
} else {
|
|
228
|
+
recordSessionRequest({
|
|
229
|
+
latencyMs,
|
|
230
|
+
ok: true,
|
|
231
|
+
promptTokens,
|
|
232
|
+
completionTokens,
|
|
233
|
+
model: options?.model,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
yield chunk
|
|
238
|
+
}
|
|
239
|
+
if (isCline && !finished) {
|
|
240
|
+
recordSessionRequest({
|
|
241
|
+
latencyMs: Date.now() - startTime,
|
|
242
|
+
ok: true,
|
|
243
|
+
promptTokens,
|
|
244
|
+
completionTokens,
|
|
245
|
+
model: options?.model,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (isCline) {
|
|
250
|
+
const latencyMs = Date.now() - startTime
|
|
251
|
+
const status = err?.status || err?.statusCode
|
|
252
|
+
const msg = String(err?.message || err)
|
|
253
|
+
recordSessionRequest({
|
|
254
|
+
latencyMs,
|
|
255
|
+
ok: false,
|
|
256
|
+
error: msg,
|
|
257
|
+
promptTokens,
|
|
258
|
+
completionTokens,
|
|
259
|
+
model: options?.model,
|
|
260
|
+
})
|
|
261
|
+
if (status === 429 || isQuotaExceededError(msg)) {
|
|
262
|
+
handleStream429()
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
throw err
|
|
266
|
+
}
|
|
267
|
+
})()
|
|
268
|
+
}, { global: true, prepend: true })
|
|
269
|
+
|
|
270
|
+
if (typeof ctx.effect === 'function') {
|
|
271
|
+
ctx.effect(() => () => unlisten?.(), 'dsh-clinebot: llm/stream failover')
|
|
272
|
+
}
|
|
204
273
|
}
|
|
205
274
|
}
|
package/lib/models.js
CHANGED
|
@@ -18,7 +18,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
18
18
|
id: 'cline-pass/deepseek-v4-flash',
|
|
19
19
|
name: 'DeepSeek V4 Flash',
|
|
20
20
|
description: 'High-speed reasoning & code completion model optimized for agentic loops.',
|
|
21
|
-
contextLength:
|
|
21
|
+
contextLength: 128000,
|
|
22
22
|
maxTokens: 8192,
|
|
23
23
|
input: ['text', 'image'],
|
|
24
24
|
category: 'coding',
|
|
@@ -30,7 +30,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
30
30
|
id: 'cline-pass/deepseek-v4-pro',
|
|
31
31
|
name: 'DeepSeek V4 Pro',
|
|
32
32
|
description: 'Flagship reasoning and multi-turn architectural coding model.',
|
|
33
|
-
contextLength:
|
|
33
|
+
contextLength: 128000,
|
|
34
34
|
maxTokens: 8192,
|
|
35
35
|
input: ['text', 'image'],
|
|
36
36
|
category: 'coding',
|
|
@@ -42,7 +42,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
42
42
|
id: 'cline-pass/glm-5.2',
|
|
43
43
|
name: 'GLM 5.2',
|
|
44
44
|
description: 'Bilingual general & coding model with strong instruction following.',
|
|
45
|
-
contextLength:
|
|
45
|
+
contextLength: 128000,
|
|
46
46
|
maxTokens: 8192,
|
|
47
47
|
input: ['text'],
|
|
48
48
|
category: 'general',
|
|
@@ -88,7 +88,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
88
88
|
id: 'cline-pass/qwen3.7-max',
|
|
89
89
|
name: 'Qwen 3.7 Max',
|
|
90
90
|
description: 'Large-scale multimodal foundation model from Alibaba Cloud with reasoning support.',
|
|
91
|
-
contextLength:
|
|
91
|
+
contextLength: 128000,
|
|
92
92
|
maxTokens: 8192,
|
|
93
93
|
input: ['text', 'image'],
|
|
94
94
|
category: 'multimodal',
|
|
@@ -100,7 +100,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
100
100
|
id: 'cline-pass/qwen3.7-plus',
|
|
101
101
|
name: 'Qwen 3.7 Plus',
|
|
102
102
|
description: 'Fast, capable multimodal model with strong multilingual skills and reasoning.',
|
|
103
|
-
contextLength:
|
|
103
|
+
contextLength: 128000,
|
|
104
104
|
maxTokens: 8192,
|
|
105
105
|
input: ['text', 'image'],
|
|
106
106
|
category: 'multimodal',
|
|
@@ -124,7 +124,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
124
124
|
id: 'cline-pass/mimo-v2.5',
|
|
125
125
|
name: 'MiMo V2.5',
|
|
126
126
|
description: 'Xiaomi MiMo efficient instruction model with reasoning and multimodal support.',
|
|
127
|
-
contextLength:
|
|
127
|
+
contextLength: 128000,
|
|
128
128
|
maxTokens: 8192,
|
|
129
129
|
input: ['text', 'image'],
|
|
130
130
|
category: 'general',
|
|
@@ -136,7 +136,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
136
136
|
id: 'cline-pass/mimo-v2.5-pro',
|
|
137
137
|
name: 'MiMo V2.5 Pro',
|
|
138
138
|
description: 'Xiaomi MiMo advanced agentic reasoning model with multimodal support.',
|
|
139
|
-
contextLength:
|
|
139
|
+
contextLength: 128000,
|
|
140
140
|
maxTokens: 8192,
|
|
141
141
|
input: ['text', 'image'],
|
|
142
142
|
category: 'coding',
|
|
@@ -215,27 +215,49 @@ export function parsePlanIncludedModels(includedInput) {
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
/**
|
|
218
|
-
* Get all models
|
|
218
|
+
* Get all models:
|
|
219
|
+
* 1. When plan is synchronized (dynamicModels is a non-empty array):
|
|
220
|
+
* Registers strictly the models included in the active plan, enriching them
|
|
221
|
+
* with curated capabilities, reasoning efforts, and descriptions from CLINE_MODELS.
|
|
222
|
+
* 2. When plan is unsynced (dynamicModels is empty):
|
|
223
|
+
* Falls back to the full curated CLINE_MODELS catalog marked with unverified: true.
|
|
219
224
|
*/
|
|
220
225
|
export function getAllModels(dynamicModels = []) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
226
|
+
if (Array.isArray(dynamicModels) && dynamicModels.length > 0) {
|
|
227
|
+
const normalize = (s) => String(s || '').toLowerCase().replace(/[\s._-]+/g, '')
|
|
228
|
+
return dynamicModels.map((dm) => {
|
|
229
|
+
const targetNorm = normalize(dm.id.replace(/^cline-pass\//, ''))
|
|
230
|
+
const curated = CLINE_MODELS.find(
|
|
231
|
+
(cm) => cm.id === dm.id || normalize(cm.id.replace(/^cline-pass\//, '')) === targetNorm || normalize(cm.name) === targetNorm
|
|
232
|
+
)
|
|
233
|
+
if (curated) {
|
|
234
|
+
return {
|
|
235
|
+
...curated,
|
|
236
|
+
...dm,
|
|
237
|
+
description: dm.description || curated.description,
|
|
238
|
+
contextLength: dm.contextLength || curated.contextLength,
|
|
239
|
+
maxTokens: dm.maxTokens || curated.maxTokens,
|
|
240
|
+
input: dm.input && dm.input.length ? dm.input : curated.input,
|
|
241
|
+
category: dm.category || curated.category,
|
|
242
|
+
reasoningEfforts: dm.reasoningEfforts !== undefined ? dm.reasoningEfforts : curated.reasoningEfforts,
|
|
243
|
+
unverified: false,
|
|
244
|
+
}
|
|
229
245
|
}
|
|
230
|
-
|
|
246
|
+
return {
|
|
247
|
+
...dm,
|
|
248
|
+
unverified: false,
|
|
249
|
+
}
|
|
250
|
+
})
|
|
231
251
|
}
|
|
232
252
|
|
|
233
|
-
return
|
|
253
|
+
return CLINE_MODELS.map((m) => ({ ...m, unverified: true }))
|
|
234
254
|
}
|
|
235
255
|
|
|
236
256
|
export function findModel(id, dynamicModels = []) {
|
|
237
257
|
const all = getAllModels(dynamicModels)
|
|
238
|
-
|
|
258
|
+
const found = all.find((m) => m.id === id)
|
|
259
|
+
if (found) return found
|
|
260
|
+
return CLINE_MODELS.find((m) => m.id === id) || null
|
|
239
261
|
}
|
|
240
262
|
|
|
241
263
|
export function isSupportedModel(id, dynamicModels = []) {
|
|
@@ -298,14 +320,14 @@ export function isVisionModel(id, dynamicModels = []) {
|
|
|
298
320
|
/**
|
|
299
321
|
* Disk caching for discovered models.
|
|
300
322
|
*/
|
|
301
|
-
export async function saveModelsDiskCache(cachePath, models = []) {
|
|
323
|
+
export async function saveModelsDiskCache(cachePath, models = [], planSyncedAt = Date.now()) {
|
|
302
324
|
if (!cachePath || !Array.isArray(models) || !models.length) return false
|
|
303
325
|
try {
|
|
304
326
|
const fs = await import('node:fs/promises')
|
|
305
327
|
const path = await import('node:path')
|
|
306
328
|
const dir = path.dirname(cachePath)
|
|
307
329
|
await fs.mkdir(dir, { recursive: true })
|
|
308
|
-
const payload = JSON.stringify({ savedAt: Date.now(), models }, null, 2)
|
|
330
|
+
const payload = JSON.stringify({ savedAt: Date.now(), planSyncedAt, models }, null, 2)
|
|
309
331
|
await fs.writeFile(cachePath, payload, 'utf8')
|
|
310
332
|
return true
|
|
311
333
|
} catch {
|
|
@@ -319,7 +341,10 @@ export async function loadModelsDiskCache(cachePath) {
|
|
|
319
341
|
const fs = await import('node:fs/promises')
|
|
320
342
|
const raw = await fs.readFile(cachePath, 'utf8')
|
|
321
343
|
const parsed = JSON.parse(raw)
|
|
322
|
-
|
|
344
|
+
const list = Array.isArray(parsed?.models) ? parsed.models : (Array.isArray(parsed) ? parsed : null)
|
|
345
|
+
if (!list) return null
|
|
346
|
+
list.planSyncedAt = typeof parsed?.planSyncedAt === 'number' ? parsed.planSyncedAt : (typeof parsed?.savedAt === 'number' ? parsed.savedAt : 0)
|
|
347
|
+
return list
|
|
323
348
|
} catch {
|
|
324
349
|
return null
|
|
325
350
|
}
|
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,7 @@ import {
|
|
|
16
16
|
fetchUsageLimits,
|
|
17
17
|
buildPiAiProvider,
|
|
18
18
|
sessionStats,
|
|
19
|
+
getLastRotation,
|
|
19
20
|
} from './cline-client.js'
|
|
20
21
|
|
|
21
22
|
export function resolvePathWithHome(p) {
|
|
@@ -113,6 +114,7 @@ export async function buildStatus(ctx, cfg) {
|
|
|
113
114
|
usage: publicUsage(usage),
|
|
114
115
|
quotaWarning,
|
|
115
116
|
sessionStats: { ...sessionStats },
|
|
117
|
+
lastRotation: getLastRotation(),
|
|
116
118
|
isRegistered,
|
|
117
119
|
availableModels: allModels,
|
|
118
120
|
}
|
|
@@ -129,9 +131,11 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
|
|
|
129
131
|
const allowedSet = new Set(activeModelIds || pub.enabledModels)
|
|
130
132
|
const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
|
|
131
133
|
|
|
134
|
+
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
135
|
+
|
|
132
136
|
const providerObj = buildPiAiProvider({
|
|
133
137
|
baseUrl: pub.baseUrl,
|
|
134
|
-
apiKeyEnv: pub.apiKeyEnv,
|
|
138
|
+
apiKeyEnv: activeAcc?.apiKeyEnv || pub.apiKeyEnv,
|
|
135
139
|
models: modelsToRegister.length ? modelsToRegister : allModels,
|
|
136
140
|
customModels: pub.dynamicModels,
|
|
137
141
|
displayName: PROVIDER_DISPLAY_NAME,
|
|
@@ -180,7 +184,11 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
|
|
|
180
184
|
if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
|
|
181
185
|
const fromDisk = await loadModelsDiskCache(cacheFile)
|
|
182
186
|
if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
|
|
183
|
-
const next = Config({
|
|
187
|
+
const next = Config({
|
|
188
|
+
...plainConfig(live()),
|
|
189
|
+
dynamicModels: fromDisk,
|
|
190
|
+
planSyncedAt: fromDisk.planSyncedAt || Date.now(),
|
|
191
|
+
})
|
|
184
192
|
await settingsApi.replace(next)
|
|
185
193
|
await syncProviderState(next)
|
|
186
194
|
}
|
|
@@ -200,14 +208,16 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
|
|
|
200
208
|
const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
|
|
201
209
|
|
|
202
210
|
if (hasNew && settingsApi?.replace) {
|
|
211
|
+
const now = Date.now()
|
|
203
212
|
const next = Config({
|
|
204
|
-
...live(),
|
|
213
|
+
...plainConfig(live()),
|
|
205
214
|
dynamicModels: usageData.dynamicModels,
|
|
215
|
+
planSyncedAt: now,
|
|
206
216
|
})
|
|
207
217
|
await settingsApi.replace(next)
|
|
208
218
|
await syncProviderState(next)
|
|
209
219
|
if (cacheFile) {
|
|
210
|
-
await saveModelsDiskCache(cacheFile, usageData.dynamicModels)
|
|
220
|
+
await saveModelsDiskCache(cacheFile, usageData.dynamicModels, now)
|
|
211
221
|
}
|
|
212
222
|
}
|
|
213
223
|
}
|
package/lib/routes/accounts.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { writeJson, readBody } from '../http.js'
|
|
2
2
|
import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
|
|
3
|
-
import { publicConfig, Config } from '../config.js'
|
|
3
|
+
import { publicConfig, plainConfig, Config } from '../config.js'
|
|
4
4
|
import { upsertPiAiProvider, removePiAiProvider } from '../provider-sync.js'
|
|
5
5
|
import { clearUsageCache, clearProbeCache } from '../cline-client.js'
|
|
6
6
|
|
|
@@ -21,14 +21,14 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
21
21
|
clearUsageCache()
|
|
22
22
|
clearProbeCache()
|
|
23
23
|
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 })
|
|
24
|
+
if (!settingsApi || typeof settingsApi.replace !== 'function') {
|
|
25
|
+
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
31
26
|
}
|
|
27
|
+
|
|
28
|
+
const next = Config({ ...plainConfig(live()), activeAccount: account })
|
|
29
|
+
await settingsApi.replace(next)
|
|
30
|
+
await syncProviderState(next)
|
|
31
|
+
writeJson(res, 200, { ok: true, activeAccount: account })
|
|
32
32
|
} catch (err) {
|
|
33
33
|
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
34
34
|
}
|
package/lib/routes/auth.js
CHANGED
|
@@ -2,50 +2,79 @@ import { writeJson, readBody } from '../http.js'
|
|
|
2
2
|
import { isTrustedSettingsRequest, 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
|
+
const ac = new AbortController()
|
|
33
|
+
const timer = setTimeout(() => ac.abort(), 6000)
|
|
34
|
+
try {
|
|
35
|
+
const [meRes, planRes] = await Promise.all([
|
|
36
|
+
fetch(`${base}/users/me`, {
|
|
37
|
+
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
|
|
38
|
+
signal: ac.signal,
|
|
39
|
+
}).catch((err) => ({ ok: false, status: 500, error: err })),
|
|
40
|
+
fetch(`${base}/users/me/plan`, {
|
|
41
|
+
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
|
|
42
|
+
signal: ac.signal,
|
|
43
|
+
}).catch(() => null),
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
if (!meRes.ok) {
|
|
47
|
+
return writeJson(res, 200, {
|
|
48
|
+
ok: false,
|
|
49
|
+
valid: false,
|
|
50
|
+
error: meRes.status === 401 ? 'Invalid API key (HTTP 401 Unauthorized)' : `Upstream returned HTTP ${meRes.status}`,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const meData = await meRes.json().catch(() => ({}))
|
|
55
|
+
const email = meData?.data?.email || meData?.email || 'authenticated user'
|
|
56
|
+
|
|
57
|
+
let planName = 'ClinePass'
|
|
58
|
+
if (planRes && planRes.ok) {
|
|
59
|
+
const planData = await planRes.json().catch(() => ({}))
|
|
60
|
+
const plan = planData?.data?.plan || planData?.data || planData?.plan || planData
|
|
61
|
+
planName = plan?.displayName || plan?.title || plan?.name || 'ClinePass'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return writeJson(res, 200, {
|
|
65
|
+
ok: true,
|
|
66
|
+
valid: true,
|
|
67
|
+
email,
|
|
68
|
+
plan: planName,
|
|
69
|
+
})
|
|
70
|
+
} finally {
|
|
71
|
+
clearTimeout(timer)
|
|
23
72
|
}
|
|
24
|
-
writeJson(res, 200, {
|
|
25
|
-
ok: true,
|
|
26
|
-
status: authSession.state,
|
|
27
|
-
authUrl,
|
|
28
|
-
})
|
|
29
73
|
} catch (err) {
|
|
30
|
-
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
74
|
+
return writeJson(res, 500, { ok: false, valid: false, error: String(err?.message || err) })
|
|
31
75
|
}
|
|
32
76
|
},
|
|
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')
|
|
77
|
+
}), 'dsh-clinebot: /key/verify')
|
|
49
78
|
|
|
50
79
|
// POST /dsh-clinebot/smoke — live ping test
|
|
51
80
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -73,12 +102,11 @@ export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderStat
|
|
|
73
102
|
model: modelToTest,
|
|
74
103
|
timeoutMs: pub.smokeTimeoutMs,
|
|
75
104
|
})
|
|
76
|
-
|
|
105
|
+
recordSmokeTest({
|
|
77
106
|
latencyMs: outcome.latencyMs,
|
|
78
107
|
ok: outcome.ok,
|
|
79
108
|
error: outcome.error,
|
|
80
|
-
|
|
81
|
-
completionTokens: outcome.completionTokens || 10,
|
|
109
|
+
model: modelToTest,
|
|
82
110
|
})
|
|
83
111
|
|
|
84
112
|
let failover = null
|
|
@@ -91,7 +119,7 @@ export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderStat
|
|
|
91
119
|
|
|
92
120
|
writeJson(res, outcome.ok ? 200 : 502, { ...outcome, failover })
|
|
93
121
|
} catch (err) {
|
|
94
|
-
|
|
122
|
+
recordSmokeTest({ ok: false, error: String(err?.message || err) })
|
|
95
123
|
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
96
124
|
}
|
|
97
125
|
},
|
package/lib/routes/models.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { writeJson, readBody } from '../http.js'
|
|
2
2
|
import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
|
|
3
|
-
import { publicConfig, Config } from '../config.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
|
}
|