@goodandready/dsh-clinebot 0.3.7 → 0.3.8
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 +11 -0
- package/docs/design/DESIGN.md +8 -0
- package/lib/client.js +31 -25
- package/lib/cline-client.js +169 -23
- package/lib/index.js +139 -18
- package/lib/models.js +69 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.8] - 2026-09-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Stale-While-Revalidate (SWR) Network Probe** (Issue #18): Introduced in-memory SWR caching (`probeCache`) with 25s TTL for host health probes (`probeHealth`), reducing settings card status endpoint latency from ~500ms to <1ms on repeated calls while revalidating asynchronously in the background.
|
|
12
|
+
- **HTTP Keep-Alive Connection Reuse**: Added persistent `keepalive: true` connection options across all HTTP calls (`probeHealth`, `smokeChat`, `fetchUsageLimits`) to eliminate recurrent TCP/TLS handshakes to `api.cline.bot`.
|
|
13
|
+
- **Auto-Failover Account Rotation**: Implemented `rotateToNextAccount` to automatically rotate active accounts in the configured pool when encountering HTTP 429 rate limits or 100% quota exhaustion, instantly synchronizing DSH `llm-pi-ai` credentials without restarting.
|
|
14
|
+
- **Accurate Token Telemetry**: Extracted real usage metrics (`prompt_tokens`, `completion_tokens`, `total_tokens`) from chat completion responses, replacing static estimation counters.
|
|
15
|
+
- **Expanded Slash-Commands**: Extended `/cline` chat command with `/cline test [model]` (live smoke verification), `/cline ping` (real-time host connectivity test), and `/cline rotate` (manual failover).
|
|
16
|
+
- **Curated Models Catalog Expansion**: Added Claude 3.7 Sonnet (Hybrid Reasoning), GPT-4.5 Preview, o3-mini, Gemini 2.5 Pro / Flash, and Qwen 2.5 Coder 32B to the official `CLINE_MODELS` catalogue.
|
|
17
|
+
- **Debounced Model Picker Toggles**: Added 280ms debounce for model exclusion persistence in `lib/client.js`, providing 0ms UI checkbox responsiveness and preventing network request thrashing.
|
|
18
|
+
|
|
8
19
|
## [0.3.7] - 2026-09-10
|
|
9
20
|
|
|
10
21
|
### Fixed
|
package/docs/design/DESIGN.md
CHANGED
|
@@ -72,3 +72,11 @@ graph LR
|
|
|
72
72
|
* **Reasoning Effort Support**: Models declaring `reasoningEfforts: ['low', 'medium', 'high', 'max']` expose native thinking controls within the DSH model picker, accompanied by UI badges (`🧠 Reasoning`).
|
|
73
73
|
* **Offline Cold-Start Cache**: Discovered plan models are serialized locally to `modelsCachePath` (`~/.dsh/clinebot-models-cache.json`), ensuring models remain immediately available on cold boot even if the upstream network or Cline API is temporarily unavailable.
|
|
74
74
|
|
|
75
|
+
|
|
76
|
+
## 6. Performance, Resilience & Telemetry (v0.3.8)
|
|
77
|
+
* **Stale-While-Revalidate (SWR) Network Probing**: `probeHealth()` utilizes an in-memory SWR cache (`probeCache`) with 25s TTL. Repeated `/status` queries return instantaneously (<1ms latency) with fresh host availability, asynchronously refreshing network latency in the background without blocking the client UI thread.
|
|
78
|
+
* **HTTP Keep-Alive Connection Reuse**: Outbound fetch calls to `api.cline.bot` enforce persistent connection keepalive (`keepalive: true`), eliminating recurrent TLS handshake and TCP connection establishment latency.
|
|
79
|
+
* **Auto-Failover Account Rotation**: When an active account encounters HTTP 429 (Rate Limit) or 100% quota depletion, `rotateToNextAccount()` automatically selects the next configured account in the pool, applies the update to DSH settings, and re-synchronizes credentials in `llm-pi-ai` in real time.
|
|
80
|
+
* **Accurate Token Telemetry**: Real usage metadata (`prompt_tokens`, `completion_tokens`, `total_tokens`) is parsed directly from chat completion responses and tracked in session telemetry (`sessionStats`).
|
|
81
|
+
* **Expanded Slash Commands**: Slash command `/cline` supports `/cline test [model]` (smoke test with latency, response and token metrics), `/cline ping` (real-time host connectivity test), and `/cline rotate` (round-robin active account rotation).
|
|
82
|
+
* **Debounced Model Selection**: Model exclusion checkboxes in `lib/client.js` utilize immediate optimistic UI rendering paired with a 280ms debounced persistence layer, ensuring smooth interaction without request thrashing.
|
package/lib/client.js
CHANGED
|
@@ -597,8 +597,33 @@ window.__ModuleLoader__.load({
|
|
|
597
597
|
}
|
|
598
598
|
}
|
|
599
599
|
|
|
600
|
-
//
|
|
601
|
-
|
|
600
|
+
// Debounced persistence for model exclusion to avoid stuttering and request thrashing
|
|
601
|
+
const debounceTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
|
|
602
|
+
|
|
603
|
+
React.useEffect(() => {
|
|
604
|
+
return () => {
|
|
605
|
+
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
|
|
606
|
+
}
|
|
607
|
+
}, [])
|
|
608
|
+
|
|
609
|
+
function debounceSaveDisabledModels(nextDisabled) {
|
|
610
|
+
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
|
|
611
|
+
debounceTimerRef.current = setTimeout(async () => {
|
|
612
|
+
if (scope && snapshotStatus === 'ready') {
|
|
613
|
+
try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
await fetch(`${ROUTE_PREFIX}/models/toggle`, {
|
|
617
|
+
method: 'POST',
|
|
618
|
+
headers: { 'Content-Type': 'application/json' },
|
|
619
|
+
body: JSON.stringify({ disabledModels: nextDisabled }),
|
|
620
|
+
})
|
|
621
|
+
} catch {}
|
|
622
|
+
}, 280)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Toggle model exclusion (disabledModels logic) with instant UI state update & debounced network persist
|
|
626
|
+
function handleToggleModel(id) {
|
|
602
627
|
if (!draft) return
|
|
603
628
|
const currentDisabled = new Set(draft.disabledModels || [])
|
|
604
629
|
if (currentDisabled.has(id)) {
|
|
@@ -613,21 +638,11 @@ window.__ModuleLoader__.load({
|
|
|
613
638
|
const nextEnabled = allIds.filter((mId) => !currentDisabled.has(mId))
|
|
614
639
|
|
|
615
640
|
setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
|
|
616
|
-
|
|
617
|
-
if (scope && snapshotStatus === 'ready') {
|
|
618
|
-
try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
|
|
619
|
-
}
|
|
620
|
-
try {
|
|
621
|
-
await fetch(`${ROUTE_PREFIX}/models/toggle`, {
|
|
622
|
-
method: 'POST',
|
|
623
|
-
headers: { 'Content-Type': 'application/json' },
|
|
624
|
-
body: JSON.stringify({ disabledModels: nextDisabled }),
|
|
625
|
-
})
|
|
626
|
-
} catch {}
|
|
641
|
+
debounceSaveDisabledModels(nextDisabled)
|
|
627
642
|
}
|
|
628
643
|
|
|
629
|
-
// Select all / filter models
|
|
630
|
-
|
|
644
|
+
// Select all / filter models with instant UI state update & debounced network persist
|
|
645
|
+
function handleSetModelsFilter(type) {
|
|
631
646
|
if (!status?.availableModels) return
|
|
632
647
|
const all = status.availableModels
|
|
633
648
|
let allowed = new Set()
|
|
@@ -645,16 +660,7 @@ window.__ModuleLoader__.load({
|
|
|
645
660
|
const nextEnabled = Array.from(allowed)
|
|
646
661
|
|
|
647
662
|
setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
|
|
648
|
-
|
|
649
|
-
try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
|
|
650
|
-
}
|
|
651
|
-
try {
|
|
652
|
-
await fetch(`${ROUTE_PREFIX}/models/toggle`, {
|
|
653
|
-
method: 'POST',
|
|
654
|
-
headers: { 'Content-Type': 'application/json' },
|
|
655
|
-
body: JSON.stringify({ disabledModels: nextDisabled }),
|
|
656
|
-
})
|
|
657
|
-
} catch {}
|
|
663
|
+
debounceSaveDisabledModels(nextDisabled)
|
|
658
664
|
}
|
|
659
665
|
|
|
660
666
|
if (snapshotStatus === 'unavailable') {
|
package/lib/cline-client.js
CHANGED
|
@@ -132,6 +132,12 @@ function abortAfter(ms) {
|
|
|
132
132
|
|
|
133
133
|
// In-memory cache for quota queries to avoid hammering the ClinePass endpoint
|
|
134
134
|
const usageCache = new Map()
|
|
135
|
+
export const probeCache = new Map()
|
|
136
|
+
|
|
137
|
+
export function clearProbeCache() {
|
|
138
|
+
probeCache.clear()
|
|
139
|
+
}
|
|
140
|
+
|
|
135
141
|
export function clearUsageCache() {
|
|
136
142
|
usageCache.clear()
|
|
137
143
|
}
|
|
@@ -309,34 +315,65 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
|
|
|
309
315
|
}
|
|
310
316
|
|
|
311
317
|
/**
|
|
312
|
-
* Quick network probe to verify server availability.
|
|
318
|
+
* Quick network probe to verify server availability with Stale-While-Revalidate caching.
|
|
313
319
|
*/
|
|
314
|
-
export async function probeHealth(baseUrl, {
|
|
320
|
+
export async function probeHealth(baseUrl, {
|
|
321
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
322
|
+
fetchImpl = fetch,
|
|
323
|
+
bypassCache = false,
|
|
324
|
+
ttlMs = 25000,
|
|
325
|
+
} = {}) {
|
|
315
326
|
const root = normalizeBaseUrl(baseUrl)
|
|
316
|
-
const
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
327
|
+
const cacheKey = `probe:${root}`
|
|
328
|
+
const now = Date.now()
|
|
329
|
+
|
|
330
|
+
const doProbe = async () => {
|
|
331
|
+
const { signal, cancel } = abortAfter(timeoutMs)
|
|
332
|
+
const start = Date.now()
|
|
333
|
+
try {
|
|
334
|
+
const res = await fetchImpl(root, { method: 'GET', signal, keepalive: true }).catch(async () => {
|
|
335
|
+
return await fetchImpl(root, { method: 'HEAD', signal, keepalive: true })
|
|
336
|
+
})
|
|
337
|
+
const latencyMs = Date.now() - start
|
|
338
|
+
const reachable = res.status > 0 && res.status < 500
|
|
339
|
+
const outcome = {
|
|
340
|
+
ok: reachable,
|
|
341
|
+
status: res.status,
|
|
342
|
+
latencyMs,
|
|
343
|
+
error: reachable ? null : `HTTP status ${res.status}`,
|
|
344
|
+
checkedAt: Date.now(),
|
|
345
|
+
}
|
|
346
|
+
probeCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + ttlMs, isRevalidating: false })
|
|
347
|
+
return outcome
|
|
348
|
+
} catch (err) {
|
|
349
|
+
const latencyMs = Date.now() - start
|
|
350
|
+
const outcome = {
|
|
351
|
+
ok: false,
|
|
352
|
+
latencyMs,
|
|
353
|
+
error: String(err?.message || err),
|
|
354
|
+
checkedAt: Date.now(),
|
|
355
|
+
}
|
|
356
|
+
probeCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + 5000, isRevalidating: false })
|
|
357
|
+
return outcome
|
|
358
|
+
} finally {
|
|
359
|
+
cancel()
|
|
329
360
|
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!bypassCache && probeCache.has(cacheKey)) {
|
|
364
|
+
const cached = probeCache.get(cacheKey)
|
|
365
|
+
if (cached.expiresAt > now) {
|
|
366
|
+
return cached.data
|
|
336
367
|
}
|
|
337
|
-
|
|
338
|
-
|
|
368
|
+
// Stale-While-Revalidate: trigger async refresh and return stale snapshot immediately
|
|
369
|
+
if (!cached.isRevalidating) {
|
|
370
|
+
cached.isRevalidating = true
|
|
371
|
+
doProbe().catch(() => {})
|
|
372
|
+
}
|
|
373
|
+
return cached.data
|
|
339
374
|
}
|
|
375
|
+
|
|
376
|
+
return doProbe()
|
|
340
377
|
}
|
|
341
378
|
|
|
342
379
|
/**
|
|
@@ -368,6 +405,7 @@ export async function smokeChat(baseUrl, apiKey, {
|
|
|
368
405
|
stream: false,
|
|
369
406
|
}),
|
|
370
407
|
signal,
|
|
408
|
+
keepalive: true,
|
|
371
409
|
})
|
|
372
410
|
|
|
373
411
|
const latencyMs = Date.now() - start
|
|
@@ -385,12 +423,18 @@ export async function smokeChat(baseUrl, apiKey, {
|
|
|
385
423
|
|
|
386
424
|
const payload = data?.data && typeof data.data === 'object' ? data.data : data
|
|
387
425
|
const content = payload?.choices?.[0]?.message?.content || payload?.choices?.[0]?.message?.reasoning
|
|
426
|
+
const promptTokens = Number(data?.usage?.prompt_tokens) || Number(payload?.usage?.prompt_tokens) || 0
|
|
427
|
+
const completionTokens = Number(data?.usage?.completion_tokens) || Number(payload?.usage?.completion_tokens) || 0
|
|
428
|
+
const totalTokens = Number(data?.usage?.total_tokens) || Number(payload?.usage?.total_tokens) || (promptTokens + completionTokens)
|
|
388
429
|
return {
|
|
389
430
|
ok: true,
|
|
390
431
|
status: res.status,
|
|
391
432
|
latencyMs,
|
|
392
433
|
model: payload?.model || model,
|
|
393
434
|
preview: typeof content === 'string' ? content.trim().slice(0, 150) : 'OK',
|
|
435
|
+
promptTokens,
|
|
436
|
+
completionTokens,
|
|
437
|
+
totalTokens,
|
|
394
438
|
}
|
|
395
439
|
} catch (err) {
|
|
396
440
|
const latencyMs = Date.now() - start
|
|
@@ -446,3 +490,105 @@ export function buildPiAiProvider({
|
|
|
446
490
|
models: modelList,
|
|
447
491
|
}
|
|
448
492
|
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Safely resolve key value from DSH credentials or process.env.
|
|
496
|
+
*/
|
|
497
|
+
export async function resolveKeyValue(ctx, apiKeyEnv) {
|
|
498
|
+
const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
|
|
499
|
+
const creds = (ctx?.get && ctx.get('credentials')) || ctx?.credentials
|
|
500
|
+
if (creds && typeof creds.resolve === 'function') {
|
|
501
|
+
try {
|
|
502
|
+
const ref = await toCredentialRef(refName)
|
|
503
|
+
const hit = await creds.resolve(ref)
|
|
504
|
+
if (hit?.value) {
|
|
505
|
+
return { envName: refName, value: hit.value, source: 'credentials' }
|
|
506
|
+
}
|
|
507
|
+
} catch {
|
|
508
|
+
/* miss */
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const fromEnv = resolveApiKey(refName)
|
|
513
|
+
if (fromEnv.value) {
|
|
514
|
+
return { ...fromEnv, source: 'env' }
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return { envName: refName, value: '', source: 'none' }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Resolve all accounts in pool with their status and keys.
|
|
522
|
+
*/
|
|
523
|
+
export async function resolveAccountPool(ctx, cfg) {
|
|
524
|
+
const apiKeyEnv = cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV
|
|
525
|
+
const defaultSlot = {
|
|
526
|
+
id: 'default',
|
|
527
|
+
label: 'Default',
|
|
528
|
+
apiKeyEnv,
|
|
529
|
+
}
|
|
530
|
+
const accounts = Array.isArray(cfg?.accounts) ? cfg.accounts : []
|
|
531
|
+
const allSlots = [defaultSlot, ...accounts]
|
|
532
|
+
const activeAccount = String(cfg?.activeAccount || '')
|
|
533
|
+
const resolved = []
|
|
534
|
+
|
|
535
|
+
for (let i = 0; i < allSlots.length; i++) {
|
|
536
|
+
const slot = allSlots[i]
|
|
537
|
+
const envName = slot.apiKeyEnv || (i === 0 ? apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
|
|
538
|
+
const keyInfo = await resolveKeyValue(ctx, envName)
|
|
539
|
+
resolved.push({
|
|
540
|
+
id: slot.id || (i === 0 ? 'default' : `account-${i + 1}`),
|
|
541
|
+
label: slot.label || (i === 0 ? 'Default' : `Account ${i + 1}`),
|
|
542
|
+
apiKeyEnv: envName,
|
|
543
|
+
present: Boolean(keyInfo.value),
|
|
544
|
+
source: keyInfo.source,
|
|
545
|
+
value: keyInfo.value,
|
|
546
|
+
isPinned: activeAccount ? activeAccount === envName : i === 0,
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return resolved
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Rotate active account to next available configured account upon rate-limiting (429) or quota exhaustion.
|
|
555
|
+
*/
|
|
556
|
+
export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', settingsApi = null) {
|
|
557
|
+
const pool = await resolveAccountPool(ctx, cfg)
|
|
558
|
+
const configured = pool.filter((acc) => acc.present && acc.value)
|
|
559
|
+
if (configured.length <= 1) {
|
|
560
|
+
return { rotated: false, reason, message: 'Pool has only 1 configured account' }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const active = String(cfg?.activeAccount || configured[0].apiKeyEnv)
|
|
564
|
+
const currentIndex = configured.findIndex((acc) => acc.apiKeyEnv === active)
|
|
565
|
+
const nextIndex = (currentIndex + 1) % configured.length
|
|
566
|
+
const nextAcc = configured[nextIndex]
|
|
567
|
+
|
|
568
|
+
let updated = false
|
|
569
|
+
if (settingsApi?.replace) {
|
|
570
|
+
try {
|
|
571
|
+
const next = { ...cfg, activeAccount: nextAcc.apiKeyEnv }
|
|
572
|
+
await settingsApi.replace(next)
|
|
573
|
+
updated = true
|
|
574
|
+
} catch {}
|
|
575
|
+
} else {
|
|
576
|
+
const settings = (ctx?.get && ctx.get('settings')) || ctx?.settings
|
|
577
|
+
if (settings?.mutate) {
|
|
578
|
+
try {
|
|
579
|
+
await settings.mutate('dsh-clinebot', [
|
|
580
|
+
{ op: 'set', path: ['activeAccount'], value: nextAcc.apiKeyEnv },
|
|
581
|
+
])
|
|
582
|
+
updated = true
|
|
583
|
+
} catch {}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
rotated: true,
|
|
589
|
+
previousAccount: active,
|
|
590
|
+
activeAccount: nextAcc.apiKeyEnv,
|
|
591
|
+
reason,
|
|
592
|
+
updatedSettings: updated,
|
|
593
|
+
}
|
|
594
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -205,19 +205,70 @@ async function checkRegisteredInPiAi(ctx) {
|
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Rotate active account to next available configured account upon rate-limiting (429) or quota exhaustion.
|
|
210
|
+
*/
|
|
211
|
+
export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', settingsApi = null) {
|
|
212
|
+
const pool = await resolveAccountPool(ctx, cfg)
|
|
213
|
+
const configured = pool.filter((acc) => acc.present && acc.value)
|
|
214
|
+
if (configured.length <= 1) {
|
|
215
|
+
return { rotated: false, reason, message: 'Pool has only 1 configured account' }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const pub = publicConfig(cfg)
|
|
219
|
+
const currentEnv = pub.activeAccount || configured[0].apiKeyEnv
|
|
220
|
+
const currentIndex = configured.findIndex((acc) => acc.apiKeyEnv === currentEnv)
|
|
221
|
+
const nextIndex = (currentIndex + 1) % configured.length
|
|
222
|
+
const nextAcc = configured[nextIndex]
|
|
223
|
+
|
|
224
|
+
let updated = false
|
|
225
|
+
if (settingsApi?.replace) {
|
|
226
|
+
try {
|
|
227
|
+
const next = Config({ ...cfg, activeAccount: nextAcc.apiKeyEnv })
|
|
228
|
+
await settingsApi.replace(next)
|
|
229
|
+
updated = true
|
|
230
|
+
} catch {}
|
|
231
|
+
} else {
|
|
232
|
+
const settings = (ctx?.get && ctx.get('settings')) || ctx?.settings
|
|
233
|
+
if (settings?.mutate) {
|
|
234
|
+
try {
|
|
235
|
+
await settings.mutate(NS, [
|
|
236
|
+
{ op: 'set', path: ['activeAccount'], value: nextAcc.apiKeyEnv },
|
|
237
|
+
])
|
|
238
|
+
updated = true
|
|
239
|
+
} catch {}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
rotated: true,
|
|
245
|
+
previousAccount: currentEnv,
|
|
246
|
+
activeAccount: nextAcc.apiKeyEnv,
|
|
247
|
+
reason,
|
|
248
|
+
updatedSettings: updated,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
208
252
|
async function buildStatus(ctx, cfg) {
|
|
209
253
|
const pub = publicConfig(cfg)
|
|
210
|
-
const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
|
|
211
|
-
// Non-blocking quick health probe with low timeout so settings page loads instantly
|
|
212
254
|
const probeTimeout = Math.min(2500, pub.timeoutMs || 2500)
|
|
213
|
-
|
|
214
|
-
|
|
255
|
+
|
|
256
|
+
// Concurrent resolution of keys, health probe (SWR cached), accounts pool and DSH registration
|
|
257
|
+
const [key, pool, isRegistered, health] = await Promise.all([
|
|
258
|
+
resolveKeyValue(ctx, pub.apiKeyEnv),
|
|
259
|
+
resolveAccountPool(ctx, cfg),
|
|
260
|
+
checkRegisteredInPiAi(ctx),
|
|
261
|
+
probeHealth(pub.baseUrl, { timeoutMs: probeTimeout }),
|
|
262
|
+
])
|
|
263
|
+
|
|
264
|
+
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
215
265
|
const allModels = getAllModels(pub.dynamicModels)
|
|
216
266
|
|
|
217
267
|
let usage = null
|
|
218
|
-
|
|
268
|
+
const keyToUse = activeAcc.value || key.value
|
|
269
|
+
if (keyToUse) {
|
|
219
270
|
// Uses 60s cache; if cache miss, times out quickly
|
|
220
|
-
usage = await fetchUsageLimits(pub.baseUrl,
|
|
271
|
+
usage = await fetchUsageLimits(pub.baseUrl, keyToUse, { timeoutMs: probeTimeout }).catch(() => null)
|
|
221
272
|
}
|
|
222
273
|
|
|
223
274
|
// Evaluate warning state
|
|
@@ -239,10 +290,6 @@ async function buildStatus(ctx, cfg) {
|
|
|
239
290
|
}
|
|
240
291
|
}
|
|
241
292
|
|
|
242
|
-
// Resolve all accounts in pool
|
|
243
|
-
const pool = await resolveAccountPool(ctx, cfg)
|
|
244
|
-
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
245
|
-
|
|
246
293
|
return {
|
|
247
294
|
ok: true,
|
|
248
295
|
providerId: PROVIDER_ID,
|
|
@@ -618,10 +665,19 @@ export function apply(ctx, config) {
|
|
|
618
665
|
latencyMs: outcome.latencyMs,
|
|
619
666
|
ok: outcome.ok,
|
|
620
667
|
error: outcome.error,
|
|
621
|
-
promptTokens: 5,
|
|
622
|
-
completionTokens: 10,
|
|
668
|
+
promptTokens: outcome.promptTokens || 5,
|
|
669
|
+
completionTokens: outcome.completionTokens || 10,
|
|
623
670
|
})
|
|
624
|
-
|
|
671
|
+
|
|
672
|
+
let failover = null
|
|
673
|
+
if (outcome.status === 429) {
|
|
674
|
+
failover = await rotateToNextAccount(ctx, live(), 'smoke_429', settingsApi)
|
|
675
|
+
if (failover.rotated) {
|
|
676
|
+
await syncProviderState(live())
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
writeJson(res, outcome.ok ? 200 : 502, { ...outcome, failover })
|
|
625
681
|
} catch (err) {
|
|
626
682
|
recordSessionRequest({ ok: false, error: String(err?.message || err) })
|
|
627
683
|
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
@@ -802,7 +858,7 @@ export function apply(ctx, config) {
|
|
|
802
858
|
|
|
803
859
|
const unregister = commands.register({
|
|
804
860
|
name: 'cline',
|
|
805
|
-
description: 'Check ClinePass subscription quota, models, accounts and
|
|
861
|
+
description: 'Check ClinePass subscription quota, models, accounts and test connectivity (/cline [quota|models|accounts|switch <name>|test [model]|ping|rotate])',
|
|
806
862
|
execute: async (rawArgs) => {
|
|
807
863
|
const pub = publicConfig(live())
|
|
808
864
|
const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
|
|
@@ -857,6 +913,65 @@ export function apply(ctx, config) {
|
|
|
857
913
|
return `⚠️ Не удалось применить настройку (сервис настроек недоступен).`
|
|
858
914
|
}
|
|
859
915
|
|
|
916
|
+
// 4. Subcommand /cline rotate (auto-failover next)
|
|
917
|
+
if (subcmd === 'rotate') {
|
|
918
|
+
const res = await rotateToNextAccount(ctx, live(), 'slash_command', settingsApi)
|
|
919
|
+
if (res.rotated) {
|
|
920
|
+
await syncProviderState(live())
|
|
921
|
+
return `🔄 **Ротация аккаунта**: переключено с \`${res.previousAccount}\` на \`${res.activeAccount}\`. Провайдер DSH обновлён!`
|
|
922
|
+
}
|
|
923
|
+
return `⚠️ Ротация не выполнена: ${res.message || 'нет доступных альтернативных аккаунтов в пуле'}.`
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// 5. Subcommand /cline ping (fresh host reachability probe)
|
|
927
|
+
if (subcmd === 'ping') {
|
|
928
|
+
const health = await probeHealth(pub.baseUrl, { bypassCache: true, timeoutMs: 5000 })
|
|
929
|
+
if (health.ok) {
|
|
930
|
+
return `🏓 **Cline API Pong**: \`${pub.baseUrl}\` доступен (задержка: **${health.latencyMs} мс**, HTTP ${health.status})`
|
|
931
|
+
}
|
|
932
|
+
return `❌ **Cline API Ping Failed**: ${health.error || 'Хост недоступен'}`
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// 6. Subcommand /cline test [model]
|
|
936
|
+
if (subcmd === 'test' || subcmd === 'smoke') {
|
|
937
|
+
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
938
|
+
if (!activeKey.value) {
|
|
939
|
+
return '⚠️ **ClineBot**: API-ключ не настроен. Откройте **Настройки → ClineBot**.'
|
|
940
|
+
}
|
|
941
|
+
const modelToTest = param || pub.defaultModel || DEFAULT_MODEL_ID
|
|
942
|
+
const outcome = await smokeChat(pub.baseUrl, activeKey.value, {
|
|
943
|
+
model: modelToTest,
|
|
944
|
+
timeoutMs: pub.smokeTimeoutMs,
|
|
945
|
+
})
|
|
946
|
+
recordSessionRequest({
|
|
947
|
+
latencyMs: outcome.latencyMs,
|
|
948
|
+
ok: outcome.ok,
|
|
949
|
+
error: outcome.error,
|
|
950
|
+
promptTokens: outcome.promptTokens || 5,
|
|
951
|
+
completionTokens: outcome.completionTokens || 10,
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
let failoverNotice = ''
|
|
955
|
+
if (outcome.status === 429) {
|
|
956
|
+
const failover = await rotateToNextAccount(ctx, live(), 'smoke_429', settingsApi)
|
|
957
|
+
if (failover.rotated) {
|
|
958
|
+
await syncProviderState(live())
|
|
959
|
+
failoverNotice = `
|
|
960
|
+
🔄 **Auto-failover**: Обнаружен HTTP 429! Активный аккаунт автоматически переключен на \`${failover.activeAccount}\`.`
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (outcome.ok) {
|
|
965
|
+
return [
|
|
966
|
+
`### 🟢 Smoke Test Успешен: \`${outcome.model}\``,
|
|
967
|
+
`* **Задержка**: ${outcome.latencyMs} мс`,
|
|
968
|
+
`* **Токены**: prompt: ${outcome.promptTokens}, completion: ${outcome.completionTokens} (всего: ${outcome.totalTokens})`,
|
|
969
|
+
`* **Превью**: _"${outcome.preview}"_`,
|
|
970
|
+
].join('\n')
|
|
971
|
+
}
|
|
972
|
+
return `❌ **Smoke Test Ошибка**: ${outcome.error} (HTTP ${outcome.status || 'timeout'})${failoverNotice}`
|
|
973
|
+
}
|
|
974
|
+
|
|
860
975
|
// 4. Default /cline quota
|
|
861
976
|
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
862
977
|
if (!activeKey.value) {
|
|
@@ -917,15 +1032,21 @@ export function apply(ctx, config) {
|
|
|
917
1032
|
},
|
|
918
1033
|
runSmokeTest: async (model) => {
|
|
919
1034
|
const pub = publicConfig(live())
|
|
920
|
-
const
|
|
921
|
-
const res = await smokeChat(pub.baseUrl,
|
|
1035
|
+
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
1036
|
+
const res = await smokeChat(pub.baseUrl, activeKey.value, { model: model || pub.defaultModel })
|
|
922
1037
|
recordSessionRequest({
|
|
923
1038
|
latencyMs: res.latencyMs,
|
|
924
1039
|
ok: res.ok,
|
|
925
1040
|
error: res.error,
|
|
926
|
-
promptTokens: 5,
|
|
927
|
-
completionTokens: 10,
|
|
1041
|
+
promptTokens: res.promptTokens || 5,
|
|
1042
|
+
completionTokens: res.completionTokens || 10,
|
|
928
1043
|
})
|
|
1044
|
+
if (res.status === 429) {
|
|
1045
|
+
const failover = await rotateToNextAccount(ctx, live(), 'service_429', settingsApi)
|
|
1046
|
+
if (failover.rotated) {
|
|
1047
|
+
await syncProviderState(live())
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
929
1050
|
return res
|
|
930
1051
|
},
|
|
931
1052
|
}
|
package/lib/models.js
CHANGED
|
@@ -136,6 +136,75 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
136
136
|
recommended: false,
|
|
137
137
|
isCustom: false,
|
|
138
138
|
},
|
|
139
|
+
{
|
|
140
|
+
id: 'cline-pass/claude-3-7-sonnet',
|
|
141
|
+
name: 'Claude 3.7 Sonnet',
|
|
142
|
+
description: 'Hybrid reasoning and standard generation model with high coding proficiency.',
|
|
143
|
+
contextLength: 200000,
|
|
144
|
+
maxTokens: 8192,
|
|
145
|
+
input: ['text', 'image'],
|
|
146
|
+
category: 'coding',
|
|
147
|
+
recommended: true,
|
|
148
|
+
isCustom: false,
|
|
149
|
+
reasoningEfforts: ['low', 'medium', 'high'],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'cline-pass/gpt-4.5-preview',
|
|
153
|
+
name: 'GPT-4.5 Preview',
|
|
154
|
+
description: 'Advanced flagship frontier model with deep world knowledge and intuition.',
|
|
155
|
+
contextLength: 128000,
|
|
156
|
+
maxTokens: 16384,
|
|
157
|
+
input: ['text', 'image'],
|
|
158
|
+
category: 'general',
|
|
159
|
+
recommended: true,
|
|
160
|
+
isCustom: false,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: 'cline-pass/o3-mini',
|
|
164
|
+
name: 'o3-mini',
|
|
165
|
+
description: 'Fast, cost-effective reasoning model specialized for STEM and coding.',
|
|
166
|
+
contextLength: 200000,
|
|
167
|
+
maxTokens: 65536,
|
|
168
|
+
input: ['text'],
|
|
169
|
+
category: 'reasoning',
|
|
170
|
+
recommended: true,
|
|
171
|
+
isCustom: false,
|
|
172
|
+
reasoningEfforts: ['low', 'medium', 'high'],
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'cline-pass/gemini-2.5-pro',
|
|
176
|
+
name: 'Gemini 2.5 Pro',
|
|
177
|
+
description: 'State-of-the-art multimodal reasoning model with extended context.',
|
|
178
|
+
contextLength: 1000000,
|
|
179
|
+
maxTokens: 8192,
|
|
180
|
+
input: ['text', 'image'],
|
|
181
|
+
category: 'multimodal',
|
|
182
|
+
recommended: true,
|
|
183
|
+
isCustom: false,
|
|
184
|
+
reasoningEfforts: ['low', 'medium', 'high'],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 'cline-pass/gemini-2.5-flash',
|
|
188
|
+
name: 'Gemini 2.5 Flash',
|
|
189
|
+
description: 'Ultra-fast multimodal model optimized for real-time agent workflows.',
|
|
190
|
+
contextLength: 1000000,
|
|
191
|
+
maxTokens: 8192,
|
|
192
|
+
input: ['text', 'image'],
|
|
193
|
+
category: 'general',
|
|
194
|
+
recommended: false,
|
|
195
|
+
isCustom: false,
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: 'cline-pass/qwen-2.5-coder-32b',
|
|
199
|
+
name: 'Qwen 2.5 Coder 32B',
|
|
200
|
+
description: 'Open-weights powerhouse for code generation, refactoring and bug fixing.',
|
|
201
|
+
contextLength: 131072,
|
|
202
|
+
maxTokens: 8192,
|
|
203
|
+
input: ['text'],
|
|
204
|
+
category: 'coding',
|
|
205
|
+
recommended: false,
|
|
206
|
+
isCustom: false,
|
|
207
|
+
},
|
|
139
208
|
])
|
|
140
209
|
|
|
141
210
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|