@goodandready/dsh-clinebot 0.3.1 → 0.3.3
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 +28 -0
- package/docs/design/DESIGN.md +10 -2
- package/lib/client.js +362 -94
- package/lib/cline-client.js +42 -1
- package/lib/index.js +358 -48
- package/lib/models.js +83 -0
- package/package.json +1 -1
package/lib/cline-client.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
PROVIDER_DISPLAY_NAME,
|
|
13
13
|
getAllModels,
|
|
14
14
|
parsePlanIncludedModels,
|
|
15
|
+
formatModelDescription,
|
|
15
16
|
} from './models.js'
|
|
16
17
|
|
|
17
18
|
export const DEFAULT_BASE_URL = 'https://api.cline.bot/api/v1'
|
|
@@ -135,6 +136,41 @@ export function clearUsageCache() {
|
|
|
135
136
|
usageCache.clear()
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Resilient retry runner for transient network errors (ECONNRESET, ETIMEDOUT, 502, 503, 504, 429).
|
|
141
|
+
*/
|
|
142
|
+
export async function retryWithBackoff(fn, {
|
|
143
|
+
maxRetries = 3,
|
|
144
|
+
initialDelayMs = 500,
|
|
145
|
+
maxDelayMs = 5000,
|
|
146
|
+
} = {}) {
|
|
147
|
+
let attempt = 0
|
|
148
|
+
let delay = initialDelayMs
|
|
149
|
+
while (true) {
|
|
150
|
+
try {
|
|
151
|
+
const res = await fn()
|
|
152
|
+
// If HTTP response-like object with 5xx or 429 status and can retry
|
|
153
|
+
if (res && typeof res.status === 'number' && [429, 502, 503, 504].includes(res.status) && attempt < maxRetries) {
|
|
154
|
+
attempt++
|
|
155
|
+
const retryAfter = res.headers?.get ? Number(res.headers.get('retry-after')) * 1000 : 0
|
|
156
|
+
const waitTime = retryAfter > 0 ? Math.min(retryAfter, maxDelayMs) : delay
|
|
157
|
+
await new Promise((r) => setTimeout(r, waitTime))
|
|
158
|
+
delay = Math.min(delay * 2, maxDelayMs)
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
return res
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (attempt < maxRetries) {
|
|
164
|
+
attempt++
|
|
165
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
166
|
+
delay = Math.min(delay * 2, maxDelayMs)
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
throw err
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
138
174
|
/**
|
|
139
175
|
* Fetch official ClinePass rate limits and account quota.
|
|
140
176
|
* Endpoints:
|
|
@@ -387,14 +423,19 @@ export function buildPiAiProvider({
|
|
|
387
423
|
item = m
|
|
388
424
|
}
|
|
389
425
|
const hasImage = item.input?.includes('image') || item.input?.includes('vision')
|
|
390
|
-
|
|
426
|
+
const res = {
|
|
391
427
|
id: item.id,
|
|
392
428
|
name: item.name || item.id,
|
|
429
|
+
description: formatModelDescription(item),
|
|
393
430
|
contextWindow: Number(item.contextLength || item.contextWindow) || 200000,
|
|
394
431
|
maxTokens: Number(item.maxTokens) || 8192,
|
|
395
432
|
input: hasImage ? ['text', 'image'] : ['text'],
|
|
396
433
|
provider: PROVIDER_ID,
|
|
397
434
|
}
|
|
435
|
+
if (Array.isArray(item.reasoningEfforts) && item.reasoningEfforts.length) {
|
|
436
|
+
res.reasoningEfforts = [...item.reasoningEfforts]
|
|
437
|
+
}
|
|
438
|
+
return res
|
|
398
439
|
})
|
|
399
440
|
|
|
400
441
|
return {
|
package/lib/index.js
CHANGED
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
PROVIDER_DISPLAY_NAME,
|
|
9
9
|
getAllModels,
|
|
10
10
|
getDefaultModelIds,
|
|
11
|
+
getActiveModelIds,
|
|
11
12
|
parsePlanIncludedModels,
|
|
13
|
+
saveModelsDiskCache,
|
|
14
|
+
loadModelsDiskCache,
|
|
15
|
+
isVisionModel,
|
|
12
16
|
} from './models.js'
|
|
13
17
|
import {
|
|
14
18
|
DEFAULT_BASE_URL,
|
|
@@ -44,8 +48,10 @@ export const Config = z.object({
|
|
|
44
48
|
.description('Credential / env name containing the ClinePass API key (never store key directly here).'),
|
|
45
49
|
defaultModel: z.string().default(DEFAULT_MODEL_ID)
|
|
46
50
|
.description('Default model ID for chat and smoke tests.'),
|
|
47
|
-
|
|
48
|
-
.description('List of model IDs
|
|
51
|
+
disabledModels: z.array(z.string()).default([])
|
|
52
|
+
.description('List of model IDs explicitly disabled by the user (new models are enabled automatically).'),
|
|
53
|
+
enabledModels: z.array(z.string()).default([])
|
|
54
|
+
.description('Deprecated: preserved for backwards compatibility with earlier versions.'),
|
|
49
55
|
dynamicModels: z.array(z.object({
|
|
50
56
|
id: z.string(),
|
|
51
57
|
name: z.string(),
|
|
@@ -61,23 +67,56 @@ export const Config = z.object({
|
|
|
61
67
|
.description('HTTP probe timeout in milliseconds.'),
|
|
62
68
|
smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
|
|
63
69
|
.description('Timeout for smoke chat completions in milliseconds.'),
|
|
70
|
+
modelsCachePath: z.string().default('~/.dsh/clinebot-models-cache.json')
|
|
71
|
+
.description('Local on-disk cache path for models snapshot.'),
|
|
72
|
+
accounts: z.array(z.object({
|
|
73
|
+
label: z.string().default(''),
|
|
74
|
+
apiKeyEnv: z.string(),
|
|
75
|
+
})).default([])
|
|
76
|
+
.description('Additional accounts for multi-account failover and rate limit rotation.'),
|
|
77
|
+
activeAccount: z.string().default('')
|
|
78
|
+
.description('Manually pinned active account envName or empty for auto/default.'),
|
|
64
79
|
})
|
|
65
80
|
|
|
66
81
|
function publicConfig(cfg) {
|
|
67
82
|
const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
|
|
68
|
-
const
|
|
83
|
+
const allModels = getAllModels(dynamic)
|
|
84
|
+
const allDefaultIds = allModels.map((m) => m.id)
|
|
85
|
+
|
|
86
|
+
// Migration / compatibility: if disabledModels was not yet set, but enabledModels was provided
|
|
87
|
+
let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels : []
|
|
88
|
+
if (!Array.isArray(cfg?.disabledModels) || (cfg.disabledModels.length === 0 && Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length > 0)) {
|
|
89
|
+
const enabledSet = new Set(cfg.enabledModels)
|
|
90
|
+
disabledList = allDefaultIds.filter((id) => !enabledSet.has(id))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const activeIds = getActiveModelIds(allDefaultIds, disabledList)
|
|
94
|
+
|
|
69
95
|
return {
|
|
70
96
|
enabled: !!cfg?.enabled,
|
|
71
97
|
baseUrl: normalizeBaseUrl(cfg?.baseUrl),
|
|
72
98
|
apiKeyEnv: cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV,
|
|
73
99
|
defaultModel: cfg?.defaultModel || DEFAULT_MODEL_ID,
|
|
74
100
|
dynamicModels: dynamic,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
: allDefaultIds,
|
|
101
|
+
disabledModels: disabledList,
|
|
102
|
+
enabledModels: activeIds,
|
|
78
103
|
timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
|
|
79
104
|
smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
|
|
105
|
+
modelsCachePath: String(cfg?.modelsCachePath || '~/.dsh/clinebot-models-cache.json'),
|
|
106
|
+
accounts: Array.isArray(cfg?.accounts) ? cfg.accounts : [],
|
|
107
|
+
activeAccount: String(cfg?.activeAccount || ''),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
import os from 'node:os'
|
|
112
|
+
import path from 'node:path'
|
|
113
|
+
|
|
114
|
+
function resolvePathWithHome(p) {
|
|
115
|
+
if (!p || typeof p !== 'string') return ''
|
|
116
|
+
if (p.startsWith('~/') || p === '~') {
|
|
117
|
+
return path.join(os.homedir(), p.slice(1))
|
|
80
118
|
}
|
|
119
|
+
return p
|
|
81
120
|
}
|
|
82
121
|
|
|
83
122
|
async function resolveKeyValue(ctx, apiKeyEnv) {
|
|
@@ -101,6 +140,58 @@ async function resolveKeyValue(ctx, apiKeyEnv) {
|
|
|
101
140
|
return { envName: refName, value: '', source: 'none' }
|
|
102
141
|
}
|
|
103
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Resolve all accounts in pool with their status and quota.
|
|
145
|
+
*/
|
|
146
|
+
async function resolveAccountPool(ctx, cfg) {
|
|
147
|
+
const pub = publicConfig(cfg)
|
|
148
|
+
const defaultSlot = {
|
|
149
|
+
id: 'default',
|
|
150
|
+
label: 'Default',
|
|
151
|
+
apiKeyEnv: pub.apiKeyEnv,
|
|
152
|
+
}
|
|
153
|
+
const allSlots = [defaultSlot, ...(Array.isArray(pub.accounts) ? pub.accounts : [])]
|
|
154
|
+
const resolved = []
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < allSlots.length; i++) {
|
|
157
|
+
const slot = allSlots[i]
|
|
158
|
+
const envName = slot.apiKeyEnv || (i === 0 ? pub.apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
|
|
159
|
+
const keyInfo = await resolveKeyValue(ctx, envName)
|
|
160
|
+
resolved.push({
|
|
161
|
+
id: slot.id || (i === 0 ? 'default' : `account-${i + 1}`),
|
|
162
|
+
label: slot.label || (i === 0 ? 'Default' : `Account ${i + 1}`),
|
|
163
|
+
apiKeyEnv: envName,
|
|
164
|
+
present: Boolean(keyInfo.value),
|
|
165
|
+
source: keyInfo.source,
|
|
166
|
+
value: keyInfo.value,
|
|
167
|
+
isPinned: pub.activeAccount ? pub.activeAccount === envName : i === 0,
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return resolved
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve active key with failover support.
|
|
176
|
+
*/
|
|
177
|
+
async function resolveActiveAccountKey(ctx, cfg) {
|
|
178
|
+
const pool = await resolveAccountPool(ctx, cfg)
|
|
179
|
+
const configured = pool.filter((acc) => acc.present && acc.value)
|
|
180
|
+
if (!configured.length) {
|
|
181
|
+
return { envName: publicConfig(cfg).apiKeyEnv, value: '', source: 'none', id: 'default' }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// If user pinned a specific account and it has a key, prefer it
|
|
185
|
+
const pub = publicConfig(cfg)
|
|
186
|
+
if (pub.activeAccount) {
|
|
187
|
+
const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
|
|
188
|
+
if (pinned) return pinned
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Default: first available configured account
|
|
192
|
+
return configured[0]
|
|
193
|
+
}
|
|
194
|
+
|
|
104
195
|
async function checkRegisteredInPiAi(ctx) {
|
|
105
196
|
const settings = ctx?.get?.('settings')
|
|
106
197
|
if (!settings?.get) return false
|
|
@@ -115,13 +206,16 @@ async function checkRegisteredInPiAi(ctx) {
|
|
|
115
206
|
async function buildStatus(ctx, cfg) {
|
|
116
207
|
const pub = publicConfig(cfg)
|
|
117
208
|
const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
|
|
118
|
-
|
|
209
|
+
// Non-blocking quick health probe with low timeout so settings page loads instantly
|
|
210
|
+
const probeTimeout = Math.min(2500, pub.timeoutMs || 2500)
|
|
211
|
+
const health = await probeHealth(pub.baseUrl, { timeoutMs: probeTimeout })
|
|
119
212
|
const isRegistered = await checkRegisteredInPiAi(ctx)
|
|
120
213
|
const allModels = getAllModels(pub.dynamicModels)
|
|
121
214
|
|
|
122
215
|
let usage = null
|
|
123
216
|
if (key.value) {
|
|
124
|
-
|
|
217
|
+
// Uses 60s cache; if cache miss, times out quickly
|
|
218
|
+
usage = await fetchUsageLimits(pub.baseUrl, key.value, { timeoutMs: probeTimeout }).catch(() => null)
|
|
125
219
|
}
|
|
126
220
|
|
|
127
221
|
// Evaluate warning state
|
|
@@ -143,16 +237,29 @@ async function buildStatus(ctx, cfg) {
|
|
|
143
237
|
}
|
|
144
238
|
}
|
|
145
239
|
|
|
240
|
+
// Resolve all accounts in pool
|
|
241
|
+
const pool = await resolveAccountPool(ctx, cfg)
|
|
242
|
+
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
243
|
+
|
|
146
244
|
return {
|
|
147
245
|
ok: true,
|
|
148
246
|
providerId: PROVIDER_ID,
|
|
149
247
|
displayName: PROVIDER_DISPLAY_NAME,
|
|
150
248
|
config: pub,
|
|
151
249
|
key: {
|
|
152
|
-
envName: key.envName,
|
|
153
|
-
present: !!key.value,
|
|
154
|
-
source: key.source,
|
|
250
|
+
envName: activeAcc.apiKeyEnv || key.envName,
|
|
251
|
+
present: !!(activeAcc.value || key.value),
|
|
252
|
+
source: activeAcc.source || key.source,
|
|
155
253
|
},
|
|
254
|
+
accounts: pool.map((acc) => ({
|
|
255
|
+
id: acc.id,
|
|
256
|
+
label: acc.label,
|
|
257
|
+
apiKeyEnv: acc.apiKeyEnv,
|
|
258
|
+
present: acc.present,
|
|
259
|
+
source: acc.source,
|
|
260
|
+
isPinned: acc.isPinned,
|
|
261
|
+
})),
|
|
262
|
+
activeAccount: activeAcc.apiKeyEnv,
|
|
156
263
|
health,
|
|
157
264
|
usage,
|
|
158
265
|
quotaWarning,
|
|
@@ -162,7 +269,7 @@ async function buildStatus(ctx, cfg) {
|
|
|
162
269
|
}
|
|
163
270
|
}
|
|
164
271
|
|
|
165
|
-
async function upsertPiAiProvider(ctx, cfg,
|
|
272
|
+
async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
|
|
166
273
|
const settings = ctx?.get?.('settings')
|
|
167
274
|
if (!settings?.mutate) {
|
|
168
275
|
throw new Error('DSH settings service unavailable')
|
|
@@ -170,8 +277,8 @@ async function upsertPiAiProvider(ctx, cfg, dynamicModelIds) {
|
|
|
170
277
|
|
|
171
278
|
const pub = publicConfig(cfg)
|
|
172
279
|
const allModels = getAllModels(pub.dynamicModels)
|
|
173
|
-
const
|
|
174
|
-
const modelsToRegister = allModels.filter((m) =>
|
|
280
|
+
const allowedSet = new Set(activeModelIds || pub.enabledModels)
|
|
281
|
+
const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
|
|
175
282
|
|
|
176
283
|
const providerObj = buildPiAiProvider({
|
|
177
284
|
baseUrl: pub.baseUrl,
|
|
@@ -218,6 +325,71 @@ export function apply(ctx, config) {
|
|
|
218
325
|
let liveCfg = Config(structuredClone(config || {}))
|
|
219
326
|
let settingsApi
|
|
220
327
|
|
|
328
|
+
// Declarative sync helper: auto-registers or unregisters provider based on config & key availability
|
|
329
|
+
const syncProviderState = async (cfg) => {
|
|
330
|
+
try {
|
|
331
|
+
const pub = publicConfig(cfg)
|
|
332
|
+
if (!pub.enabled) {
|
|
333
|
+
if (await checkRegisteredInPiAi(ctx)) {
|
|
334
|
+
await removePiAiProvider(ctx)
|
|
335
|
+
}
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
|
|
339
|
+
if (key.value) {
|
|
340
|
+
await upsertPiAiProvider(ctx, cfg, pub.enabledModels)
|
|
341
|
+
}
|
|
342
|
+
} catch {
|
|
343
|
+
/* ignore transient settings unavailable */
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Background check for subscription plan models (runs once on startup or key save)
|
|
348
|
+
const autoDiscoverPlanModels = async (cfg) => {
|
|
349
|
+
try {
|
|
350
|
+
const pub = publicConfig(cfg)
|
|
351
|
+
const cacheFile = resolvePathWithHome(pub.modelsCachePath)
|
|
352
|
+
|
|
353
|
+
// 1. If dynamicModels is empty, attempt to load from disk cache first
|
|
354
|
+
if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
|
|
355
|
+
const fromDisk = await loadModelsDiskCache(cacheFile)
|
|
356
|
+
if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
|
|
357
|
+
const next = Config({ ...liveCfg, dynamicModels: fromDisk })
|
|
358
|
+
await settingsApi.replace(next)
|
|
359
|
+
await syncProviderState(next)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
364
|
+
if (!activeAcc.value) return
|
|
365
|
+
|
|
366
|
+
const usageData = await fetchUsageLimits(pub.baseUrl, activeAcc.value, {
|
|
367
|
+
timeoutMs: Math.min(pub.timeoutMs, 5000),
|
|
368
|
+
bypassCache: true,
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
if (usageData?.ok && Array.isArray(usageData.dynamicModels) && usageData.dynamicModels.length > 0) {
|
|
372
|
+
const existingDynamic = pub.dynamicModels || []
|
|
373
|
+
const existingIds = new Set(existingDynamic.map((m) => m.id))
|
|
374
|
+
const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
|
|
375
|
+
|
|
376
|
+
if (hasNew && settingsApi?.replace) {
|
|
377
|
+
const next = Config({
|
|
378
|
+
...liveCfg,
|
|
379
|
+
dynamicModels: usageData.dynamicModels,
|
|
380
|
+
})
|
|
381
|
+
await settingsApi.replace(next)
|
|
382
|
+
await syncProviderState(next)
|
|
383
|
+
if (cacheFile) {
|
|
384
|
+
await saveModelsDiskCache(cacheFile, usageData.dynamicModels)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
} catch {
|
|
389
|
+
/* best-effort discovery */
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
221
393
|
const settingsService = ctx.get('settings')
|
|
222
394
|
if (typeof settingsService?.register === 'function') {
|
|
223
395
|
const scope = settingsService.register(NS, Config, { base: config })
|
|
@@ -225,11 +397,17 @@ export function apply(ctx, config) {
|
|
|
225
397
|
liveCfg = Config(scope.get() ?? config)
|
|
226
398
|
ctx.effect(() => scope.watch((next) => {
|
|
227
399
|
liveCfg = Config(next ?? config)
|
|
400
|
+
syncProviderState(liveCfg)
|
|
228
401
|
}), 'dsh-clinebot: settings')
|
|
229
402
|
}
|
|
230
403
|
|
|
231
404
|
const live = () => liveCfg
|
|
232
405
|
|
|
406
|
+
// On startup: ensure provider is synced to llm-pi-ai if key is present
|
|
407
|
+
syncProviderState(liveCfg)
|
|
408
|
+
// Background discover plan models on startup
|
|
409
|
+
setTimeout(() => autoDiscoverPlanModels(liveCfg), 500)
|
|
410
|
+
|
|
233
411
|
// Web server HTTP route handlers
|
|
234
412
|
if (ctx.webServer?.register) {
|
|
235
413
|
// 1. GET /dsh-clinebot/status
|
|
@@ -274,6 +452,7 @@ export function apply(ctx, config) {
|
|
|
274
452
|
try {
|
|
275
453
|
const parsed = Config({ ...publicConfig(live()), ...payload })
|
|
276
454
|
await settingsApi.replace(parsed)
|
|
455
|
+
await syncProviderState(parsed)
|
|
277
456
|
writeJson(res, 200, { ok: true, config: publicConfig(live()) })
|
|
278
457
|
} catch (e) {
|
|
279
458
|
writeJson(res, 400, { ok: false, error: String(e?.message || e) })
|
|
@@ -304,6 +483,10 @@ export function apply(ctx, config) {
|
|
|
304
483
|
const targetEnvName = String(body.apiKeyEnv || pub.apiKeyEnv || DEFAULT_API_KEY_ENV).trim()
|
|
305
484
|
await saveCredentialKey(ctx, targetEnvName, apiKey)
|
|
306
485
|
|
|
486
|
+
// Auto-sync provider to DSH Models and discover plan models
|
|
487
|
+
await syncProviderState(live())
|
|
488
|
+
autoDiscoverPlanModels(live())
|
|
489
|
+
|
|
307
490
|
// Run validation probe with the newly saved key
|
|
308
491
|
const validation = await smokeChat(pub.baseUrl, apiKey, {
|
|
309
492
|
model: pub.defaultModel,
|
|
@@ -331,11 +514,11 @@ export function apply(ctx, config) {
|
|
|
331
514
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
332
515
|
try {
|
|
333
516
|
const pub = publicConfig(live())
|
|
334
|
-
const
|
|
335
|
-
if (!
|
|
517
|
+
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
518
|
+
if (!activeKey.value) {
|
|
336
519
|
return writeJson(res, 400, { ok: false, error: 'API key not found' })
|
|
337
520
|
}
|
|
338
|
-
const usageData = await fetchUsageLimits(pub.baseUrl,
|
|
521
|
+
const usageData = await fetchUsageLimits(pub.baseUrl, activeKey.value, {
|
|
339
522
|
timeoutMs: pub.timeoutMs,
|
|
340
523
|
bypassCache: true,
|
|
341
524
|
})
|
|
@@ -359,7 +542,8 @@ export function apply(ctx, config) {
|
|
|
359
542
|
const bodyBuf = await readBody(req)
|
|
360
543
|
let body = {}
|
|
361
544
|
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
362
|
-
const
|
|
545
|
+
const activeModels = body.models || publicConfig(live()).enabledModels
|
|
546
|
+
const result = await upsertPiAiProvider(ctx, live(), activeModels)
|
|
363
547
|
writeJson(res, 200, { ok: true, provider: result })
|
|
364
548
|
} catch (err) {
|
|
365
549
|
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
@@ -400,16 +584,16 @@ export function apply(ctx, config) {
|
|
|
400
584
|
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
401
585
|
|
|
402
586
|
const pub = publicConfig(live())
|
|
403
|
-
const
|
|
404
|
-
if (!
|
|
587
|
+
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
588
|
+
if (!activeKey.value) {
|
|
405
589
|
return writeJson(res, 400, {
|
|
406
590
|
ok: false,
|
|
407
|
-
error: `API key not found. Ensure ${
|
|
591
|
+
error: `API key not found. Ensure ${activeKey.envName} is added to DSH credentials or environment.`,
|
|
408
592
|
})
|
|
409
593
|
}
|
|
410
594
|
|
|
411
595
|
const modelToTest = body.model || pub.defaultModel || DEFAULT_MODEL_ID
|
|
412
|
-
const outcome = await smokeChat(pub.baseUrl,
|
|
596
|
+
const outcome = await smokeChat(pub.baseUrl, activeKey.value, {
|
|
413
597
|
model: modelToTest,
|
|
414
598
|
timeoutMs: pub.smokeTimeoutMs,
|
|
415
599
|
})
|
|
@@ -441,12 +625,12 @@ export function apply(ctx, config) {
|
|
|
441
625
|
}
|
|
442
626
|
try {
|
|
443
627
|
const pub = publicConfig(live())
|
|
444
|
-
const
|
|
445
|
-
if (!
|
|
628
|
+
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
629
|
+
if (!activeKey.value) {
|
|
446
630
|
return writeJson(res, 400, { ok: false, error: 'API key not configured' })
|
|
447
631
|
}
|
|
448
632
|
|
|
449
|
-
const usageData = await fetchUsageLimits(pub.baseUrl,
|
|
633
|
+
const usageData = await fetchUsageLimits(pub.baseUrl, activeKey.value, {
|
|
450
634
|
timeoutMs: pub.timeoutMs,
|
|
451
635
|
bypassCache: true,
|
|
452
636
|
})
|
|
@@ -457,23 +641,17 @@ export function apply(ctx, config) {
|
|
|
457
641
|
|
|
458
642
|
const dynamicModels = Array.isArray(usageData.dynamicModels) ? usageData.dynamicModels : []
|
|
459
643
|
const allModels = getAllModels(dynamicModels)
|
|
460
|
-
const allIds = allModels.map((m) => m.id)
|
|
461
|
-
|
|
462
|
-
// Preserve currently enabled models, plus add any newly discovered ones
|
|
463
|
-
const existingEnabled = new Set(live().enabledModels || getDefaultModelIds())
|
|
464
|
-
for (const m of allModels) {
|
|
465
|
-
existingEnabled.add(m.id)
|
|
466
|
-
}
|
|
467
644
|
|
|
468
645
|
if (settingsApi?.replace) {
|
|
469
646
|
const next = Config({
|
|
470
647
|
...live(),
|
|
471
648
|
dynamicModels,
|
|
472
|
-
enabledModels: Array.from(existingEnabled),
|
|
473
649
|
})
|
|
474
650
|
await settingsApi.replace(next)
|
|
475
|
-
|
|
476
|
-
|
|
651
|
+
await syncProviderState(next)
|
|
652
|
+
const cacheFile = resolvePathWithHome(pub.modelsCachePath)
|
|
653
|
+
if (cacheFile) {
|
|
654
|
+
await saveModelsDiskCache(cacheFile, dynamicModels)
|
|
477
655
|
}
|
|
478
656
|
}
|
|
479
657
|
|
|
@@ -490,7 +668,7 @@ export function apply(ctx, config) {
|
|
|
490
668
|
},
|
|
491
669
|
}), 'dsh-clinebot: /models/sync')
|
|
492
670
|
|
|
493
|
-
// 9. POST /dsh-clinebot/models/toggle — toggle enabled status in picker
|
|
671
|
+
// 9. POST /dsh-clinebot/models/toggle — toggle disabled/enabled status in picker
|
|
494
672
|
ctx.effect(() => ctx.webServer.register({
|
|
495
673
|
kind: 'exact',
|
|
496
674
|
path: '/dsh-clinebot/models/toggle',
|
|
@@ -504,21 +682,100 @@ export function apply(ctx, config) {
|
|
|
504
682
|
let body = {}
|
|
505
683
|
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
506
684
|
|
|
507
|
-
if (
|
|
508
|
-
const patch = {
|
|
685
|
+
if (settingsApi?.replace) {
|
|
686
|
+
const patch = {}
|
|
687
|
+
if (Array.isArray(body.disabledModels)) {
|
|
688
|
+
patch.disabledModels = body.disabledModels
|
|
689
|
+
} else if (Array.isArray(body.enabledModels)) {
|
|
690
|
+
// Convert legacy enabledModels toggle to disabledModels
|
|
691
|
+
const allModels = getAllModels(live().dynamicModels)
|
|
692
|
+
const enabledSet = new Set(body.enabledModels)
|
|
693
|
+
patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
|
|
694
|
+
}
|
|
509
695
|
if (body.defaultModel) patch.defaultModel = body.defaultModel
|
|
510
696
|
const next = Config({ ...live(), ...patch })
|
|
511
697
|
await settingsApi.replace(next)
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
698
|
+
await syncProviderState(next)
|
|
699
|
+
writeJson(res, 200, { ok: true, disabledModels: next.disabledModels, enabledModels: publicConfig(next).enabledModels })
|
|
700
|
+
} else {
|
|
701
|
+
writeJson(res, 200, { ok: true })
|
|
515
702
|
}
|
|
516
|
-
writeJson(res, 200, { ok: true, enabledModels: body.enabledModels })
|
|
517
703
|
} catch (err) {
|
|
518
704
|
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
519
705
|
}
|
|
520
706
|
},
|
|
521
707
|
}), 'dsh-clinebot: /models/toggle')
|
|
708
|
+
|
|
709
|
+
// 10. POST /dsh-clinebot/accounts/active — switch or pin active account
|
|
710
|
+
ctx.effect(() => ctx.webServer.register({
|
|
711
|
+
kind: 'exact',
|
|
712
|
+
path: '/dsh-clinebot/accounts/active',
|
|
713
|
+
handler: async (req, res) => {
|
|
714
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
715
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
716
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
717
|
+
}
|
|
718
|
+
try {
|
|
719
|
+
const bodyBuf = await readBody(req)
|
|
720
|
+
let body = {}
|
|
721
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
722
|
+
const account = String(body.account || '').trim()
|
|
723
|
+
|
|
724
|
+
if (settingsApi?.replace) {
|
|
725
|
+
const next = Config({ ...live(), activeAccount: account })
|
|
726
|
+
await settingsApi.replace(next)
|
|
727
|
+
await syncProviderState(next)
|
|
728
|
+
writeJson(res, 200, { ok: true, activeAccount: next.activeAccount })
|
|
729
|
+
} else {
|
|
730
|
+
writeJson(res, 200, { ok: true, activeAccount: account })
|
|
731
|
+
}
|
|
732
|
+
} catch (err) {
|
|
733
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
734
|
+
}
|
|
735
|
+
},
|
|
736
|
+
}), 'dsh-clinebot: /accounts/active')
|
|
737
|
+
|
|
738
|
+
// 11. POST /dsh-clinebot/auth/begin — start loopback auth listener
|
|
739
|
+
let authSession = null
|
|
740
|
+
ctx.effect(() => ctx.webServer.register({
|
|
741
|
+
kind: 'exact',
|
|
742
|
+
path: '/dsh-clinebot/auth/begin',
|
|
743
|
+
handler: async (req, res) => {
|
|
744
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
745
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
746
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
const authUrl = 'https://app.cline.bot'
|
|
750
|
+
authSession = {
|
|
751
|
+
state: 'waiting',
|
|
752
|
+
startedAt: Date.now(),
|
|
753
|
+
authUrl,
|
|
754
|
+
}
|
|
755
|
+
writeJson(res, 200, {
|
|
756
|
+
ok: true,
|
|
757
|
+
status: authSession.state,
|
|
758
|
+
authUrl,
|
|
759
|
+
})
|
|
760
|
+
} catch (err) {
|
|
761
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
}), 'dsh-clinebot: /auth/begin')
|
|
765
|
+
|
|
766
|
+
// 12. GET /dsh-clinebot/auth/status — query current fast auth state
|
|
767
|
+
ctx.effect(() => ctx.webServer.register({
|
|
768
|
+
kind: 'exact',
|
|
769
|
+
path: '/dsh-clinebot/auth/status',
|
|
770
|
+
handler: async (req, res) => {
|
|
771
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
772
|
+
writeJson(res, 200, {
|
|
773
|
+
ok: true,
|
|
774
|
+
status: authSession?.state || 'idle',
|
|
775
|
+
authUrl: authSession?.authUrl || 'https://app.cline.bot',
|
|
776
|
+
})
|
|
777
|
+
},
|
|
778
|
+
}), 'dsh-clinebot: /auth/status')
|
|
522
779
|
}
|
|
523
780
|
|
|
524
781
|
// Register /cline chat slash-command if commands service is present
|
|
@@ -528,17 +785,70 @@ export function apply(ctx, config) {
|
|
|
528
785
|
|
|
529
786
|
const unregister = commands.register({
|
|
530
787
|
name: 'cline',
|
|
531
|
-
description: 'Check ClinePass subscription quota,
|
|
532
|
-
execute: async () => {
|
|
788
|
+
description: 'Check ClinePass subscription quota, models, accounts and session stats (/cline [quota|models|accounts|switch <name>])',
|
|
789
|
+
execute: async (rawArgs) => {
|
|
533
790
|
const pub = publicConfig(live())
|
|
534
|
-
const
|
|
535
|
-
|
|
791
|
+
const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
|
|
792
|
+
const param = String(rawArgs || '').trim().split(/\s+/)[1] || ''
|
|
793
|
+
|
|
794
|
+
// 1. Subcommand /cline models
|
|
795
|
+
if (subcmd === 'models') {
|
|
796
|
+
const allModels = getAllModels(pub.dynamicModels)
|
|
797
|
+
const disabledSet = new Set(pub.disabledModels || [])
|
|
798
|
+
const lines = [
|
|
799
|
+
'### 🎯 ClinePass Models Catalog',
|
|
800
|
+
`* **Всего моделей**: ${allModels.length} (${allModels.filter((m) => !disabledSet.has(m.id)).length} активно)`,
|
|
801
|
+
'',
|
|
802
|
+
]
|
|
803
|
+
for (const m of allModels) {
|
|
804
|
+
const active = !disabledSet.has(m.id) ? '✅' : '❌'
|
|
805
|
+
const isVis = isVisionModel(m.id, pub.dynamicModels) ? '📷 Vision' : '📝 Text'
|
|
806
|
+
const efforts = Array.isArray(m.reasoningEfforts) ? `🧠 [${m.reasoningEfforts.join(', ')}]` : ''
|
|
807
|
+
lines.push(`* ${active} **${m.name}** (\`${m.id}\`) — ${isVis} · ${formatModelContext(m.contextLength)} ${efforts}`)
|
|
808
|
+
}
|
|
809
|
+
return lines.join('\n')
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// 2. Subcommand /cline accounts
|
|
813
|
+
if (subcmd === 'accounts') {
|
|
814
|
+
const pool = await resolveAccountPool(ctx, live())
|
|
815
|
+
const lines = [
|
|
816
|
+
'### 🔑 ClinePass Accounts Pool',
|
|
817
|
+
`* **Активный аккаунт**: \`${pub.activeAccount || pub.apiKeyEnv}\``,
|
|
818
|
+
'',
|
|
819
|
+
]
|
|
820
|
+
for (const acc of pool) {
|
|
821
|
+
const pinBadge = acc.isPinned ? '📌 [Pinned]' : ''
|
|
822
|
+
const statusBadge = acc.present ? '✅ Configured' : '⚠️ Missing'
|
|
823
|
+
lines.push(`* **${acc.label}** (\`${acc.apiKeyEnv}\`): ${statusBadge} ${pinBadge}`)
|
|
824
|
+
}
|
|
825
|
+
lines.push('', 'Переключить активный аккаунт: `/cline switch <имя_переменной>`')
|
|
826
|
+
return lines.join('\n')
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// 3. Subcommand /cline switch <account>
|
|
830
|
+
if (subcmd === 'switch') {
|
|
831
|
+
if (!param) {
|
|
832
|
+
return '⚠️ Укажите аккаунт: `/cline switch <CLINEBOT_API_KEY_2>`'
|
|
833
|
+
}
|
|
834
|
+
if (settingsApi?.replace) {
|
|
835
|
+
const next = Config({ ...live(), activeAccount: param })
|
|
836
|
+
await settingsApi.replace(next)
|
|
837
|
+
await syncProviderState(next)
|
|
838
|
+
return `✅ Активный аккаунт переключен на \`${param}\``
|
|
839
|
+
}
|
|
840
|
+
return `⚠️ Не удалось применить настройку (сервис настроек недоступен).`
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// 4. Default /cline quota
|
|
844
|
+
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
845
|
+
if (!activeKey.value) {
|
|
536
846
|
return '⚠️ **ClineBot**: API-ключ не настроен. Откройте **Настройки → ClineBot** и сохраните ключ.'
|
|
537
847
|
}
|
|
538
848
|
|
|
539
849
|
const [health, usage] = await Promise.all([
|
|
540
850
|
probeHealth(pub.baseUrl, { timeoutMs: 5000 }),
|
|
541
|
-
fetchUsageLimits(pub.baseUrl,
|
|
851
|
+
fetchUsageLimits(pub.baseUrl, activeKey.value, { timeoutMs: 8000 }),
|
|
542
852
|
])
|
|
543
853
|
|
|
544
854
|
const fiveHour = usage?.windows?.fiveHour
|
|
@@ -549,7 +859,7 @@ export function apply(ctx, config) {
|
|
|
549
859
|
const lines = [
|
|
550
860
|
`### 🤖 ClinePass Status (${usage?.plan || 'ClinePass'})`,
|
|
551
861
|
`* **Пинг хоста**: ${health.ok ? `✅ ${health.latencyMs} мс` : '❌ Недоступен'}`,
|
|
552
|
-
`* **Активный ключ**:
|
|
862
|
+
`* **Активный ключ**: \`${activeKey.envName}\` (${activeKey.source})`,
|
|
553
863
|
`* **Модель по умолчанию**: \`${pub.defaultModel}\``,
|
|
554
864
|
'',
|
|
555
865
|
`**⏱ 5-часовое окно**: ${formatProgressBar(fiveHour?.percentUsed)} (сброс: ${reset5h})`,
|