@goodandready/dsh-clinebot 0.3.11 โ†’ 0.3.12

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/lib/index.js CHANGED
@@ -1,285 +1,38 @@
1
- import z from '@deepseek-ai/schemastery'
2
- import { credentialRef } from '@deepseek-ai/dsh-credentials'
3
- import { writeJson, readBody, isTrustedSettingsRequest } from './http.js'
1
+ import { Config, publicConfig, NS, LLM_PI_AI_NS } from './config.js'
4
2
  import { registerPluginUpdater } from './updater.js'
5
3
  import {
6
- CLINE_MODELS,
7
- DEFAULT_MODEL_ID,
8
- PROVIDER_ID,
9
- PROVIDER_DISPLAY_NAME,
10
- getAllModels,
11
- getDefaultModelIds,
12
- getActiveModelIds,
13
- isSupportedModel,
14
- parsePlanIncludedModels,
15
- saveModelsDiskCache,
16
- loadModelsDiskCache,
17
- isVisionModel,
18
- } from './models.js'
19
- import {
20
- DEFAULT_BASE_URL,
21
- DEFAULT_API_KEY_ENV,
22
- DEFAULT_TIMEOUT_MS,
23
- DEFAULT_SMOKE_TIMEOUT_MS,
24
- normalizeBaseUrl,
25
- resolveApiKey,
26
- resolveKeyValue,
27
- resolveAccountPool,
28
- rotateToNextAccount,
29
- saveCredentialKey,
30
- fetchUsageLimits,
31
- probeHealth,
32
- smokeChat,
33
- buildPiAiProvider,
34
4
  sessionStats,
35
5
  recordSessionRequest,
36
6
  resetSessionStats,
37
- clearUsageCache,
38
- clearProbeCache,
39
- usageCache,
7
+ rotateToNextAccount,
8
+ resolveKeyValue,
9
+ smokeChat,
10
+ fetchUsageLimits,
40
11
  } from './cline-client.js'
12
+ import {
13
+ checkRegisteredInPiAi,
14
+ upsertPiAiProvider,
15
+ removePiAiProvider,
16
+ buildStatus,
17
+ autoDiscoverPlanModels,
18
+ resolveActiveAccountKey,
19
+ } from './provider-sync.js'
20
+ import { registerSettingsRoutes } from './routes/settings.js'
21
+ import { registerAccountsRoutes } from './routes/accounts.js'
22
+ import { registerModelsRoutes } from './routes/models.js'
23
+ import { registerAuthRoutes } from './routes/auth.js'
24
+ import { registerSlashCommand } from './slash-command.js'
41
25
 
42
26
  export const name = '@goodandready/dsh-clinebot'
43
27
  export const inject = ['settings', 'webServer', 'credentials']
44
28
 
