@goodandready/dsh-clinebot 0.3.6 → 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 +20 -0
- package/docs/design/DESIGN.md +15 -5
- package/lib/client.js +127 -69
- package/lib/cline-client.js +169 -23
- package/lib/index.js +173 -36
- package/lib/models.js +69 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ 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
|
+
|
|
19
|
+
## [0.3.7] - 2026-09-10
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **Canonical Settings Namespace Registration** (Issue #16): Moved settings declaration in `lib/index.js` to `ctx.inject(['settings'], (sctx) => { sctx.settings.register(NS, Config, { base: config }) })`, guaranteeing synchronous/asynchronous namespace availability and reactive config watching via `live()`.
|
|
23
|
+
- **Top-Level Section Removal**: Removed the unauthorized fallback to `settings.section` in `lib/client.js`, strictly confining the plugin UI to the standard `settings.plugin.item` slot under Settings → Plugins → Plugin Settings.
|
|
24
|
+
- **Safe Service Resolution**: Replaced direct property access `ctx.credentials` with safe proxy lookup `(ctx?.get && ctx.get('credentials')) || ctx?.credentials`.
|
|
25
|
+
- **Client Mirror Invalidation & Scope Sync**: Added `refreshMirrorUntilVisible(ctx)` in `lib/client.js` to trigger settings mirror re-reads until the namespace is visible in the web client, and synchronized multi-account changes directly through `scope.set('activeAccount', accountEnv)`.
|
|
26
|
+
- **Duplicate-Safe Locales**: Wrapped client dictionary registration with duplicate-safe guards (`ctx.locale.register()`) preventing registration collision errors.
|
|
27
|
+
|
|
8
28
|
## [0.3.6] - 2026-09-09
|
|
9
29
|
|
|
10
30
|
### Fixed
|
package/docs/design/DESIGN.md
CHANGED
|
@@ -7,17 +7,19 @@
|
|
|
7
7
|
The plugin consists of two runtime boundaries conforming to DSH authoring standards:
|
|
8
8
|
|
|
9
9
|
### 2.1 Host Runtime (`lib/index.js`, `lib/cline-client.js`, `lib/models.js`, `lib/http.js`)
|
|
10
|
-
* **Cordis Service Registration**:
|
|
11
|
-
* **
|
|
10
|
+
* **Cordis Service Registration**: Declares `inject = ['settings', 'webServer', 'credentials']` and registers the settings namespace dynamically via `ctx.inject(['settings'], (sctx) => { sctx.settings.register(NS, Config, { base: config }) })`. This guarantees that the configuration schema, default values, and reactive watchers are declared and available to the host and client settingsScope without timing issues.
|
|
11
|
+
* **Safe Service Resolution**: Service lookups utilize defensive proxy resolution `(ctx?.get && ctx.get('credentials')) || ctx?.credentials` to prevent `undefined` properties on Cordis proxies.
|
|
12
|
+
* **Credential Isolation**: The plugin NEVER stores plain API keys in its configuration. The setting `apiKeyEnv` holds the credential identifier (default: `CLINEBOT_API_KEY`), resolved via `ctx.get('credentials').resolve()` or `process.env`.
|
|
12
13
|
* **State Synchronization & Auto-Registration**: Mutates the core `llm-pi-ai` settings space (`op: 'set', path: ['providers', 'clinebot']`) declaratively and automatically when enabled or key is saved.
|
|
13
14
|
* **Auto-Discovery & `disabledModels`**: Features automatic background polling of subscription plan models (`GET /api/v1/users/me/plan`). User preferences are tracked via `disabledModels: []`, ensuring newly added plan models appear enabled by default in the DSH chat picker without manual re-synchronization.
|
|
14
15
|
|
|
15
16
|
### 2.2 Client Runtime (`lib/client.js`)
|
|
16
17
|
* Self-registering module via `window.__ModuleLoader__.load({ id: '@goodandready/dsh-clinebot', factory })`.
|
|
17
18
|
* Injects `['slots', 'locale', 'settingsScope']`.
|
|
18
|
-
* Slots into `settings.plugin.item` (
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* Slots strictly and exclusively into `settings.plugin.item` (`key: NS`, `locale: NS`). Standalone top-level `settings.section` registration is omitted to maintain clean primary navigation in DSH and prevent side-list pollution.
|
|
20
|
+
* Uses `refreshMirrorUntilVisible(ctx)` to invalidate and re-read the client settings mirror until the namespace is reported ready by the host.
|
|
21
|
+
* Registers localized `en` and `ru` dictionaries with duplicate-safe guards (`ctx.locale.register()`).
|
|
22
|
+
* Reactive binding via `((ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope).bind({ namespace: NS })` with `useSyncExternalStore` guarding against `unavailable` / `loading` snapshot states. Form mutations write directly to `scope.set()`.
|
|
21
23
|
* Uses native design tokens (`--dsw-alias-...`) with full dark/light theme support.
|
|
22
24
|
* Injects isolated style tag tagged with `data-dsh-plugin="dsh-clinebot"`.
|
|
23
25
|
|
|
@@ -70,3 +72,11 @@ graph LR
|
|
|
70
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`).
|
|
71
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.
|
|
72
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
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// & Plugin Card (settings.plugin.item).
|
|
1
|
+
// Plugin Settings Card (settings.plugin.item).
|
|
3
2
|
window.__ModuleLoader__.load({
|
|
4
3
|
id: '@goodandready/dsh-clinebot',
|
|
5
4
|
factory: (require) => {
|
|
@@ -357,9 +356,10 @@ window.__ModuleLoader__.load({
|
|
|
357
356
|
const t = props?.t || makeT(ru, en)
|
|
358
357
|
|
|
359
358
|
const scope = React.useMemo(() => {
|
|
360
|
-
|
|
359
|
+
const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
|
|
360
|
+
if (!s?.bind) return undefined
|
|
361
361
|
try {
|
|
362
|
-
return
|
|
362
|
+
return s.bind({ namespace: NS })
|
|
363
363
|
} catch (_) {
|
|
364
364
|
return undefined
|
|
365
365
|
}
|
|
@@ -377,11 +377,11 @@ window.__ModuleLoader__.load({
|
|
|
377
377
|
}, [scope])
|
|
378
378
|
|
|
379
379
|
const getSnapshot = React.useCallback(() => {
|
|
380
|
-
if (!scope?.getSnapshot) return
|
|
380
|
+
if (!scope?.getSnapshot) return SNAPSHOT_LOADING
|
|
381
381
|
try {
|
|
382
|
-
return scope.getSnapshot() ||
|
|
382
|
+
return scope.getSnapshot() || SNAPSHOT_LOADING
|
|
383
383
|
} catch (_) {
|
|
384
|
-
return
|
|
384
|
+
return SNAPSHOT_LOADING
|
|
385
385
|
}
|
|
386
386
|
}, [scope])
|
|
387
387
|
|
|
@@ -390,7 +390,7 @@ window.__ModuleLoader__.load({
|
|
|
390
390
|
getSnapshot,
|
|
391
391
|
React.useCallback(() => SNAPSHOT_LOADING, [])
|
|
392
392
|
)
|
|
393
|
-
const snapshotStatus = snapshot?.status || '
|
|
393
|
+
const snapshotStatus = snapshot?.status || 'loading'
|
|
394
394
|
|
|
395
395
|
const [status, setStatus] = React.useState(null)
|
|
396
396
|
const [draft, setDraft] = React.useState(null)
|
|
@@ -420,6 +420,12 @@ window.__ModuleLoader__.load({
|
|
|
420
420
|
load().catch((e) => setErr(String(e.message || e)))
|
|
421
421
|
}, [load])
|
|
422
422
|
|
|
423
|
+
React.useEffect(() => {
|
|
424
|
+
if (snapshotStatus === 'ready' && snapshot?.value && typeof snapshot.value === 'object') {
|
|
425
|
+
setDraft((prev) => (prev ? { ...prev, ...snapshot.value } : snapshot.value))
|
|
426
|
+
}
|
|
427
|
+
}, [snapshotStatus, snapshot?.value])
|
|
428
|
+
|
|
423
429
|
// Save Key handler
|
|
424
430
|
async function handleSaveKey() {
|
|
425
431
|
if (!apiKeyInput.trim()) {
|
|
@@ -570,6 +576,9 @@ window.__ModuleLoader__.load({
|
|
|
570
576
|
|
|
571
577
|
// Pin active account
|
|
572
578
|
async function handlePinAccount(accountEnv) {
|
|
579
|
+
if (scope && snapshotStatus === "ready") {
|
|
580
|
+
try { await scope.set("activeAccount", accountEnv) } catch (_) {}
|
|
581
|
+
}
|
|
573
582
|
setBusy(`pin-${accountEnv}`)
|
|
574
583
|
setErr('')
|
|
575
584
|
try {
|
|
@@ -588,8 +597,33 @@ window.__ModuleLoader__.load({
|
|
|
588
597
|
}
|
|
589
598
|
}
|
|
590
599
|
|
|
591
|
-
//
|
|
592
|
-
|
|
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) {
|
|
593
627
|
if (!draft) return
|
|
594
628
|
const currentDisabled = new Set(draft.disabledModels || [])
|
|
595
629
|
if (currentDisabled.has(id)) {
|
|
@@ -604,21 +638,11 @@ window.__ModuleLoader__.load({
|
|
|
604
638
|
const nextEnabled = allIds.filter((mId) => !currentDisabled.has(mId))
|
|
605
639
|
|
|
606
640
|
setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
|
|
607
|
-
|
|
608
|
-
if (scope && snapshotStatus === 'ready') {
|
|
609
|
-
try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
|
|
610
|
-
}
|
|
611
|
-
try {
|
|
612
|
-
await fetch(`${ROUTE_PREFIX}/models/toggle`, {
|
|
613
|
-
method: 'POST',
|
|
614
|
-
headers: { 'Content-Type': 'application/json' },
|
|
615
|
-
body: JSON.stringify({ disabledModels: nextDisabled }),
|
|
616
|
-
})
|
|
617
|
-
} catch {}
|
|
641
|
+
debounceSaveDisabledModels(nextDisabled)
|
|
618
642
|
}
|
|
619
643
|
|
|
620
|
-
// Select all / filter models
|
|
621
|
-
|
|
644
|
+
// Select all / filter models with instant UI state update & debounced network persist
|
|
645
|
+
function handleSetModelsFilter(type) {
|
|
622
646
|
if (!status?.availableModels) return
|
|
623
647
|
const all = status.availableModels
|
|
624
648
|
let allowed = new Set()
|
|
@@ -636,16 +660,7 @@ window.__ModuleLoader__.load({
|
|
|
636
660
|
const nextEnabled = Array.from(allowed)
|
|
637
661
|
|
|
638
662
|
setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
|
|
639
|
-
|
|
640
|
-
try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
|
|
641
|
-
}
|
|
642
|
-
try {
|
|
643
|
-
await fetch(`${ROUTE_PREFIX}/models/toggle`, {
|
|
644
|
-
method: 'POST',
|
|
645
|
-
headers: { 'Content-Type': 'application/json' },
|
|
646
|
-
body: JSON.stringify({ disabledModels: nextDisabled }),
|
|
647
|
-
})
|
|
648
|
-
} catch {}
|
|
663
|
+
debounceSaveDisabledModels(nextDisabled)
|
|
649
664
|
}
|
|
650
665
|
|
|
651
666
|
if (snapshotStatus === 'unavailable') {
|
|
@@ -1212,49 +1227,92 @@ window.__ModuleLoader__.load({
|
|
|
1212
1227
|
)
|
|
1213
1228
|
}
|
|
1214
1229
|
|
|
1230
|
+
function refreshMirrorUntilVisible(ctx) {
|
|
1231
|
+
const visible = () => {
|
|
1232
|
+
try {
|
|
1233
|
+
const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
|
|
1234
|
+
const view = s?.describe?.()?.getSnapshot?.()?.view
|
|
1235
|
+
return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS)
|
|
1236
|
+
} catch (_) {
|
|
1237
|
+
return false
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
if (visible()) return () => {}
|
|
1241
|
+
let tries = 0
|
|
1242
|
+
const timer = setInterval(() => {
|
|
1243
|
+
if (visible() || tries >= 15) { clearInterval(timer); return }
|
|
1244
|
+
tries += 1
|
|
1245
|
+
try {
|
|
1246
|
+
const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
|
|
1247
|
+
s?.describe?.()?.load?.()
|
|
1248
|
+
} catch (_) {}
|
|
1249
|
+
}, 1000)
|
|
1250
|
+
return () => clearInterval(timer)
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1215
1253
|
function apply(ctx) {
|
|
1254
|
+
const addLocale = (locale, dictionary) => {
|
|
1255
|
+
try {
|
|
1256
|
+
return ctx.locale.register(NS, locale, dictionary)
|
|
1257
|
+
} catch (_) {
|
|
1258
|
+
return () => {}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1216
1261
|
if (ctx.locale && ctx.locale.register) {
|
|
1217
|
-
|
|
1262
|
+
if (typeof ctx.effect === 'function') {
|
|
1263
|
+
ctx.effect(() => {
|
|
1264
|
+
const undo = [addLocale('en', en), addLocale('ru', ru)]
|
|
1265
|
+
return () => { for (const off of undo) off() }
|
|
1266
|
+
}, 'dsh-clinebot: dictionaries')
|
|
1267
|
+
} else {
|
|
1268
|
+
addLocale('en', en)
|
|
1269
|
+
addLocale('ru', ru)
|
|
1270
|
+
}
|
|
1218
1271
|
}
|
|
1219
|
-
const t = (ctx.locale && ctx.locale.bind) ? ctx.locale.bind(NS) : makeT(ru, en)
|
|
1220
1272
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
{
|
|
1227
|
-
name: 'settings.plugin.item',
|
|
1228
|
-
key: NS,
|
|
1229
|
-
locale: NS,
|
|
1230
|
-
inject: () => ({ ctx }),
|
|
1231
|
-
},
|
|
1232
|
-
(props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
|
|
1233
|
-
)
|
|
1234
|
-
})
|
|
1235
|
-
if (res === null) placed = false
|
|
1236
|
-
} catch (_) {
|
|
1237
|
-
placed = false
|
|
1273
|
+
if (typeof ctx.effect === 'function') {
|
|
1274
|
+
ctx.effect(
|
|
1275
|
+
() => refreshMirrorUntilVisible(ctx),
|
|
1276
|
+
'dsh-clinebot: re-read the settings mirror until our namespace appears',
|
|
1277
|
+
)
|
|
1238
1278
|
}
|
|
1239
1279
|
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
{
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1280
|
+
function registerSlotWhenReady(slotName, registerFn) {
|
|
1281
|
+
if (!ctx.slots) return
|
|
1282
|
+
if (typeof ctx.slots.inject === 'function') {
|
|
1283
|
+
try {
|
|
1284
|
+
ctx.slots.inject(slotName, () => {
|
|
1285
|
+
try {
|
|
1286
|
+
return registerFn()
|
|
1287
|
+
} catch (err) {
|
|
1288
|
+
console.warn('[dsh-clinebot] Error registering slot ' + slotName + ':', err)
|
|
1289
|
+
}
|
|
1290
|
+
})
|
|
1291
|
+
return
|
|
1292
|
+
} catch (err) {
|
|
1293
|
+
console.warn('[dsh-clinebot] Failed to inject slot ' + slotName + ':', err)
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if (typeof ctx.slots.register === 'function') {
|
|
1297
|
+
try {
|
|
1298
|
+
registerFn()
|
|
1299
|
+
} catch (err) {
|
|
1300
|
+
console.warn('[dsh-clinebot] Failed direct registration for ' + slotName + ':', err)
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1257
1303
|
}
|
|
1304
|
+
|
|
1305
|
+
registerSlotWhenReady('settings.plugin.item', () =>
|
|
1306
|
+
ctx.slots.register(
|
|
1307
|
+
{
|
|
1308
|
+
name: 'settings.plugin.item',
|
|
1309
|
+
key: NS,
|
|
1310
|
+
locale: NS,
|
|
1311
|
+
inject: () => ({ ctx }),
|
|
1312
|
+
},
|
|
1313
|
+
(props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
|
|
1314
|
+
)
|
|
1315
|
+
)
|
|
1258
1316
|
}
|
|
1259
1317
|
|
|
1260
1318
|
module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] }
|
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
|
@@ -122,9 +122,10 @@ function resolvePathWithHome(p) {
|
|
|
122
122
|
|
|
123
123
|
async function resolveKeyValue(ctx, apiKeyEnv) {
|
|
124
124
|
const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
|
|
125
|
-
|
|
125
|
+
const creds = (ctx?.get && ctx.get('credentials')) || ctx?.credentials
|
|
126
|
+
if (creds && typeof creds.resolve === 'function') {
|
|
126
127
|
try {
|
|
127
|
-
const hit = await
|
|
128
|
+
const hit = await creds.resolve(credentialRef(refName))
|
|
128
129
|
if (hit?.value) {
|
|
129
130
|
return { envName: refName, value: hit.value, source: 'credentials' }
|
|
130
131
|
}
|
|
@@ -204,19 +205,70 @@ async function checkRegisteredInPiAi(ctx) {
|
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
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
|
+
|
|
207
252
|
async function buildStatus(ctx, cfg) {
|
|
208
253
|
const pub = publicConfig(cfg)
|
|
209
|
-
const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
|
|
210
|
-
// Non-blocking quick health probe with low timeout so settings page loads instantly
|
|
211
254
|
const probeTimeout = Math.min(2500, pub.timeoutMs || 2500)
|
|
212
|
-
|
|
213
|
-
|
|
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)
|
|
214
265
|
const allModels = getAllModels(pub.dynamicModels)
|
|
215
266
|
|
|
216
267
|
let usage = null
|
|
217
|
-
|
|
268
|
+
const keyToUse = activeAcc.value || key.value
|
|
269
|
+
if (keyToUse) {
|
|
218
270
|
// Uses 60s cache; if cache miss, times out quickly
|
|
219
|
-
usage = await fetchUsageLimits(pub.baseUrl,
|
|
271
|
+
usage = await fetchUsageLimits(pub.baseUrl, keyToUse, { timeoutMs: probeTimeout }).catch(() => null)
|
|
220
272
|
}
|
|
221
273
|
|
|
222
274
|
// Evaluate warning state
|
|
@@ -238,10 +290,6 @@ async function buildStatus(ctx, cfg) {
|
|
|
238
290
|
}
|
|
239
291
|
}
|
|
240
292
|
|
|
241
|
-
// Resolve all accounts in pool
|
|
242
|
-
const pool = await resolveAccountPool(ctx, cfg)
|
|
243
|
-
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
244
|
-
|
|
245
293
|
return {
|
|
246
294
|
ok: true,
|
|
247
295
|
providerId: PROVIDER_ID,
|
|
@@ -323,7 +371,8 @@ function formatProgressBar(pct, totalWidth = 10) {
|
|
|
323
371
|
}
|
|
324
372
|
|
|
325
373
|
export function apply(ctx, config) {
|
|
326
|
-
let
|
|
374
|
+
let getConfig = () => config
|
|
375
|
+
const live = () => (getConfig() ? Config(structuredClone(getConfig())) : config)
|
|
327
376
|
let settingsApi
|
|
328
377
|
|
|
329
378
|
// Declarative sync helper: auto-registers or unregisters provider based on config & key availability
|
|
@@ -355,7 +404,7 @@ export function apply(ctx, config) {
|
|
|
355
404
|
if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
|
|
356
405
|
const fromDisk = await loadModelsDiskCache(cacheFile)
|
|
357
406
|
if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
|
|
358
|
-
const next = Config({ ...
|
|
407
|
+
const next = Config({ ...live(), dynamicModels: fromDisk })
|
|
359
408
|
await settingsApi.replace(next)
|
|
360
409
|
await syncProviderState(next)
|
|
361
410
|
}
|
|
@@ -376,7 +425,7 @@ export function apply(ctx, config) {
|
|
|
376
425
|
|
|
377
426
|
if (hasNew && settingsApi?.replace) {
|
|
378
427
|
const next = Config({
|
|
379
|
-
...
|
|
428
|
+
...live(),
|
|
380
429
|
dynamicModels: usageData.dynamicModels,
|
|
381
430
|
})
|
|
382
431
|
await settingsApi.replace(next)
|
|
@@ -391,23 +440,37 @@ export function apply(ctx, config) {
|
|
|
391
440
|
}
|
|
392
441
|
}
|
|
393
442
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
443
|
+
if (typeof ctx.inject === 'function') {
|
|
444
|
+
ctx.inject(['settings'], (sctx) => {
|
|
445
|
+
const scope = sctx.settings.register(NS, Config, { base: config })
|
|
446
|
+
settingsApi = scope
|
|
447
|
+
getConfig = () => (scope?.get?.() ?? config) ?? config
|
|
448
|
+
sctx.effect(() => scope.watch((next) => {
|
|
449
|
+
syncProviderState(live())
|
|
450
|
+
}), 'dsh-clinebot: settings')
|
|
451
|
+
sctx.effect(() => () => {
|
|
452
|
+
getConfig = () => config
|
|
453
|
+
settingsApi = undefined
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
} else {
|
|
457
|
+
const settingsService = (ctx?.get && ctx.get('settings')) || ctx?.settings
|
|
458
|
+
if (typeof settingsService?.register === 'function') {
|
|
459
|
+
const scope = settingsService.register(NS, Config, { base: config })
|
|
460
|
+
settingsApi = scope
|
|
461
|
+
getConfig = () => (scope?.get?.() ?? config) ?? config
|
|
462
|
+
if (typeof ctx.effect === 'function') {
|
|
463
|
+
ctx.effect(() => scope.watch((next) => {
|
|
464
|
+
syncProviderState(live())
|
|
465
|
+
}), 'dsh-clinebot: settings')
|
|
466
|
+
}
|
|
467
|
+
}
|
|
403
468
|
}
|
|
404
469
|
|
|
405
|
-
const live = () => liveCfg
|
|
406
|
-
|
|
407
470
|
// On startup: ensure provider is synced to llm-pi-ai if key is present
|
|
408
|
-
syncProviderState(
|
|
471
|
+
syncProviderState(live())
|
|
409
472
|
// Background discover plan models on startup
|
|
410
|
-
setTimeout(() => autoDiscoverPlanModels(
|
|
473
|
+
setTimeout(() => autoDiscoverPlanModels(live()), 500)
|
|
411
474
|
|
|
412
475
|
// Web server HTTP route handlers
|
|
413
476
|
if (ctx.webServer?.register) {
|
|
@@ -602,10 +665,19 @@ export function apply(ctx, config) {
|
|
|
602
665
|
latencyMs: outcome.latencyMs,
|
|
603
666
|
ok: outcome.ok,
|
|
604
667
|
error: outcome.error,
|
|
605
|
-
promptTokens: 5,
|
|
606
|
-
completionTokens: 10,
|
|
668
|
+
promptTokens: outcome.promptTokens || 5,
|
|
669
|
+
completionTokens: outcome.completionTokens || 10,
|
|
607
670
|
})
|
|
608
|
-
|
|
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 })
|
|
609
681
|
} catch (err) {
|
|
610
682
|
recordSessionRequest({ ok: false, error: String(err?.message || err) })
|
|
611
683
|
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
@@ -786,7 +858,7 @@ export function apply(ctx, config) {
|
|
|
786
858
|
|
|
787
859
|
const unregister = commands.register({
|
|
788
860
|
name: 'cline',
|
|
789
|
-
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])',
|
|
790
862
|
execute: async (rawArgs) => {
|
|
791
863
|
const pub = publicConfig(live())
|
|
792
864
|
const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
|
|
@@ -841,6 +913,65 @@ export function apply(ctx, config) {
|
|
|
841
913
|
return `⚠️ Не удалось применить настройку (сервис настроек недоступен).`
|
|
842
914
|
}
|
|
843
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
|
+
|
|
844
975
|
// 4. Default /cline quota
|
|
845
976
|
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
846
977
|
if (!activeKey.value) {
|
|
@@ -901,15 +1032,21 @@ export function apply(ctx, config) {
|
|
|
901
1032
|
},
|
|
902
1033
|
runSmokeTest: async (model) => {
|
|
903
1034
|
const pub = publicConfig(live())
|
|
904
|
-
const
|
|
905
|
-
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 })
|
|
906
1037
|
recordSessionRequest({
|
|
907
1038
|
latencyMs: res.latencyMs,
|
|
908
1039
|
ok: res.ok,
|
|
909
1040
|
error: res.error,
|
|
910
|
-
promptTokens: 5,
|
|
911
|
-
completionTokens: 10,
|
|
1041
|
+
promptTokens: res.promptTokens || 5,
|
|
1042
|
+
completionTokens: res.completionTokens || 10,
|
|
912
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
|
+
}
|
|
913
1050
|
return res
|
|
914
1051
|
},
|
|
915
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",
|