@goodandready/dsh-clinebot 0.3.11 → 0.3.13

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.
@@ -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
- // Assess quota for candidates if cached in memory
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
+ }