@goodandready/dsh-clinebot 0.3.10 → 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/CHANGELOG.md +16 -0
- package/lib/access.js +10 -0
- package/lib/account-pool.js +131 -0
- package/lib/client.js +1119 -1025
- package/lib/cline-client.js +1 -128
- package/lib/config.js +84 -0
- package/lib/index.js +36 -864
- package/lib/provider-sync.js +216 -0
- package/lib/routes/accounts.js +79 -0
- package/lib/routes/auth.js +102 -0
- package/lib/routes/models.js +126 -0
- package/lib/routes/settings.js +102 -0
- package/lib/slash-command.js +198 -0
- package/package.json +3 -2
package/lib/cline-client.js
CHANGED
|
@@ -553,132 +553,5 @@ export async function resolveKeyValue(ctx, apiKeyEnv) {
|
|
|
553
553
|
return { envName: refName, value: '', source: 'none' }
|
|
554
554
|
}
|
|
555
555
|
|
|
556
|
-
/**
|
|
557
|
-
* Resolve all accounts in pool with their status and keys.
|
|
558
|
-
*/
|
|
559
|
-
export async function resolveAccountPool(ctx, cfg) {
|
|
560
|
-
const apiKeyEnv = cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV
|
|
561
|
-
const defaultSlot = {
|
|
562
|
-
id: 'default',
|
|
563
|
-
label: 'Default',
|
|
564
|
-
apiKeyEnv,
|
|
565
|
-
}
|
|
566
|
-
const accounts = Array.isArray(cfg?.accounts) ? cfg.accounts : []
|
|
567
|
-
const allSlots = [defaultSlot, ...accounts]
|
|
568
|
-
const activeAccount = String(cfg?.activeAccount || '')
|
|
569
|
-
const resolved = []
|
|
570
|
-
|
|
571
|
-
for (let i = 0; i < allSlots.length; i++) {
|
|
572
|
-
const slot = allSlots[i]
|
|
573
|
-
const envName = slot.apiKeyEnv || (i === 0 ? apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
|
|
574
|
-
const keyInfo = await resolveKeyValue(ctx, envName)
|
|
575
|
-
resolved.push({
|
|
576
|
-
id: slot.id || (i === 0 ? 'default' : `account-${i + 1}`),
|
|
577
|
-
label: slot.label || (i === 0 ? 'Default' : `Account ${i + 1}`),
|
|
578
|
-
apiKeyEnv: envName,
|
|
579
|
-
present: Boolean(keyInfo.value),
|
|
580
|
-
source: keyInfo.source,
|
|
581
|
-
value: keyInfo.value,
|
|
582
|
-
isPinned: activeAccount ? activeAccount === envName : i === 0,
|
|
583
|
-
})
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
return resolved
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Check if a cached quota entry is currently exhausted.
|
|
591
|
-
* Checks 5-hour rolling limit and verifies if resetsAt timestamp has already elapsed.
|
|
592
|
-
*/
|
|
593
|
-
export function isAccountQuotaExhausted(usage) {
|
|
594
|
-
if (!usage?.windows?.fiveHour) return false
|
|
595
|
-
const fiveHour = usage.windows.fiveHour
|
|
596
|
-
if (typeof fiveHour.percentUsed !== 'number' || fiveHour.percentUsed < 95) {
|
|
597
|
-
return false
|
|
598
|
-
}
|
|
599
|
-
// Auto-recovery: if resetsAt is present and in the past, the account is recovered
|
|
600
|
-
if (fiveHour.resetsAt) {
|
|
601
|
-
const resetTime = new Date(fiveHour.resetsAt).getTime()
|
|
602
|
-
if (!Number.isNaN(resetTime) && Date.now() >= resetTime) {
|
|
603
|
-
return false
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
return true
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* Smart Quota-Aware Failover: rotates active account upon 429 or quota exhaustion.
|
|
611
|
-
* Prioritizes accounts with lowest percentUsed and respects resetsAt recovery.
|
|
612
|
-
*/
|
|
613
|
-
export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', settingsApi = null) {
|
|
614
|
-
const pool = await resolveAccountPool(ctx, cfg)
|
|
615
|
-
const configured = pool.filter((acc) => acc.present && acc.value)
|
|
616
|
-
if (configured.length <= 1) {
|
|
617
|
-
return { rotated: false, reason, message: 'Pool has only 1 configured account' }
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
const active = String(cfg?.activeAccount || configured[0].apiKeyEnv)
|
|
621
|
-
const currentIndex = configured.findIndex((acc) => acc.apiKeyEnv === active)
|
|
622
|
-
|
|
623
|
-
// Candidate pool excluding current account if possible
|
|
624
|
-
const candidates = configured.filter((acc) => acc.apiKeyEnv !== active)
|
|
625
|
-
if (!candidates.length) {
|
|
626
|
-
return { rotated: false, reason, message: 'No alternative accounts configured' }
|
|
627
|
-
}
|
|
628
556
|
|
|
629
|
-
|
|
630
|
-
let bestCandidate = null
|
|
631
|
-
let lowestUsagePct = Infinity
|
|
632
|
-
|
|
633
|
-
for (const cand of candidates) {
|
|
634
|
-
const cacheKey = `cline:usage:${cand.value.slice(-8)}`
|
|
635
|
-
const cached = usageCache.get(cacheKey)?.data
|
|
636
|
-
const isExhausted = isAccountQuotaExhausted(cached)
|
|
637
|
-
|
|
638
|
-
if (!isExhausted) {
|
|
639
|
-
const pct = cached?.windows?.fiveHour?.percentUsed ?? 50
|
|
640
|
-
if (pct < lowestUsagePct) {
|
|
641
|
-
lowestUsagePct = pct
|
|
642
|
-
bestCandidate = cand
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// Fallback if all candidates are either exhausted or uncached: pick next in round-robin
|
|
648
|
-
const nextAcc = bestCandidate || candidates[currentIndex % candidates.length] || candidates[0]
|
|
649
|
-
|
|
650
|
-
let updated = false
|
|
651
|
-
if (settingsApi?.replace) {
|
|
652
|
-
try {
|
|
653
|
-
const next = { ...cfg, activeAccount: nextAcc.apiKeyEnv }
|
|
654
|
-
await settingsApi.replace(next)
|
|
655
|
-
updated = true
|
|
656
|
-
} catch (err) {
|
|
657
|
-
ctx?.logger?.warn?.('[dsh-clinebot] Failed to persist rotated activeAccount via settingsApi: ' + (err?.message || err))
|
|
658
|
-
}
|
|
659
|
-
} else {
|
|
660
|
-
const settings = (ctx?.get && ctx.get('settings')) || ctx?.settings
|
|
661
|
-
if (settings?.mutate) {
|
|
662
|
-
try {
|
|
663
|
-
await settings.mutate('dsh-clinebot', [
|
|
664
|
-
{ op: 'set', path: ['activeAccount'], value: nextAcc.apiKeyEnv },
|
|
665
|
-
])
|
|
666
|
-
updated = true
|
|
667
|
-
} catch (err) {
|
|
668
|
-
ctx?.logger?.warn?.('[dsh-clinebot] Failed to persist rotated activeAccount via settings.mutate: ' + (err?.message || err))
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// Reset cached quota and host probes so new account immediately revalidates
|
|
674
|
-
clearUsageCache()
|
|
675
|
-
clearProbeCache()
|
|
676
|
-
|
|
677
|
-
return {
|
|
678
|
-
rotated: true,
|
|
679
|
-
previousAccount: active,
|
|
680
|
-
activeAccount: nextAcc.apiKeyEnv,
|
|
681
|
-
reason,
|
|
682
|
-
updatedSettings: updated,
|
|
683
|
-
}
|
|
684
|
-
}
|
|
557
|
+
export { resolveAccountPool, isAccountQuotaExhausted, rotateToNextAccount } from './account-pool.js'
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery'
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_BASE_URL,
|
|
4
|
+
DEFAULT_API_KEY_ENV,
|
|
5
|
+
DEFAULT_TIMEOUT_MS,
|
|
6
|
+
DEFAULT_SMOKE_TIMEOUT_MS,
|
|
7
|
+
normalizeBaseUrl,
|
|
8
|
+
} from './cline-client.js'
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_MODEL_ID,
|
|
11
|
+
getAllModels,
|
|
12
|
+
getActiveModelIds,
|
|
13
|
+
} from './models.js'
|
|
14
|
+
|
|
15
|
+
export const NS = 'dsh-clinebot'
|
|
16
|
+
export const LLM_PI_AI_NS = 'llm-pi-ai'
|
|
17
|
+
|
|
18
|
+
export const Config = z.object({
|
|
19
|
+
enabled: z.boolean().default(true)
|
|
20
|
+
.description('When true, ClineBot is registered as a model provider in DSH.'),
|
|
21
|
+
baseUrl: z.string().default(DEFAULT_BASE_URL)
|
|
22
|
+
.description('Base API URL (default: https://api.cline.bot/api/v1).'),
|
|
23
|
+
apiKeyEnv: z.string().default(DEFAULT_API_KEY_ENV)
|
|
24
|
+
.description('Credential / env name containing the ClinePass API key (never store key directly here).'),
|
|
25
|
+
defaultModel: z.string().default(DEFAULT_MODEL_ID)
|
|
26
|
+
.description('Default model ID for chat and smoke tests.'),
|
|
27
|
+
disabledModels: z.array(z.string()).default([])
|
|
28
|
+
.description('List of model IDs explicitly disabled by the user (new models are enabled automatically).'),
|
|
29
|
+
enabledModels: z.array(z.string()).default([])
|
|
30
|
+
.description('Deprecated: preserved for backwards compatibility with earlier versions.'),
|
|
31
|
+
dynamicModels: z.array(z.object({
|
|
32
|
+
id: z.string(),
|
|
33
|
+
name: z.string(),
|
|
34
|
+
description: z.string().default(''),
|
|
35
|
+
contextLength: z.number().default(200000),
|
|
36
|
+
maxTokens: z.number().default(8192),
|
|
37
|
+
input: z.array(z.string()).default(['text']),
|
|
38
|
+
category: z.string().default('general'),
|
|
39
|
+
isCustom: z.boolean().default(false),
|
|
40
|
+
})).default([])
|
|
41
|
+
.description('Models automatically discovered from the official ClinePass subscription plan.'),
|
|
42
|
+
timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
|
|
43
|
+
.description('HTTP probe timeout in milliseconds.'),
|
|
44
|
+
smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
|
|
45
|
+
.description('Timeout for smoke chat completions in milliseconds.'),
|
|
46
|
+
modelsCachePath: z.string().default('~/.dsh/clinebot-models-cache.json')
|
|
47
|
+
.description('Local on-disk cache path for models snapshot.'),
|
|
48
|
+
accounts: z.array(z.object({
|
|
49
|
+
label: z.string().default(''),
|
|
50
|
+
apiKeyEnv: z.string(),
|
|
51
|
+
})).default([])
|
|
52
|
+
.description('Additional accounts for multi-account failover and rate limit rotation.'),
|
|
53
|
+
activeAccount: z.string().default('')
|
|
54
|
+
.description('Manually pinned active account envName or empty for auto/default.'),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
export function publicConfig(cfg) {
|
|
58
|
+
const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
|
|
59
|
+
const allModels = getAllModels(dynamic)
|
|
60
|
+
const allDefaultIds = allModels.map((m) => m.id)
|
|
61
|
+
|
|
62
|
+
let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels : []
|
|
63
|
+
if (!Array.isArray(cfg?.disabledModels) || (cfg.disabledModels.length === 0 && Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length > 0)) {
|
|
64
|
+
const enabledSet = new Set(cfg.enabledModels)
|
|
65
|
+
disabledList = allDefaultIds.filter((id) => !enabledSet.has(id))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const activeIds = getActiveModelIds(allDefaultIds, disabledList)
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
enabled: !!cfg?.enabled,
|
|
72
|
+
baseUrl: normalizeBaseUrl(cfg?.baseUrl),
|
|
73
|
+
apiKeyEnv: cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV,
|
|
74
|
+
defaultModel: cfg?.defaultModel || DEFAULT_MODEL_ID,
|
|
75
|
+
dynamicModels: dynamic,
|
|
76
|
+
disabledModels: disabledList,
|
|
77
|
+
enabledModels: activeIds,
|
|
78
|
+
timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
|
|
79
|
+
smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
|
|
80
|
+
modelsCachePath: String(cfg?.modelsCachePath || '~/.dsh/clinebot-models-cache.json'),
|
|
81
|
+
accounts: Array.isArray(cfg?.accounts) ? cfg.accounts : [],
|
|
82
|
+
activeAccount: String(cfg?.activeAccount || ''),
|
|
83
|
+
}
|
|
84
|
+
}
|