@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.
@@ -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
- return {
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
- enabledModels: z.array(z.string()).default(getDefaultModelIds())
48
- .description('List of model IDs enabled for selection in DSH.'),
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 allDefaultIds = getDefaultModelIds(dynamic)
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
- enabledModels: Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length
76
- ? cfg.enabledModels
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
- const health = await probeHealth(pub.baseUrl, { timeoutMs: pub.timeoutMs })
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
- usage = await fetchUsageLimits(pub.baseUrl, key.value, { timeoutMs: pub.timeoutMs }).catch(() => null)
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, dynamicModelIds) {
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 selectedIds = new Set(dynamicModelIds || pub.enabledModels)
174
- const modelsToRegister = allModels.filter((m) => selectedIds.has(m.id))
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 key = await resolveKeyValue(ctx, pub.apiKeyEnv)
335
- if (!key.value) {
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, key.value, {
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 result = await upsertPiAiProvider(ctx, live(), body.models)
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 key = await resolveKeyValue(ctx, pub.apiKeyEnv)
404
- if (!key.value) {
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 ${key.envName} is added to DSH credentials or environment.`,
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, key.value, {
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 key = await resolveKeyValue(ctx, pub.apiKeyEnv)
445
- if (!key.value) {
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, key.value, {
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
- if (await checkRegisteredInPiAi(ctx)) {
476
- await upsertPiAiProvider(ctx, next, Array.from(existingEnabled))
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 (Array.isArray(body.enabledModels) && settingsApi?.replace) {
508
- const patch = { enabledModels: body.enabledModels }
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
- if (await checkRegisteredInPiAi(ctx)) {
513
- await upsertPiAiProvider(ctx, next, body.enabledModels)
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, rate limits, session stats and latency',
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 key = await resolveKeyValue(ctx, pub.apiKeyEnv)
535
- if (!key.value) {
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, key.value, { timeoutMs: 8000 }),
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
- `* **Активный ключ**: ${key.envName} (${key.source})`,
862
+ `* **Активный ключ**: \`${activeKey.envName}\` (${activeKey.source})`,
553
863
  `* **Модель по умолчанию**: \`${pub.defaultModel}\``,
554
864
  '',
555
865
  `**⏱ 5-часовое окно**: ${formatProgressBar(fiveHour?.percentUsed)} (сброс: ${reset5h})`,