45
- export const NS = 'dsh-clinebot'
46
- export const LLM_PI_AI_NS = 'llm-pi-ai'
47
-
48
- export { sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
49
-
50
- export const Config = z.object({
51
- enabled: z.boolean().default(true)
52
- .description('When true, ClineBot is registered as a model provider in DSH.'),
53
- baseUrl: z.string().default(DEFAULT_BASE_URL)
54
- .description('Base API URL (default: https://api.cline.bot/api/v1).'),
55
- apiKeyEnv: z.string().default(DEFAULT_API_KEY_ENV)
56
- .description('Credential / env name containing the ClinePass API key (never store key directly here).'),
57
- defaultModel: z.string().default(DEFAULT_MODEL_ID)
58
- .description('Default model ID for chat and smoke tests.'),
59
- disabledModels: z.array(z.string()).default([])
60
- .description('List of model IDs explicitly disabled by the user (new models are enabled automatically).'),
61
- enabledModels: z.array(z.string()).default([])
62
- .description('Deprecated: preserved for backwards compatibility with earlier versions.'),
63
- dynamicModels: z.array(z.object({
64
- id: z.string(),
65
- name: z.string(),
66
- description: z.string().default(''),
67
- contextLength: z.number().default(200000),
68
- maxTokens: z.number().default(8192),
69
- input: z.array(z.string()).default(['text']),
70
- category: z.string().default('general'),
71
- isCustom: z.boolean().default(false),
72
- })).default([])
73
- .description('Models automatically discovered from the official ClinePass subscription plan.'),
74
- timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
75
- .description('HTTP probe timeout in milliseconds.'),
76
- smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
77
- .description('Timeout for smoke chat completions in milliseconds.'),
78
- modelsCachePath: z.string().default('~/.dsh/clinebot-models-cache.json')
79
- .description('Local on-disk cache path for models snapshot.'),
80
- accounts: z.array(z.object({
81
- label: z.string().default(''),
82
- apiKeyEnv: z.string(),
83
- })).default([])
84
- .description('Additional accounts for multi-account failover and rate limit rotation.'),
85
- activeAccount: z.string().default('')
86
- .description('Manually pinned active account envName or empty for auto/default.'),
87
- })
88
-
89
- function publicConfig(cfg) {
90
- const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
91
- const allModels = getAllModels(dynamic)
92
- const allDefaultIds = allModels.map((m) => m.id)
93
-
94
- // Migration / compatibility: if disabledModels was not yet set, but enabledModels was provided
95
- let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels : []
96
- if (!Array.isArray(cfg?.disabledModels) || (cfg.disabledModels.length === 0 && Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length > 0)) {
97
- const enabledSet = new Set(cfg.enabledModels)
98
- disabledList = allDefaultIds.filter((id) => !enabledSet.has(id))
99
- }
100
-
101
- const activeIds = getActiveModelIds(allDefaultIds, disabledList)
102
-
103
- return {
104
- enabled: !!cfg?.enabled,
105
- baseUrl: normalizeBaseUrl(cfg?.baseUrl),
106
- apiKeyEnv: cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV,
107
- defaultModel: cfg?.defaultModel || DEFAULT_MODEL_ID,
108
- dynamicModels: dynamic,
109
- disabledModels: disabledList,
110
- enabledModels: activeIds,
111
- timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
112
- smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
113
- modelsCachePath: String(cfg?.modelsCachePath || '~/.dsh/clinebot-models-cache.json'),
114
- accounts: Array.isArray(cfg?.accounts) ? cfg.accounts : [],
115
- activeAccount: String(cfg?.activeAccount || ''),
116
- }
117
- }
118
-
119
- import os from 'node:os'
120
- import path from 'node:path'
121
-
122
- function resolvePathWithHome(p) {
123
- if (!p || typeof p !== 'string') return ''
124
- if (p === '~') return os.homedir()
125
- if (p.startsWith('~/') || p.startsWith('~\\')) {
126
- return path.join(os.homedir(), p.slice(2))
127
- }
128
- return p
129
- }
130
-
131
- async function checkRegisteredInPiAi(ctx) {
132
- const settings = ctx?.get?.('settings')
133
- if (!settings?.get) return false
134
- try {
135
- const piAi = settings.get(LLM_PI_AI_NS)
136
- return !!piAi?.providers?.[PROVIDER_ID]
137
- } catch {
138
- return false
139
- }
140
- }
141
-
142
- async function resolveActiveAccountKey(ctx, cfg) {
143
- const pool = await resolveAccountPool(ctx, cfg)
144
- const configured = pool.filter((acc) => acc.present && acc.value)
145
- if (!configured.length) {
146
- return { envName: publicConfig(cfg).apiKeyEnv, value: '', source: 'none', id: 'default' }
147
- }
148
- const pub = publicConfig(cfg)
149
- if (pub.activeAccount) {
150
- const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
151
- if (pinned) return pinned
152
- }
153
- return configured[0]
154
- }
155
-
156
- async function buildStatus(ctx, cfg) {
157
- const pub = publicConfig(cfg)
158
- const probeTimeout = Math.min(2500, pub.timeoutMs || 2500)
159
-
160
- // Concurrent resolution of keys, health probe (SWR cached), accounts pool and DSH registration
161
- const [key, pool, isRegistered, health] = await Promise.all([
162
- resolveKeyValue(ctx, pub.apiKeyEnv),
163
- resolveAccountPool(ctx, cfg),
164
- checkRegisteredInPiAi(ctx),
165
- probeHealth(pub.baseUrl, { timeoutMs: probeTimeout }),
166
- ])
167
-
168
- const activeAcc = await resolveActiveAccountKey(ctx, cfg)
169
- const allModels = getAllModels(pub.dynamicModels)
170
-
171
- let usage = null
172
- const keyToUse = activeAcc.value || key.value
173
- if (keyToUse) {
174
- // Uses 60s cache; if cache miss, times out quickly
175
- usage = await fetchUsageLimits(pub.baseUrl, keyToUse, { timeoutMs: probeTimeout }).catch(() => null)
176
- }
177
-
178
- // Evaluate warning state
179
- let quotaWarning = null
180
- if (usage?.windows?.fiveHour) {
181
- const pct = usage.windows.fiveHour.percentUsed
182
- if (pct >= 95) {
183
- quotaWarning = {
184
- level: 'exhausted',
185
- message: `5-hour rolling limit is almost exhausted (${pct}%). New requests may be rejected until quota reset.`,
186
- resetsAt: usage.windows.fiveHour.resetsAt,
187
- }
188
- } else if (pct >= 80) {
189
- quotaWarning = {
190
- level: 'warning',
191
- message: `Notice: ${pct}% of the 5-hour rolling limit has been consumed.`,
192
- resetsAt: usage.windows.fiveHour.resetsAt,
193
- }
194
- }
195
- }
196
-
197
- return {
198
- ok: true,
199
- providerId: PROVIDER_ID,
200
- displayName: PROVIDER_DISPLAY_NAME,
201
- config: pub,
202
- key: {
203
- envName: activeAcc.apiKeyEnv || key.envName,
204
- present: !!(activeAcc.value || key.value),
205
- source: activeAcc.source || key.source,
206
- },
207
- accounts: pool.map((acc) => ({
208
- id: acc.id,
209
- label: acc.label,
210
- apiKeyEnv: acc.apiKeyEnv,
211
- present: acc.present,
212
- source: acc.source,
213
- isPinned: acc.isPinned,
214
- })),
215
- activeAccount: activeAcc.apiKeyEnv,
216
- health,
217
- usage,
218
- quotaWarning,
219
- sessionStats: { ...sessionStats },
220
- isRegistered,
221
- availableModels: allModels,
222
- }
223
- }
224
-
225
- async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
226
- const settings = ctx?.get?.('settings')
227
- if (!settings?.mutate) {
228
- throw new Error('DSH settings service unavailable')
229
- }
230
-
231
- const pub = publicConfig(cfg)
232
- const allModels = getAllModels(pub.dynamicModels)
233
- const allowedSet = new Set(activeModelIds || pub.enabledModels)
234
- const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
235
-
236
- const providerObj = buildPiAiProvider({
237
- baseUrl: pub.baseUrl,
238
- apiKeyEnv: pub.apiKeyEnv,
239
- models: modelsToRegister.length ? modelsToRegister : allModels,
240
- customModels: pub.dynamicModels,
241
- displayName: PROVIDER_DISPLAY_NAME,
242
- })
243
-
244
- await settings.mutate(LLM_PI_AI_NS, [
245
- {
246
- op: 'set',
247
- path: ['providers', PROVIDER_ID],
248
- value: providerObj,
249
- },
250
- ])
251
-
252
- return providerObj
253
- }
254
-
255
- async function removePiAiProvider(ctx) {
256
- const settings = ctx?.get?.('settings')
257
- if (!settings?.mutate) {
258
- throw new Error('DSH settings service unavailable')
259
- }
260
-
261
- await settings.mutate(LLM_PI_AI_NS, [
262
- {
263
- op: 'remove',
264
- path: ['providers', PROVIDER_ID],
265
- },
266
- ])
267
- return { ok: true }
268
- }
269
-
270
- function formatProgressBar(pct, totalWidth = 10) {
271
- const clamped = Math.max(0, Math.min(100, pct || 0))
272
- const filled = Math.round((clamped / 100) * totalWidth)
273
- const empty = Math.max(0, totalWidth - filled)
274
- return `[${'โ–ˆ'.repeat(filled)}${'โ–‘'.repeat(empty)}] ${clamped}%`
275
- }
29
+ export { NS, LLM_PI_AI_NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
276
30
 
277
31
  export function apply(ctx, config) {
278
32
  let getConfig = () => config
279
33
  const live = () => (getConfig() ? Config(structuredClone(getConfig())) : config)
280
34
  let settingsApi
281
35
 
282
- // Declarative sync helper: auto-registers or unregisters provider based on config & key availability
283
36
  const syncProviderState = async (cfg) => {
284
37
  try {
285
38
  const pub = publicConfig(cfg)
@@ -298,50 +51,8 @@ export function apply(ctx, config) {
298
51
  }
299
52
  }
300
53
 
301
- // Background check for subscription plan models (runs once on startup or key save)
302
- const autoDiscoverPlanModels = async (cfg) => {
303
- try {
304
- const pub = publicConfig(cfg)
305
- const cacheFile = resolvePathWithHome(pub.modelsCachePath)
306
-
307
- // 1. If dynamicModels is empty, attempt to load from disk cache first
308
- if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
309
- const fromDisk = await loadModelsDiskCache(cacheFile)
310
- if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
311
- const next = Config({ ...live(), dynamicModels: fromDisk })
312
- await settingsApi.replace(next)
313
- await syncProviderState(next)
314
- }
315
- }
316
-
317
- const activeAcc = await resolveActiveAccountKey(ctx, cfg)
318
- if (!activeAcc.value) return
319
-
320
- const usageData = await fetchUsageLimits(pub.baseUrl, activeAcc.value, {
321
- timeoutMs: Math.min(pub.timeoutMs, 5000),
322
- bypassCache: true,
323
- })
324
-
325
- if (usageData?.ok && Array.isArray(usageData.dynamicModels) && usageData.dynamicModels.length > 0) {
326
- const existingDynamic = pub.dynamicModels || []
327
- const existingIds = new Set(existingDynamic.map((m) => m.id))
328
- const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
329
-
330
- if (hasNew && settingsApi?.replace) {
331
- const next = Config({
332
- ...live(),
333
- dynamicModels: usageData.dynamicModels,
334
- })
335
- await settingsApi.replace(next)
336
- await syncProviderState(next)
337
- if (cacheFile) {
338
- await saveModelsDiskCache(cacheFile, usageData.dynamicModels)
339
- }
340
- }
341
- }
342
- } catch {
343
- /* best-effort discovery */
344
- }
54
+ const triggerAutoDiscover = () => {
55
+ autoDiscoverPlanModels(ctx, { live, getSettingsApi: () => settingsApi, syncProviderState })
345
56
  }
346
57
 
347
58
  if (typeof ctx.inject === 'function') {
@@ -371,14 +82,10 @@ export function apply(ctx, config) {
371
82
  }
372
83
  }
373
84
 
374
- // On startup: ensure provider is synced to llm-pi-ai if key is present
375
85
  syncProviderState(live())
376
- // Background discover plan models on startup
377
- setTimeout(() => autoDiscoverPlanModels(live()), 500)
86
+ setTimeout(triggerAutoDiscover, 500)
378
87
 
379
- // Web server HTTP route handlers
380
88
  if (ctx.webServer?.register) {
381
- // 0. GET & POST /dsh-clinebot/update โ€” host one-click updater
382
89
  const unregisterUpdater = registerPluginUpdater(ctx, {
383
90
  endpoint: '/dsh-clinebot/update',
384
91
  packageName: name,
@@ -388,558 +95,23 @@ export function apply(ctx, config) {
388
95
  ctx.effect(() => () => unregisterUpdater?.(), 'dsh-clinebot: updater')
389
96
  }
390
97
 
391
- // 1. GET /dsh-clinebot/status
392
- ctx.effect(() => ctx.webServer.register({
393
- kind: 'exact',
394
- path: '/dsh-clinebot/status',
395
- handler: async (req, res) => {
396
- if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
397
- try {
398
- const st = await buildStatus(ctx, live())
399
- writeJson(res, 200, st)
400
- } catch (err) {
401
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
402
- }
403
- },
404
- }), 'dsh-clinebot: /status')
405
-
406
- // 2. GET & PUT /dsh-clinebot/config
407
- ctx.effect(() => ctx.webServer.register({
408
- kind: 'exact',
409
- path: '/dsh-clinebot/config',
410
- handler: async (req, res) => {
411
- if (req.method === 'GET') {
412
- return writeJson(res, 200, { ok: true, config: publicConfig(live()) })
413
- }
414
- if (req.method !== 'PUT') {
415
- return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
416
- }
417
- if (!isTrustedSettingsRequest(req)) {
418
- return writeJson(res, 403, { ok: false, error: 'same-origin only' })
419
- }
420
- if (!settingsApi) {
421
- return writeJson(res, 503, { ok: false, error: 'settings not ready' })
422
- }
423
- let payload
424
- try {
425
- payload = JSON.parse((await readBody(req)).toString('utf8') || '{}')
426
- } catch {
427
- return writeJson(res, 400, { ok: false, error: 'invalid json' })
428
- }
429
- if (payload && typeof payload.config === 'object') payload = payload.config
430
- try {
431
- const parsed = Config({ ...publicConfig(live()), ...payload })
432
- await settingsApi.replace(parsed)
433
- await syncProviderState(parsed)
434
- writeJson(res, 200, { ok: true, config: publicConfig(live()) })
435
- } catch (e) {
436
- writeJson(res, 400, { ok: false, error: String(e?.message || e) })
437
- }
438
- },
439
- }), 'dsh-clinebot: /config')
440
-
441
- // 3. POST /dsh-clinebot/save-key โ€” direct saving into DSH credentials service
442
- ctx.effect(() => ctx.webServer.register({
443
- kind: 'exact',
444
- path: '/dsh-clinebot/save-key',
445
- handler: async (req, res) => {
446
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
447
- if (!isTrustedSettingsRequest(req)) {
448
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
449
- }
450
- try {
451
- const bodyBuf = await readBody(req)
452
- let body = {}
453
- try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
454
-
455
- const apiKey = String(body.apiKey || '').trim()
456
- if (!apiKey) {
457
- return writeJson(res, 400, { ok: false, error: 'API key cannot be empty' })
458
- }
459
-
460
- const pub = publicConfig(live())
461
- const targetEnvName = String(body.apiKeyEnv || pub.apiKeyEnv || DEFAULT_API_KEY_ENV).trim()
462
- await saveCredentialKey(ctx, targetEnvName, apiKey)
463
-
464
- // Auto-sync provider to DSH Models and discover plan models
465
- await syncProviderState(live())
466
- autoDiscoverPlanModels(live())
467
-
468
- // Run validation probe with the newly saved key
469
- const validation = await smokeChat(pub.baseUrl, apiKey, {
470
- model: pub.defaultModel,
471
- timeoutMs: 15000,
472
- })
473
-
474
- writeJson(res, 200, {
475
- ok: true,
476
- envName: targetEnvName,
477
- validated: validation.ok,
478
- latencyMs: validation.latencyMs,
479
- validationError: validation.ok ? null : validation.error,
480
- })
481
- } catch (err) {
482
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
483
- }
484
- },
485
- }), 'dsh-clinebot: /save-key')
486
-
487
- // 4. GET /dsh-clinebot/usage โ€” direct fresh usage limit query
488
- ctx.effect(() => ctx.webServer.register({
489
- kind: 'exact',
490
- path: '/dsh-clinebot/usage',
491
- handler: async (req, res) => {
492
- if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
493
- try {
494
- const pub = publicConfig(live())
495
- const activeKey = await resolveActiveAccountKey(ctx, live())
496
- if (!activeKey.value) {
497
- return writeJson(res, 400, { ok: false, error: 'API key not found' })
498
- }
499
- const usageData = await fetchUsageLimits(pub.baseUrl, activeKey.value, {
500
- timeoutMs: pub.timeoutMs,
501
- bypassCache: true,
502
- })
503
- writeJson(res, usageData.ok ? 200 : 502, usageData)
504
- } catch (err) {
505
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
506
- }
507
- },
508
- }), 'dsh-clinebot: /usage')
509
-
510
- // 5. POST /dsh-clinebot/register โ€” upsert into DSH llm-pi-ai
511
- ctx.effect(() => ctx.webServer.register({
512
- kind: 'exact',
513
- path: '/dsh-clinebot/register',
514
- handler: async (req, res) => {
515
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
516
- if (!isTrustedSettingsRequest(req)) {
517
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
518
- }
519
- try {
520
- const bodyBuf = await readBody(req)
521
- let body = {}
522
- try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
523
- const activeModels = body.models || publicConfig(live()).enabledModels
524
- const result = await upsertPiAiProvider(ctx, live(), activeModels)
525
- writeJson(res, 200, { ok: true, provider: result })
526
- } catch (err) {
527
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
528
- }
529
- },
530
- }), 'dsh-clinebot: /register')
531
-
532
- // 6. POST /dsh-clinebot/unregister โ€” remove from DSH
533
- ctx.effect(() => ctx.webServer.register({
534
- kind: 'exact',
535
- path: '/dsh-clinebot/unregister',
536
- handler: async (req, res) => {
537
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
538
- if (!isTrustedSettingsRequest(req)) {
539
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
540
- }
541
- try {
542
- await removePiAiProvider(ctx)
543
- writeJson(res, 200, { ok: true })
544
- } catch (err) {
545
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
546
- }
547
- },
548
- }), 'dsh-clinebot: /unregister')
549
-
550
- // 7. POST /dsh-clinebot/smoke โ€” live ping test
551
- ctx.effect(() => ctx.webServer.register({
552
- kind: 'exact',
553
- path: '/dsh-clinebot/smoke',
554
- handler: async (req, res) => {
555
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
556
- if (!isTrustedSettingsRequest(req)) {
557
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
558
- }
559
- try {
560
- const bodyBuf = await readBody(req)
561
- let body = {}
562
- try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
563
-
564
- const pub = publicConfig(live())
565
- const activeKey = await resolveActiveAccountKey(ctx, live())
566
- if (!activeKey.value) {
567
- return writeJson(res, 400, {
568
- ok: false,
569
- error: `API key not found. Ensure ${activeKey.envName} is added to DSH credentials or environment.`,
570
- })
571
- }
572
-
573
- const modelToTest = body.model || pub.defaultModel || DEFAULT_MODEL_ID
574
- const outcome = await smokeChat(pub.baseUrl, activeKey.value, {
575
- model: modelToTest,
576
- timeoutMs: pub.smokeTimeoutMs,
577
- })
578
- recordSessionRequest({
579
- latencyMs: outcome.latencyMs,
580
- ok: outcome.ok,
581
- error: outcome.error,
582
- promptTokens: outcome.promptTokens || 5,
583
- completionTokens: outcome.completionTokens || 10,
584
- })
585
-
586
- let failover = null
587
- if (outcome.status === 429) {
588
- failover = await rotateToNextAccount(ctx, live(), 'smoke_429', settingsApi)
589
- if (failover.rotated) {
590
- await syncProviderState(live())
591
- }
592
- }
593
-
594
- writeJson(res, outcome.ok ? 200 : 502, { ...outcome, failover })
595
- } catch (err) {
596
- recordSessionRequest({ ok: false, error: String(err?.message || err) })
597
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
598
- }
599
- },
600
- }), 'dsh-clinebot: /smoke')
601
-
602
- // 8. POST /dsh-clinebot/models/sync โ€” sync real models from official ClinePass subscription plan
603
- ctx.effect(() => ctx.webServer.register({
604
- kind: 'exact',
605
- path: '/dsh-clinebot/models/sync',
606
- handler: async (req, res) => {
607
- if (!isTrustedSettingsRequest(req)) {
608
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
609
- }
610
- if (req.method !== 'POST') {
611
- return writeJson(res, 405, { ok: false, error: 'POST only' })
612
- }
613
- try {
614
- const pub = publicConfig(live())
615
- const activeKey = await resolveActiveAccountKey(ctx, live())
616
- if (!activeKey.value) {
617
- return writeJson(res, 400, { ok: false, error: 'API key not configured' })
618
- }
619
-
620
- const usageData = await fetchUsageLimits(pub.baseUrl, activeKey.value, {
621
- timeoutMs: pub.timeoutMs,
622
- bypassCache: true,
623
- })
624
-
625
- if (!usageData.ok) {
626
- return writeJson(res, 502, { ok: false, error: usageData.error || 'Failed to fetch plan models' })
627
- }
628
-
629
- const dynamicModels = Array.isArray(usageData.dynamicModels) ? usageData.dynamicModels : []
630
- const allModels = getAllModels(dynamicModels)
631
-
632
- if (settingsApi?.replace) {
633
- const next = Config({
634
- ...live(),
635
- dynamicModels,
636
- })
637
- await settingsApi.replace(next)
638
- await syncProviderState(next)
639
- const cacheFile = resolvePathWithHome(pub.modelsCachePath)
640
- if (cacheFile) {
641
- await saveModelsDiskCache(cacheFile, dynamicModels)
642
- }
643
- }
644
-
645
- return writeJson(res, 200, {
646
- ok: true,
647
- plan: usageData.plan,
648
- discoveredCount: dynamicModels.length,
649
- totalModelsCount: allModels.length,
650
- models: allModels,
651
- })
652
- } catch (err) {
653
- return writeJson(res, 500, { ok: false, error: String(err?.message || err) })
654
- }
655
- },
656
- }), 'dsh-clinebot: /models/sync')
657
-
658
- // 9. POST /dsh-clinebot/models/toggle โ€” toggle disabled/enabled status in picker
659
- ctx.effect(() => ctx.webServer.register({
660
- kind: 'exact',
661
- path: '/dsh-clinebot/models/toggle',
662
- handler: async (req, res) => {
663
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
664
- if (!isTrustedSettingsRequest(req)) {
665
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
666
- }
667
- try {
668
- const bodyBuf = await readBody(req)
669
- let body = {}
670
- try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
671
-
672
- if (settingsApi?.replace) {
673
- const patch = {}
674
- if (Array.isArray(body.disabledModels)) {
675
- patch.disabledModels = body.disabledModels
676
- } else if (Array.isArray(body.enabledModels)) {
677
- // Convert legacy enabledModels toggle to disabledModels
678
- const allModels = getAllModels(live().dynamicModels)
679
- const enabledSet = new Set(body.enabledModels)
680
- patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
681
- }
682
- if (body.defaultModel) patch.defaultModel = body.defaultModel
683
- const next = Config({ ...live(), ...patch })
684
- await settingsApi.replace(next)
685
- await syncProviderState(next)
686
- writeJson(res, 200, { ok: true, disabledModels: next.disabledModels, enabledModels: publicConfig(next).enabledModels })
687
- } else {
688
- writeJson(res, 200, { ok: true })
689
- }
690
- } catch (err) {
691
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
692
- }
693
- },
694
- }), 'dsh-clinebot: /models/toggle')
695
-
696
- // 10. POST /dsh-clinebot/accounts/active โ€” switch or pin active account
697
- ctx.effect(() => ctx.webServer.register({
698
- kind: 'exact',
699
- path: '/dsh-clinebot/accounts/active',
700
- handler: async (req, res) => {
701
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
702
- if (!isTrustedSettingsRequest(req)) {
703
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
704
- }
705
- try {
706
- const bodyBuf = await readBody(req)
707
- let body = {}
708
- try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
709
- const account = String(body.account || '').trim()
710
-
711
- clearUsageCache()
712
- clearProbeCache()
713
- if (settingsApi?.replace) {
714
- const next = Config({ ...live(), activeAccount: account })
715
- await settingsApi.replace(next)
716
- await syncProviderState(next)
717
- writeJson(res, 200, { ok: true, activeAccount: next.activeAccount })
718
- } else {
719
- writeJson(res, 200, { ok: true, activeAccount: account })
720
- }
721
- } catch (err) {
722
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
723
- }
724
- },
725
- }), 'dsh-clinebot: /accounts/active')
726
-
727
- // 11. POST /dsh-clinebot/auth/begin โ€” start loopback auth listener
728
- let authSession = null
729
- ctx.effect(() => ctx.webServer.register({
730
- kind: 'exact',
731
- path: '/dsh-clinebot/auth/begin',
732
- handler: async (req, res) => {
733
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
734
- if (!isTrustedSettingsRequest(req)) {
735
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
736
- }
737
- try {
738
- const authUrl = 'https://app.cline.bot'
739
- authSession = {
740
- state: 'waiting',
741
- startedAt: Date.now(),
742
- authUrl,
743
- }
744
- writeJson(res, 200, {
745
- ok: true,
746
- status: authSession.state,
747
- authUrl,
748
- })
749
- } catch (err) {
750
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
751
- }
752
- },
753
- }), 'dsh-clinebot: /auth/begin')
98
+ const routeEnv = {
99
+ live,
100
+ getSettingsApi: () => settingsApi,
101
+ syncProviderState,
102
+ triggerAutoDiscover,
103
+ }
754
104
 
755
- // 12. GET /dsh-clinebot/auth/status โ€” query current fast auth state
756
- ctx.effect(() => ctx.webServer.register({
757
- kind: 'exact',
758
- path: '/dsh-clinebot/auth/status',
759
- handler: async (req, res) => {
760
- if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
761
- writeJson(res, 200, {
762
- ok: true,
763
- status: authSession?.state || 'idle',
764
- authUrl: authSession?.authUrl || 'https://app.cline.bot',
765
- })
766
- },
767
- }), 'dsh-clinebot: /auth/status')
105
+ registerSettingsRoutes(ctx, routeEnv)
106
+ registerAccountsRoutes(ctx, routeEnv)
107
+ registerModelsRoutes(ctx, routeEnv)
108
+ registerAuthRoutes(ctx, routeEnv)
768
109
  }
769
110
 
770
- // Register /cline chat slash-command if commands service is present
771
- ctx.inject(['commands'], (cmdCtx) => {
772
- const commands = cmdCtx.commands
773
- if (typeof commands?.register !== 'function') return
774
-
775
- const unregister = commands.register({
776
- name: 'cline',
777
- description: 'Check ClinePass subscription quota, models, accounts and test connectivity (/cline [quota|models|accounts|switch <name>|test [model]|ping|rotate])',
778
- execute: async (rawArgs) => {
779
- const pub = publicConfig(live())
780
- const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
781
- const param = String(rawArgs || '').trim().split(/\s+/)[1] || ''
782
-
783
- // 1. Subcommand /cline models
784
- if (subcmd === 'models') {
785
- const allModels = getAllModels(pub.dynamicModels)
786
- const disabledSet = new Set(pub.disabledModels || [])
787
- const lines = [
788
- '### ๐ŸŽฏ ClinePass Models Catalog',
789
- `* **ะ’ัะตะณะพ ะผะพะดะตะปะตะน**: ${allModels.length} (${allModels.filter((m) => !disabledSet.has(m.id)).length} ะฐะบั‚ะธะฒะฝะพ)`,
790
- '',
791
- ]
792
- for (const m of allModels) {
793
- const active = !disabledSet.has(m.id) ? 'โœ…' : 'โŒ'
794
- const isVis = isVisionModel(m.id, pub.dynamicModels) ? '๐Ÿ“ท Vision' : '๐Ÿ“ Text'
795
- const efforts = Array.isArray(m.reasoningEfforts) ? `๐Ÿง  [${m.reasoningEfforts.join(', ')}]` : ''
796
- lines.push(`* ${active} **${m.name}** (\`${m.id}\`) โ€” ${isVis} ยท ${formatModelContext(m.contextLength)} ${efforts}`)
797
- }
798
- return lines.join('\n')
799
- }
800
-
801
- // 2. Subcommand /cline accounts
802
- if (subcmd === 'accounts') {
803
- const pool = await resolveAccountPool(ctx, live())
804
- const lines = [
805
- '### ๐Ÿ”‘ ClinePass Accounts Pool',
806
- `* **Active Account**: \`${pub.activeAccount || pub.apiKeyEnv}\``,
807
- '',
808
- ]
809
- for (const acc of pool) {
810
- const pinBadge = acc.isPinned ? '๐Ÿ“Œ [Pinned]' : ''
811
- const statusBadge = acc.present ? 'โœ… Configured' : 'โš ๏ธ Missing Key'
812
- const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
813
- const cached = usageCache.get(cacheKey)?.data
814
- const usageInfo = cached?.windows?.fiveHour ? ` ยท โฑ ${cached.windows.fiveHour.percentUsed}% used` : ''
815
- lines.push(`* **${acc.label}** (\`${acc.apiKeyEnv}\`): ${statusBadge} ${pinBadge}${usageInfo}`)
816
- }
817
- lines.push('', 'Switch active account: `/cline switch <env_variable_name>`')
818
- return lines.join('\n')
819
- }
820
-
821
- // 3. Subcommand /cline switch <account>
822
- if (subcmd === 'switch') {
823
- if (!param) {
824
- return 'โš ๏ธ Please specify account: `/cline switch <CLINEBOT_API_KEY_2>`'
825
- }
826
- clearUsageCache()
827
- clearProbeCache()
828
- if (settingsApi?.replace) {
829
- const next = Config({ ...live(), activeAccount: param })
830
- await settingsApi.replace(next)
831
- await syncProviderState(next)
832
- return `โœ… Active account switched to \`${param}\``
833
- }
834
- return `โš ๏ธ Could not apply setting (settings service unavailable).`
835
- }
836
-
837
- // 4. Subcommand /cline rotate (smart failover next)
838
- if (subcmd === 'rotate') {
839
- const res = await rotateToNextAccount(ctx, live(), 'slash_command', settingsApi)
840
- if (res.rotated) {
841
- await syncProviderState(live())
842
- return `๐Ÿ”„ **Account Rotated**: switched from \`${res.previousAccount}\` to \`${res.activeAccount}\`. DSH provider updated!`
843
- }
844
- return `โš ๏ธ Rotation skipped: ${res.message || 'no alternative configured accounts in pool'}.`
845
- }
846
-
847
- // 5. Subcommand /cline ping (fresh host reachability probe)
848
- if (subcmd === 'ping') {
849
- const health = await probeHealth(pub.baseUrl, { bypassCache: true, timeoutMs: 5000 })
850
- if (health.ok) {
851
- return `๐Ÿ“ **Cline API Pong**: \`${pub.baseUrl}\` is reachable (latency: **${health.latencyMs} ms**, HTTP ${health.status})`
852
- }
853
- return `โŒ **Cline API Ping Failed**: ${health.error || 'Host unreachable'}`
854
- }
855
-
856
- // 6. Subcommand /cline test [model] / smoke
857
- if (subcmd === 'test' || subcmd === 'smoke') {
858
- if (param && !isSupportedModel(param, pub.dynamicModels)) {
859
- return `โš ๏ธ **ClineBot**: Model \`${param}\` is not recognized. Use \`/cline models\` to see available models.`
860
- }
861
- const activeKey = await resolveActiveAccountKey(ctx, live())
862
- if (!activeKey.value) {
863
- return 'โš ๏ธ **ClineBot**: API key is not configured. Open **Settings โ†’ Plugins โ†’ ClineBot**.'
864
- }
865
- const modelToTest = param || pub.defaultModel || DEFAULT_MODEL_ID
866
- const outcome = await smokeChat(pub.baseUrl, activeKey.value, {
867
- model: modelToTest,
868
- timeoutMs: pub.smokeTimeoutMs,
869
- })
870
- recordSessionRequest({
871
- latencyMs: outcome.latencyMs,
872
- ok: outcome.ok,
873
- error: outcome.error,
874
- promptTokens: outcome.promptTokens || 5,
875
- completionTokens: outcome.completionTokens || 10,
876
- })
877
-
878
- let failoverNotice = ''
879
- if (outcome.status === 429) {
880
- const failover = await rotateToNextAccount(ctx, live(), 'smoke_429', settingsApi)
881
- if (failover.rotated) {
882
- await syncProviderState(live())
883
- failoverNotice = `\n๐Ÿ”„ **Auto-failover**: HTTP 429 detected! Active account automatically rotated to \`${failover.activeAccount}\`.`
884
- }
885
- }
886
-
887
- if (outcome.ok) {
888
- return [
889
- `### ๐ŸŸข Smoke Test Passed: \`${outcome.model}\``,
890
- `* **Latency**: ${outcome.latencyMs} ms`,
891
- `* **Tokens**: prompt: ${outcome.promptTokens}, completion: ${outcome.completionTokens} (total: ${outcome.totalTokens})`,
892
- `* **Preview**: _"${outcome.preview}"_`,
893
- ].join('\n')
894
- }
895
- return `โŒ **Smoke Test Failed**: ${outcome.error} (HTTP ${outcome.status || 'timeout'})${failoverNotice}`
896
- }
897
-
898
- // 7. Subcommand /cline quota or /cline balance
899
- const activeKey = await resolveActiveAccountKey(ctx, live())
900
- if (!activeKey.value) {
901
- return 'โš ๏ธ **ClineBot**: API key is not configured. Open **Settings โ†’ Plugins โ†’ ClineBot**.'
902
- }
903
-
904
- const [health, usage] = await Promise.all([
905
- probeHealth(pub.baseUrl, { timeoutMs: 5000 }),
906
- fetchUsageLimits(pub.baseUrl, activeKey.value, { timeoutMs: 8000 }),
907
- ])
908
-
909
- const fiveHour = usage?.windows?.fiveHour
910
- const weekly = usage?.windows?.weekly
911
- const reset5h = fiveHour?.resetsAt ? new Date(fiveHour.resetsAt).toLocaleTimeString() : 'N/A'
912
- const resetWk = weekly?.resetsAt ? new Date(weekly.resetsAt).toLocaleDateString() : 'N/A'
913
-
914
- const lines = [
915
- `### ๐Ÿค– ClinePass Status (${usage?.plan || 'ClinePass'})`,
916
- `* **Host Ping**: ${health.ok ? `โœ… ${health.latencyMs} ms` : 'โŒ Unreachable'}`,
917
- `* **Active Key**: \`${activeKey.envName}\` (${activeKey.source})`,
918
- `* **Default Model**: \`${pub.defaultModel}\``,
919
- '',
920
- `**โฑ 5-Hour Rolling Limit**: ${formatProgressBar(fiveHour?.percentUsed)} (reset: ${reset5h})`,
921
- `**๐Ÿ“… Weekly Window**: ${formatProgressBar(weekly?.percentUsed)} (reset: ${resetWk})`,
922
- ]
923
-
924
- if (fiveHour?.percentUsed >= 95) {
925
- lines.push('', '๐Ÿšจ **CRITICAL LIMIT**: 5-hour quota is 95%+ consumed! New requests may be throttled until reset.')
926
- } else if (fiveHour?.percentUsed >= 80) {
927
- lines.push('', `โš ๏ธ **Warning**: 5-hour quota is ${fiveHour.percentUsed}% consumed.`)
928
- }
929
-
930
- if (sessionStats.totalRequests > 0) {
931
- lines.push('', `**๐Ÿ“Š Session Telemetry**: ${sessionStats.successfulRequests}/${sessionStats.totalRequests} successful requests, ~${sessionStats.totalTokensEst} total tokens`)
932
- }
933
-
934
- if (usage?.user?.email) {
935
- lines.push(`* **Account**: \`${usage.user.email}\``)
936
- }
937
-
938
- return lines.join('\n')
939
- },
940
- })
941
-
942
- ctx.effect(() => () => unregister?.(), 'dsh-clinebot: slash-command')
111
+ registerSlashCommand(ctx, {
112
+ live,
113
+ getSettingsApi: () => settingsApi,
114
+ syncProviderState,
943
115
  })
944
116
 
945
117
  return {