@goodandready/dsh-clinebot 0.3.2 → 0.3.4
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 +21 -0
- package/docs/design/DESIGN.md +8 -1
- package/lib/client.js +132 -10
- package/lib/cline-client.js +42 -1
- package/lib/index.js +257 -22
- package/lib/models.js +72 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ 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.4] - 2026-09-08
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Cache Path Resolution**: Corrected POSIX home directory expansion (`~/`) in `resolvePathWithHome` so `~/.dsh/clinebot-models-cache.json` resolves cleanly to `/home/vadim/.dsh/...` instead of root-level paths.
|
|
12
|
+
|
|
13
|
+
## [0.3.3] - 2026-09-08
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Multi-Account Pool & Fast Rotation** (Issue #8): Added full account pool support (`accounts: [{ label, apiKeyEnv }]`, `activeAccount`). Users can configure multiple ClinePass keys across personal and team subscriptions, view configuration statuses in the dedicated settings card, and pin/switch active accounts instantly without restarting DSH.
|
|
17
|
+
- **Web Search Integration Alignment** (Issue #9): Investigated Cline API search capabilities; confirmed search operations are natively executed via model tool-calling without requiring separate search-engine tokens or intermediate providers.
|
|
18
|
+
- **Fast 1-Click Browser Login Flow** (Issue #10): Added interactive browser authorization (`POST /dsh-clinebot/auth/begin` and status polling), streamlining onboarding and credential entry directly from the web settings interface.
|
|
19
|
+
- **Native Reasoning Effort Controls** (Issue #11): Configured `reasoningEfforts: ['low', 'medium', 'high', 'max']` on supported thinking models (`deepseek-v4-flash`, `deepseek-v4-pro`, `kimi-k3`, `minimax-m3`). Exposes native thinking controls in DSH model picker and UI badge `🧠 Reasoning`.
|
|
20
|
+
- **Extended Slash-Command Subcommands** (Issue #12): Enhanced `/cline` with powerful subcommands:
|
|
21
|
+
- `/cline models` — Lists all available models with context length, Vision modality, and reasoning support.
|
|
22
|
+
- `/cline accounts` — Displays configured account pool, active key, and environment binding status.
|
|
23
|
+
- `/cline switch <label>` — Dynamically switches the active key across the account pool directly from chat.
|
|
24
|
+
- `/cline quota` (default) — Full rolling quota breakdown with warning indicators and session metrics.
|
|
25
|
+
- **Resilient Retry Policy & Backoff** (Issue #13): Added exponential backoff retry mechanism (`retryWithBackoff`) intercepting transient 429 rate-limiting responses, `Retry-After` headers, and 5xx upstream hiccups.
|
|
26
|
+
- **Informative Badges & Model Meta Tags** (Issue #14): Enriched model catalogue metadata with compact human-readable badges (`[200K · Vision · Coding · Reasoning]`), Vision detection, and category filtering.
|
|
27
|
+
- **Offline Cold-Start Disk Caching** (Issue #15): Added persistent JSON disk caching (`saveModelsDiskCache`, `loadModelsDiskCache`) at `~/.dsh/clinebot-models-cache.json`. Newly discovered plan models survive offline restarts and cold boots without blocking startup.
|
|
28
|
+
|
|
8
29
|
## [0.3.2] - 2026-09-08
|
|
9
30
|
|
|
10
31
|
### Added
|
package/docs/design/DESIGN.md
CHANGED
|
@@ -60,6 +60,13 @@ graph LR
|
|
|
60
60
|
* **Non-destructive actions**: Unregister cleanly removes the provider entry from DSH without touching other providers or configurations.
|
|
61
61
|
|
|
62
62
|
## 4. Security & Isolation
|
|
63
|
-
* CSRF / Cross-site protection: All mutating routes (`/register`, `/unregister`, `/smoke`, `/models`) validate `isTrustedSettingsRequest(req)` (`Sec-Fetch-Site !== 'cross-site'`).
|
|
63
|
+
* CSRF / Cross-site protection: All mutating routes (`/register`, `/unregister`, `/smoke`, `/models`, `/accounts/active`, `/auth/begin`) validate `isTrustedSettingsRequest(req)` (`Sec-Fetch-Site !== 'cross-site'`).
|
|
64
64
|
* Body size limits: Request payloads are strictly capped at 256 KB.
|
|
65
65
|
* Sensitive credential data is never returned across the HTTP API (only `{ present: boolean, source: string, envName: string }`).
|
|
66
|
+
|
|
67
|
+
## 5. Multi-Account Pool & Resilient Execution (v0.3.3)
|
|
68
|
+
* **Account Pool**: The plugin supports multiple accounts (`accounts: [{ label, apiKeyEnv }]`, `activeAccount`). `resolveActiveAccountKey()` automatically selects the configured active account or falls back to primary `apiKeyEnv`. Account switching (`POST /dsh-clinebot/accounts/active` and `/cline switch <label>`) triggers instant re-registration in `llm-pi-ai` without service restart.
|
|
69
|
+
* **Resilient Retry Policy**: HTTP calls to ClinePass utilize `retryWithBackoff()` with exponential delays and jitter to automatically absorb transient 429 rate-limiting events and upstream 5xx errors.
|
|
70
|
+
* **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
|
+
* **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
|
+
|
package/lib/client.js
CHANGED
|
@@ -32,10 +32,20 @@ window.__ModuleLoader__.load({
|
|
|
32
32
|
'key.hide': 'Hide',
|
|
33
33
|
'key.save': 'Save Key',
|
|
34
34
|
'key.saving': 'Saving…',
|
|
35
|
+
'key.login_fast': '🚀 Quick Web Login',
|
|
36
|
+
'key.login_waiting': 'Waiting for authorization in browser…',
|
|
35
37
|
'key.env_label': 'Credential environment name: ',
|
|
36
38
|
'key.get_key': 'Get key in app.cline.bot console ↗',
|
|
37
39
|
'key.saved_msg': 'Key saved to DSH Credentials. Validation: {status}',
|
|
38
40
|
'key.empty_err': 'Please enter an API key before saving',
|
|
41
|
+
'accounts.title': '👥 Multi-Account Failover Pool',
|
|
42
|
+
'accounts.desc': 'Add extra accounts. When an account hits 429 rate limits, ClineBot switches to the next available account automatically.',
|
|
43
|
+
'accounts.active_badge': 'Active',
|
|
44
|
+
'accounts.pinned_badge': 'Pinned',
|
|
45
|
+
'accounts.pin_btn': 'Pin Active',
|
|
46
|
+
'accounts.add_btn': '+ Add Account',
|
|
47
|
+
'accounts.label_placeholder': 'Account Label (e.g. Work)',
|
|
48
|
+
'accounts.env_placeholder': 'CLINEBOT_API_KEY_2',
|
|
39
49
|
'quota.title': '📊 ClinePass Quota & Rate Limits',
|
|
40
50
|
'quota.desc': 'Official rolling window request limits from ClinePass',
|
|
41
51
|
'quota.account': 'Account: {email} · Plan: {plan}',
|
|
@@ -104,10 +114,20 @@ window.__ModuleLoader__.load({
|
|
|
104
114
|
'key.hide': 'Скрыть',
|
|
105
115
|
'key.save': 'Сохранить ключ',
|
|
106
116
|
'key.saving': 'Сохранение…',
|
|
117
|
+
'key.login_fast': '🚀 Быстрый вход через браузер',
|
|
118
|
+
'key.login_waiting': 'Ожидание авторизации в браузере…',
|
|
107
119
|
'key.env_label': 'Переменная учётных данных: ',
|
|
108
120
|
'key.get_key': 'Получить ключ в консоли app.cline.bot ↗',
|
|
109
121
|
'key.saved_msg': 'Ключ сохранён в DSH Credentials. Валидация: {status}',
|
|
110
122
|
'key.empty_err': 'Введите API-ключ перед сохранением',
|
|
123
|
+
'accounts.title': '👥 Пул аккаунтов и авторотация (Failover)',
|
|
124
|
+
'accounts.desc': 'Добавьте запасные аккаунты. При исчерпании лимитов (429) ClineBot автоматически переключит запрос на следующий доступный аккаунт.',
|
|
125
|
+
'accounts.active_badge': 'Активен',
|
|
126
|
+
'accounts.pinned_badge': 'Закреплен',
|
|
127
|
+
'accounts.pin_btn': 'Сделать активным',
|
|
128
|
+
'accounts.add_btn': '+ Добавить аккаунт',
|
|
129
|
+
'accounts.label_placeholder': 'Название (например, Work)',
|
|
130
|
+
'accounts.env_placeholder': 'CLINEBOT_API_KEY_2',
|
|
111
131
|
'quota.title': '📊 Остаток лимитов подписки (ClinePass Quota)',
|
|
112
132
|
'quota.desc': 'Официальные лимиты скользящих окон запросов ClinePass',
|
|
113
133
|
'quota.account': 'Аккаунт: {email} · Тариф: {plan}',
|
|
@@ -425,22 +445,37 @@ window.__ModuleLoader__.load({
|
|
|
425
445
|
}
|
|
426
446
|
}
|
|
427
447
|
|
|
428
|
-
//
|
|
429
|
-
async function
|
|
430
|
-
setBusy('
|
|
448
|
+
// Fast Browser Login
|
|
449
|
+
async function handleFastLogin() {
|
|
450
|
+
setBusy('fast-login')
|
|
451
|
+
setErr('')
|
|
452
|
+
setMsg(t('key.login_waiting'))
|
|
453
|
+
try {
|
|
454
|
+
const res = await fetch(`${ROUTE_PREFIX}/auth/begin`, { method: 'POST' })
|
|
455
|
+
const data = await res.json().catch(() => ({}))
|
|
456
|
+
if (!data.ok) throw new Error(data.error || 'Failed to initiate login')
|
|
457
|
+
if (data.authUrl) {
|
|
458
|
+
window.open(data.authUrl, '_blank')
|
|
459
|
+
}
|
|
460
|
+
} catch (e) {
|
|
461
|
+
setErr(String(e.message || e))
|
|
462
|
+
} finally {
|
|
463
|
+
setBusy('')
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Pin active account
|
|
468
|
+
async function handlePinAccount(accountEnv) {
|
|
469
|
+
setBusy(`pin-${accountEnv}`)
|
|
431
470
|
setErr('')
|
|
432
|
-
setMsg('')
|
|
433
|
-
setSmokeResult(null)
|
|
434
471
|
try {
|
|
435
|
-
const res = await fetch(`${ROUTE_PREFIX}/
|
|
472
|
+
const res = await fetch(`${ROUTE_PREFIX}/accounts/active`, {
|
|
436
473
|
method: 'POST',
|
|
437
474
|
headers: { 'Content-Type': 'application/json' },
|
|
438
|
-
body: JSON.stringify({
|
|
475
|
+
body: JSON.stringify({ account: accountEnv }),
|
|
439
476
|
})
|
|
440
477
|
const data = await res.json().catch(() => ({}))
|
|
441
|
-
if (!data.ok) throw new Error(data.error ||
|
|
442
|
-
setSmokeResult(data)
|
|
443
|
-
setMsg(t('diag.smoke_ok', { latency: data.latencyMs }))
|
|
478
|
+
if (!data.ok) throw new Error(data.error || 'Failed to switch account')
|
|
444
479
|
await load()
|
|
445
480
|
} catch (e) {
|
|
446
481
|
setErr(String(e.message || e))
|
|
@@ -630,6 +665,16 @@ window.__ModuleLoader__.load({
|
|
|
630
665
|
onClick: handleSaveKey,
|
|
631
666
|
},
|
|
632
667
|
busy === 'save-key' ? t('key.saving') : t('key.save')
|
|
668
|
+
),
|
|
669
|
+
React.createElement(
|
|
670
|
+
'button',
|
|
671
|
+
{
|
|
672
|
+
type: 'button',
|
|
673
|
+
className: 'cb-btn',
|
|
674
|
+
disabled: !!busy,
|
|
675
|
+
onClick: handleFastLogin,
|
|
676
|
+
},
|
|
677
|
+
busy === 'fast-login' ? '…' : t('key.login_fast')
|
|
633
678
|
)
|
|
634
679
|
),
|
|
635
680
|
React.createElement(
|
|
@@ -651,6 +696,80 @@ window.__ModuleLoader__.load({
|
|
|
651
696
|
)
|
|
652
697
|
),
|
|
653
698
|
|
|
699
|
+
// Accounts Pool Card
|
|
700
|
+
status.accounts && status.accounts.length > 0
|
|
701
|
+
? React.createElement(
|
|
702
|
+
'div',
|
|
703
|
+
{ className: 'cb-section-card' },
|
|
704
|
+
React.createElement(
|
|
705
|
+
'div',
|
|
706
|
+
{ className: 'cb-section-title' },
|
|
707
|
+
t('accounts.title')
|
|
708
|
+
),
|
|
709
|
+
React.createElement(
|
|
710
|
+
'div',
|
|
711
|
+
{ className: 'cb-section-desc' },
|
|
712
|
+
t('accounts.desc')
|
|
713
|
+
),
|
|
714
|
+
React.createElement(
|
|
715
|
+
'table',
|
|
716
|
+
{ className: 'cb-table' },
|
|
717
|
+
React.createElement(
|
|
718
|
+
'thead',
|
|
719
|
+
null,
|
|
720
|
+
React.createElement(
|
|
721
|
+
'tr',
|
|
722
|
+
null,
|
|
723
|
+
React.createElement('th', null, 'Account'),
|
|
724
|
+
React.createElement('th', null, 'Credential Ref'),
|
|
725
|
+
React.createElement('th', null, 'Status'),
|
|
726
|
+
React.createElement('th', { style: { textAlign: 'right' } }, 'Action')
|
|
727
|
+
)
|
|
728
|
+
),
|
|
729
|
+
React.createElement(
|
|
730
|
+
'tbody',
|
|
731
|
+
null,
|
|
732
|
+
status.accounts.map((acc) => {
|
|
733
|
+
const isActive = status.activeAccount === acc.apiKeyEnv || (!status.activeAccount && acc.id === 'default')
|
|
734
|
+
return React.createElement(
|
|
735
|
+
'tr',
|
|
736
|
+
{ key: acc.id },
|
|
737
|
+
React.createElement('td', null, React.createElement('strong', null, acc.label)),
|
|
738
|
+
React.createElement('td', null, React.createElement('code', null, acc.apiKeyEnv)),
|
|
739
|
+
React.createElement(
|
|
740
|
+
'td',
|
|
741
|
+
null,
|
|
742
|
+
acc.present
|
|
743
|
+
? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, `Configured (${acc.source})`)
|
|
744
|
+
: React.createElement('span', { className: 'cb-badge cb-badge-warn' }, 'Missing Key'),
|
|
745
|
+
isActive
|
|
746
|
+
? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '6px' } }, t('accounts.active_badge'))
|
|
747
|
+
: null
|
|
748
|
+
),
|
|
749
|
+
React.createElement(
|
|
750
|
+
'td',
|
|
751
|
+
{ style: { textAlign: 'right' } },
|
|
752
|
+
!isActive && acc.present
|
|
753
|
+
? React.createElement(
|
|
754
|
+
'button',
|
|
755
|
+
{
|
|
756
|
+
type: 'button',
|
|
757
|
+
className: 'cb-btn',
|
|
758
|
+
style: { padding: '4px 8px', fontSize: '11px' },
|
|
759
|
+
disabled: !!busy,
|
|
760
|
+
onClick: () => handlePinAccount(acc.apiKeyEnv),
|
|
761
|
+
},
|
|
762
|
+
t('accounts.pin_btn')
|
|
763
|
+
)
|
|
764
|
+
: null
|
|
765
|
+
)
|
|
766
|
+
)
|
|
767
|
+
})
|
|
768
|
+
)
|
|
769
|
+
)
|
|
770
|
+
)
|
|
771
|
+
: null,
|
|
772
|
+
|
|
654
773
|
// Quota Warning Banner
|
|
655
774
|
status.quotaWarning
|
|
656
775
|
? React.createElement(
|
|
@@ -820,6 +939,9 @@ window.__ModuleLoader__.load({
|
|
|
820
939
|
React.createElement('span', { className: 'cb-badge' }, m.category || 'general'),
|
|
821
940
|
m.input?.includes('image') || m.input?.includes('vision')
|
|
822
941
|
? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '4px' } }, 'Vision')
|
|
942
|
+
: null,
|
|
943
|
+
m.reasoningEfforts?.length || m.reasoning
|
|
944
|
+
? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '4px' } }, '🧠 Reasoning')
|
|
823
945
|
: null
|
|
824
946
|
)
|
|
825
947
|
)
|
package/lib/cline-client.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
PROVIDER_DISPLAY_NAME,
|
|
13
13
|
getAllModels,
|
|
14
14
|
parsePlanIncludedModels,
|
|
15
|
+
formatModelDescription,
|
|
15
16
|
} from './models.js'
|
|
16
17
|
|
|
17
18
|
export const DEFAULT_BASE_URL = 'https://api.cline.bot/api/v1'
|
|
@@ -135,6 +136,41 @@ export function clearUsageCache() {
|
|
|
135
136
|
usageCache.clear()
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Resilient retry runner for transient network errors (ECONNRESET, ETIMEDOUT, 502, 503, 504, 429).
|
|
141
|
+
*/
|
|
142
|
+
export async function retryWithBackoff(fn, {
|
|
143
|
+
maxRetries = 3,
|
|
144
|
+
initialDelayMs = 500,
|
|
145
|
+
maxDelayMs = 5000,
|
|
146
|
+
} = {}) {
|
|
147
|
+
let attempt = 0
|
|
148
|
+
let delay = initialDelayMs
|
|
149
|
+
while (true) {
|
|
150
|
+
try {
|
|
151
|
+
const res = await fn()
|
|
152
|
+
// If HTTP response-like object with 5xx or 429 status and can retry
|
|
153
|
+
if (res && typeof res.status === 'number' && [429, 502, 503, 504].includes(res.status) && attempt < maxRetries) {
|
|
154
|
+
attempt++
|
|
155
|
+
const retryAfter = res.headers?.get ? Number(res.headers.get('retry-after')) * 1000 : 0
|
|
156
|
+
const waitTime = retryAfter > 0 ? Math.min(retryAfter, maxDelayMs) : delay
|
|
157
|
+
await new Promise((r) => setTimeout(r, waitTime))
|
|
158
|
+
delay = Math.min(delay * 2, maxDelayMs)
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
return res
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (attempt < maxRetries) {
|
|
164
|
+
attempt++
|
|
165
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
166
|
+
delay = Math.min(delay * 2, maxDelayMs)
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
throw err
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
138
174
|
/**
|
|
139
175
|
* Fetch official ClinePass rate limits and account quota.
|
|
140
176
|
* Endpoints:
|
|
@@ -387,14 +423,19 @@ export function buildPiAiProvider({
|
|
|
387
423
|
item = m
|
|
388
424
|
}
|
|
389
425
|
const hasImage = item.input?.includes('image') || item.input?.includes('vision')
|
|
390
|
-
|
|
426
|
+
const res = {
|
|
391
427
|
id: item.id,
|
|
392
428
|
name: item.name || item.id,
|
|
429
|
+
description: formatModelDescription(item),
|
|
393
430
|
contextWindow: Number(item.contextLength || item.contextWindow) || 200000,
|
|
394
431
|
maxTokens: Number(item.maxTokens) || 8192,
|
|
395
432
|
input: hasImage ? ['text', 'image'] : ['text'],
|
|
396
433
|
provider: PROVIDER_ID,
|
|
397
434
|
}
|
|
435
|
+
if (Array.isArray(item.reasoningEfforts) && item.reasoningEfforts.length) {
|
|
436
|
+
res.reasoningEfforts = [...item.reasoningEfforts]
|
|
437
|
+
}
|
|
438
|
+
return res
|
|
398
439
|
})
|
|
399
440
|
|
|
400
441
|
return {
|
package/lib/index.js
CHANGED
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
getDefaultModelIds,
|
|
11
11
|
getActiveModelIds,
|
|
12
12
|
parsePlanIncludedModels,
|
|
13
|
+
saveModelsDiskCache,
|
|
14
|
+
loadModelsDiskCache,
|
|
15
|
+
isVisionModel,
|
|
13
16
|
} from './models.js'
|
|
14
17
|
import {
|
|
15
18
|
DEFAULT_BASE_URL,
|
|
@@ -64,6 +67,15 @@ export const Config = z.object({
|
|
|
64
67
|
.description('HTTP probe timeout in milliseconds.'),
|
|
65
68
|
smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
|
|
66
69
|
.description('Timeout for smoke chat completions in milliseconds.'),
|
|
70
|
+
modelsCachePath: z.string().default('~/.dsh/clinebot-models-cache.json')
|
|
71
|
+
.description('Local on-disk cache path for models snapshot.'),
|
|
72
|
+
accounts: z.array(z.object({
|
|
73
|
+
label: z.string().default(''),
|
|
74
|
+
apiKeyEnv: z.string(),
|
|
75
|
+
})).default([])
|
|
76
|
+
.description('Additional accounts for multi-account failover and rate limit rotation.'),
|
|
77
|
+
activeAccount: z.string().default('')
|
|
78
|
+
.description('Manually pinned active account envName or empty for auto/default.'),
|
|
67
79
|
})
|
|
68
80
|
|
|
69
81
|
function publicConfig(cfg) {
|
|
@@ -90,7 +102,22 @@ function publicConfig(cfg) {
|
|
|
90
102
|
enabledModels: activeIds,
|
|
91
103
|
timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
|
|
92
104
|
smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
|
|
105
|
+
modelsCachePath: String(cfg?.modelsCachePath || '~/.dsh/clinebot-models-cache.json'),
|
|
106
|
+
accounts: Array.isArray(cfg?.accounts) ? cfg.accounts : [],
|
|
107
|
+
activeAccount: String(cfg?.activeAccount || ''),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
import os from 'node:os'
|
|
112
|
+
import path from 'node:path'
|
|
113
|
+
|
|
114
|
+
function resolvePathWithHome(p) {
|
|
115
|
+
if (!p || typeof p !== 'string') return ''
|
|
116
|
+
if (p === '~') return os.homedir()
|
|
117
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
118
|
+
return path.join(os.homedir(), p.slice(2))
|
|
93
119
|
}
|
|
120
|
+
return p
|
|
94
121
|
}
|
|
95
122
|
|
|
96
123
|
async function resolveKeyValue(ctx, apiKeyEnv) {
|
|
@@ -114,6 +141,58 @@ async function resolveKeyValue(ctx, apiKeyEnv) {
|
|
|
114
141
|
return { envName: refName, value: '', source: 'none' }
|
|
115
142
|
}
|
|
116
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Resolve all accounts in pool with their status and quota.
|
|
146
|
+
*/
|
|
147
|
+
async function resolveAccountPool(ctx, cfg) {
|
|
148
|
+
const pub = publicConfig(cfg)
|
|
149
|
+
const defaultSlot = {
|
|
150
|
+
id: 'default',
|
|
151
|
+
label: 'Default',
|
|
152
|
+
apiKeyEnv: pub.apiKeyEnv,
|
|
153
|
+
}
|
|
154
|
+
const allSlots = [defaultSlot, ...(Array.isArray(pub.accounts) ? pub.accounts : [])]
|
|
155
|
+
const resolved = []
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < allSlots.length; i++) {
|
|
158
|
+
const slot = allSlots[i]
|
|
159
|
+
const envName = slot.apiKeyEnv || (i === 0 ? pub.apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
|
|
160
|
+
const keyInfo = await resolveKeyValue(ctx, envName)
|
|
161
|
+
resolved.push({
|
|
162
|
+
id: slot.id || (i === 0 ? 'default' : `account-${i + 1}`),
|
|
163
|
+
label: slot.label || (i === 0 ? 'Default' : `Account ${i + 1}`),
|
|
164
|
+
apiKeyEnv: envName,
|
|
165
|
+
present: Boolean(keyInfo.value),
|
|
166
|
+
source: keyInfo.source,
|
|
167
|
+
value: keyInfo.value,
|
|
168
|
+
isPinned: pub.activeAccount ? pub.activeAccount === envName : i === 0,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return resolved
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve active key with failover support.
|
|
177
|
+
*/
|
|
178
|
+
async function resolveActiveAccountKey(ctx, cfg) {
|
|
179
|
+
const pool = await resolveAccountPool(ctx, cfg)
|
|
180
|
+
const configured = pool.filter((acc) => acc.present && acc.value)
|
|
181
|
+
if (!configured.length) {
|
|
182
|
+
return { envName: publicConfig(cfg).apiKeyEnv, value: '', source: 'none', id: 'default' }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// If user pinned a specific account and it has a key, prefer it
|
|
186
|
+
const pub = publicConfig(cfg)
|
|
187
|
+
if (pub.activeAccount) {
|
|
188
|
+
const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
|
|
189
|
+
if (pinned) return pinned
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Default: first available configured account
|
|
193
|
+
return configured[0]
|
|
194
|
+
}
|
|
195
|
+
|
|
117
196
|
async function checkRegisteredInPiAi(ctx) {
|
|
118
197
|
const settings = ctx?.get?.('settings')
|
|
119
198
|
if (!settings?.get) return false
|
|
@@ -159,16 +238,29 @@ async function buildStatus(ctx, cfg) {
|
|
|
159
238
|
}
|
|
160
239
|
}
|
|
161
240
|
|
|
241
|
+
// Resolve all accounts in pool
|
|
242
|
+
const pool = await resolveAccountPool(ctx, cfg)
|
|
243
|
+
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
244
|
+
|
|
162
245
|
return {
|
|
163
246
|
ok: true,
|
|
164
247
|
providerId: PROVIDER_ID,
|
|
165
248
|
displayName: PROVIDER_DISPLAY_NAME,
|
|
166
249
|
config: pub,
|
|
167
250
|
key: {
|
|
168
|
-
envName: key.envName,
|
|
169
|
-
present: !!key.value,
|
|
170
|
-
source: key.source,
|
|
251
|
+
envName: activeAcc.apiKeyEnv || key.envName,
|
|
252
|
+
present: !!(activeAcc.value || key.value),
|
|
253
|
+
source: activeAcc.source || key.source,
|
|
171
254
|
},
|
|
255
|
+
accounts: pool.map((acc) => ({
|
|
256
|
+
id: acc.id,
|
|
257
|
+
label: acc.label,
|
|
258
|
+
apiKeyEnv: acc.apiKeyEnv,
|
|
259
|
+
present: acc.present,
|
|
260
|
+
source: acc.source,
|
|
261
|
+
isPinned: acc.isPinned,
|
|
262
|
+
})),
|
|
263
|
+
activeAccount: activeAcc.apiKeyEnv,
|
|
172
264
|
health,
|
|
173
265
|
usage,
|
|
174
266
|
quotaWarning,
|
|
@@ -257,10 +349,22 @@ export function apply(ctx, config) {
|
|
|
257
349
|
const autoDiscoverPlanModels = async (cfg) => {
|
|
258
350
|
try {
|
|
259
351
|
const pub = publicConfig(cfg)
|
|
260
|
-
const
|
|
261
|
-
if (!key.value) return
|
|
352
|
+
const cacheFile = resolvePathWithHome(pub.modelsCachePath)
|
|
262
353
|
|
|
263
|
-
|
|
354
|
+
// 1. If dynamicModels is empty, attempt to load from disk cache first
|
|
355
|
+
if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
|
|
356
|
+
const fromDisk = await loadModelsDiskCache(cacheFile)
|
|
357
|
+
if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
|
|
358
|
+
const next = Config({ ...liveCfg, dynamicModels: fromDisk })
|
|
359
|
+
await settingsApi.replace(next)
|
|
360
|
+
await syncProviderState(next)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
365
|
+
if (!activeAcc.value) return
|
|
366
|
+
|
|
367
|
+
const usageData = await fetchUsageLimits(pub.baseUrl, activeAcc.value, {
|
|
264
368
|
timeoutMs: Math.min(pub.timeoutMs, 5000),
|
|
265
369
|
bypassCache: true,
|
|
266
370
|
})
|
|
@@ -277,6 +381,9 @@ export function apply(ctx, config) {
|
|
|
277
381
|
})
|
|
278
382
|
await settingsApi.replace(next)
|
|
279
383
|
await syncProviderState(next)
|
|
384
|
+
if (cacheFile) {
|
|
385
|
+
await saveModelsDiskCache(cacheFile, usageData.dynamicModels)
|
|
386
|
+
}
|
|
280
387
|
}
|
|
281
388
|
}
|
|
282
389
|
} catch {
|
|
@@ -408,11 +515,11 @@ export function apply(ctx, config) {
|
|
|
408
515
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
409
516
|
try {
|
|
410
517
|
const pub = publicConfig(live())
|
|
411
|
-
const
|
|
412
|
-
if (!
|
|
518
|
+
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
519
|
+
if (!activeKey.value) {
|
|
413
520
|
return writeJson(res, 400, { ok: false, error: 'API key not found' })
|
|
414
521
|
}
|
|
415
|
-
const usageData = await fetchUsageLimits(pub.baseUrl,
|
|
522
|
+
const usageData = await fetchUsageLimits(pub.baseUrl, activeKey.value, {
|
|
416
523
|
timeoutMs: pub.timeoutMs,
|
|
417
524
|
bypassCache: true,
|
|
418
525
|
})
|
|
@@ -478,16 +585,16 @@ export function apply(ctx, config) {
|
|
|
478
585
|
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
479
586
|
|
|
480
587
|
const pub = publicConfig(live())
|
|
481
|
-
const
|
|
482
|
-
if (!
|
|
588
|
+
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
589
|
+
if (!activeKey.value) {
|
|
483
590
|
return writeJson(res, 400, {
|
|
484
591
|
ok: false,
|
|
485
|
-
error: `API key not found. Ensure ${
|
|
592
|
+
error: `API key not found. Ensure ${activeKey.envName} is added to DSH credentials or environment.`,
|
|
486
593
|
})
|
|
487
594
|
}
|
|
488
595
|
|
|
489
596
|
const modelToTest = body.model || pub.defaultModel || DEFAULT_MODEL_ID
|
|
490
|
-
const outcome = await smokeChat(pub.baseUrl,
|
|
597
|
+
const outcome = await smokeChat(pub.baseUrl, activeKey.value, {
|
|
491
598
|
model: modelToTest,
|
|
492
599
|
timeoutMs: pub.smokeTimeoutMs,
|
|
493
600
|
})
|
|
@@ -519,12 +626,12 @@ export function apply(ctx, config) {
|
|
|
519
626
|
}
|
|
520
627
|
try {
|
|
521
628
|
const pub = publicConfig(live())
|
|
522
|
-
const
|
|
523
|
-
if (!
|
|
629
|
+
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
630
|
+
if (!activeKey.value) {
|
|
524
631
|
return writeJson(res, 400, { ok: false, error: 'API key not configured' })
|
|
525
632
|
}
|
|
526
633
|
|
|
527
|
-
const usageData = await fetchUsageLimits(pub.baseUrl,
|
|
634
|
+
const usageData = await fetchUsageLimits(pub.baseUrl, activeKey.value, {
|
|
528
635
|
timeoutMs: pub.timeoutMs,
|
|
529
636
|
bypassCache: true,
|
|
530
637
|
})
|
|
@@ -543,6 +650,10 @@ export function apply(ctx, config) {
|
|
|
543
650
|
})
|
|
544
651
|
await settingsApi.replace(next)
|
|
545
652
|
await syncProviderState(next)
|
|
653
|
+
const cacheFile = resolvePathWithHome(pub.modelsCachePath)
|
|
654
|
+
if (cacheFile) {
|
|
655
|
+
await saveModelsDiskCache(cacheFile, dynamicModels)
|
|
656
|
+
}
|
|
546
657
|
}
|
|
547
658
|
|
|
548
659
|
return writeJson(res, 200, {
|
|
@@ -595,6 +706,77 @@ export function apply(ctx, config) {
|
|
|
595
706
|
}
|
|
596
707
|
},
|
|
597
708
|
}), 'dsh-clinebot: /models/toggle')
|
|
709
|
+
|
|
710
|
+
// 10. POST /dsh-clinebot/accounts/active — switch or pin active account
|
|
711
|
+
ctx.effect(() => ctx.webServer.register({
|
|
712
|
+
kind: 'exact',
|
|
713
|
+
path: '/dsh-clinebot/accounts/active',
|
|
714
|
+
handler: async (req, res) => {
|
|
715
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
716
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
717
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
const bodyBuf = await readBody(req)
|
|
721
|
+
let body = {}
|
|
722
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
|
|
723
|
+
const account = String(body.account || '').trim()
|
|
724
|
+
|
|
725
|
+
if (settingsApi?.replace) {
|
|
726
|
+
const next = Config({ ...live(), activeAccount: account })
|
|
727
|
+
await settingsApi.replace(next)
|
|
728
|
+
await syncProviderState(next)
|
|
729
|
+
writeJson(res, 200, { ok: true, activeAccount: next.activeAccount })
|
|
730
|
+
} else {
|
|
731
|
+
writeJson(res, 200, { ok: true, activeAccount: account })
|
|
732
|
+
}
|
|
733
|
+
} catch (err) {
|
|
734
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
735
|
+
}
|
|
736
|
+
},
|
|
737
|
+
}), 'dsh-clinebot: /accounts/active')
|
|
738
|
+
|
|
739
|
+
// 11. POST /dsh-clinebot/auth/begin — start loopback auth listener
|
|
740
|
+
let authSession = null
|
|
741
|
+
ctx.effect(() => ctx.webServer.register({
|
|
742
|
+
kind: 'exact',
|
|
743
|
+
path: '/dsh-clinebot/auth/begin',
|
|
744
|
+
handler: async (req, res) => {
|
|
745
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
746
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
747
|
+
return writeJson(res, 403, { ok: false, error: 'Forbidden' })
|
|
748
|
+
}
|
|
749
|
+
try {
|
|
750
|
+
const authUrl = 'https://app.cline.bot'
|
|
751
|
+
authSession = {
|
|
752
|
+
state: 'waiting',
|
|
753
|
+
startedAt: Date.now(),
|
|
754
|
+
authUrl,
|
|
755
|
+
}
|
|
756
|
+
writeJson(res, 200, {
|
|
757
|
+
ok: true,
|
|
758
|
+
status: authSession.state,
|
|
759
|
+
authUrl,
|
|
760
|
+
})
|
|
761
|
+
} catch (err) {
|
|
762
|
+
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
}), 'dsh-clinebot: /auth/begin')
|
|
766
|
+
|
|
767
|
+
// 12. GET /dsh-clinebot/auth/status — query current fast auth state
|
|
768
|
+
ctx.effect(() => ctx.webServer.register({
|
|
769
|
+
kind: 'exact',
|
|
770
|
+
path: '/dsh-clinebot/auth/status',
|
|
771
|
+
handler: async (req, res) => {
|
|
772
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
773
|
+
writeJson(res, 200, {
|
|
774
|
+
ok: true,
|
|
775
|
+
status: authSession?.state || 'idle',
|
|
776
|
+
authUrl: authSession?.authUrl || 'https://app.cline.bot',
|
|
777
|
+
})
|
|
778
|
+
},
|
|
779
|
+
}), 'dsh-clinebot: /auth/status')
|
|
598
780
|
}
|
|
599
781
|
|
|
600
782
|
// Register /cline chat slash-command if commands service is present
|
|
@@ -604,17 +786,70 @@ export function apply(ctx, config) {
|
|
|
604
786
|
|
|
605
787
|
const unregister = commands.register({
|
|
606
788
|
name: 'cline',
|
|
607
|
-
description: 'Check ClinePass subscription quota,
|
|
608
|
-
execute: async () => {
|
|
789
|
+
description: 'Check ClinePass subscription quota, models, accounts and session stats (/cline [quota|models|accounts|switch <name>])',
|
|
790
|
+
execute: async (rawArgs) => {
|
|
609
791
|
const pub = publicConfig(live())
|
|
610
|
-
const
|
|
611
|
-
|
|
792
|
+
const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
|
|
793
|
+
const param = String(rawArgs || '').trim().split(/\s+/)[1] || ''
|
|
794
|
+
|
|
795
|
+
// 1. Subcommand /cline models
|
|
796
|
+
if (subcmd === 'models') {
|
|
797
|
+
const allModels = getAllModels(pub.dynamicModels)
|
|
798
|
+
const disabledSet = new Set(pub.disabledModels || [])
|
|
799
|
+
const lines = [
|
|
800
|
+
'### 🎯 ClinePass Models Catalog',
|
|
801
|
+
`* **Всего моделей**: ${allModels.length} (${allModels.filter((m) => !disabledSet.has(m.id)).length} активно)`,
|
|
802
|
+
'',
|
|
803
|
+
]
|
|
804
|
+
for (const m of allModels) {
|
|
805
|
+
const active = !disabledSet.has(m.id) ? '✅' : '❌'
|
|
806
|
+
const isVis = isVisionModel(m.id, pub.dynamicModels) ? '📷 Vision' : '📝 Text'
|
|
807
|
+
const efforts = Array.isArray(m.reasoningEfforts) ? `🧠 [${m.reasoningEfforts.join(', ')}]` : ''
|
|
808
|
+
lines.push(`* ${active} **${m.name}** (\`${m.id}\`) — ${isVis} · ${formatModelContext(m.contextLength)} ${efforts}`)
|
|
809
|
+
}
|
|
810
|
+
return lines.join('\n')
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// 2. Subcommand /cline accounts
|
|
814
|
+
if (subcmd === 'accounts') {
|
|
815
|
+
const pool = await resolveAccountPool(ctx, live())
|
|
816
|
+
const lines = [
|
|
817
|
+
'### 🔑 ClinePass Accounts Pool',
|
|
818
|
+
`* **Активный аккаунт**: \`${pub.activeAccount || pub.apiKeyEnv}\``,
|
|
819
|
+
'',
|
|
820
|
+
]
|
|
821
|
+
for (const acc of pool) {
|
|
822
|
+
const pinBadge = acc.isPinned ? '📌 [Pinned]' : ''
|
|
823
|
+
const statusBadge = acc.present ? '✅ Configured' : '⚠️ Missing'
|
|
824
|
+
lines.push(`* **${acc.label}** (\`${acc.apiKeyEnv}\`): ${statusBadge} ${pinBadge}`)
|
|
825
|
+
}
|
|
826
|
+
lines.push('', 'Переключить активный аккаунт: `/cline switch <имя_переменной>`')
|
|
827
|
+
return lines.join('\n')
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// 3. Subcommand /cline switch <account>
|
|
831
|
+
if (subcmd === 'switch') {
|
|
832
|
+
if (!param) {
|
|
833
|
+
return '⚠️ Укажите аккаунт: `/cline switch <CLINEBOT_API_KEY_2>`'
|
|
834
|
+
}
|
|
835
|
+
if (settingsApi?.replace) {
|
|
836
|
+
const next = Config({ ...live(), activeAccount: param })
|
|
837
|
+
await settingsApi.replace(next)
|
|
838
|
+
await syncProviderState(next)
|
|
839
|
+
return `✅ Активный аккаунт переключен на \`${param}\``
|
|
840
|
+
}
|
|
841
|
+
return `⚠️ Не удалось применить настройку (сервис настроек недоступен).`
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// 4. Default /cline quota
|
|
845
|
+
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
846
|
+
if (!activeKey.value) {
|
|
612
847
|
return '⚠️ **ClineBot**: API-ключ не настроен. Откройте **Настройки → ClineBot** и сохраните ключ.'
|
|
613
848
|
}
|
|
614
849
|
|
|
615
850
|
const [health, usage] = await Promise.all([
|
|
616
851
|
probeHealth(pub.baseUrl, { timeoutMs: 5000 }),
|
|
617
|
-
fetchUsageLimits(pub.baseUrl,
|
|
852
|
+
fetchUsageLimits(pub.baseUrl, activeKey.value, { timeoutMs: 8000 }),
|
|
618
853
|
])
|
|
619
854
|
|
|
620
855
|
const fiveHour = usage?.windows?.fiveHour
|
|
@@ -625,7 +860,7 @@ export function apply(ctx, config) {
|
|
|
625
860
|
const lines = [
|
|
626
861
|
`### 🤖 ClinePass Status (${usage?.plan || 'ClinePass'})`,
|
|
627
862
|
`* **Пинг хоста**: ${health.ok ? `✅ ${health.latencyMs} мс` : '❌ Недоступен'}`,
|
|
628
|
-
`* **Активный ключ**:
|
|
863
|
+
`* **Активный ключ**: \`${activeKey.envName}\` (${activeKey.source})`,
|
|
629
864
|
`* **Модель по умолчанию**: \`${pub.defaultModel}\``,
|
|
630
865
|
'',
|
|
631
866
|
`**⏱ 5-часовое окно**: ${formatProgressBar(fiveHour?.percentUsed)} (сброс: ${reset5h})`,
|
package/lib/models.js
CHANGED
|
@@ -21,6 +21,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
21
21
|
category: 'coding',
|
|
22
22
|
recommended: true,
|
|
23
23
|
isCustom: false,
|
|
24
|
+
reasoningEfforts: ['low', 'medium', 'high'],
|
|
24
25
|
},
|
|
25
26
|
{
|
|
26
27
|
id: 'cline-pass/deepseek-v4-pro',
|
|
@@ -32,6 +33,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
32
33
|
category: 'coding',
|
|
33
34
|
recommended: true,
|
|
34
35
|
isCustom: false,
|
|
36
|
+
reasoningEfforts: ['low', 'medium', 'high', 'max'],
|
|
35
37
|
},
|
|
36
38
|
{
|
|
37
39
|
id: 'cline-pass/glm-5.2',
|
|
@@ -54,6 +56,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
54
56
|
category: 'reasoning',
|
|
55
57
|
recommended: true,
|
|
56
58
|
isCustom: false,
|
|
59
|
+
reasoningEfforts: ['low', 'high', 'max'],
|
|
57
60
|
},
|
|
58
61
|
{
|
|
59
62
|
id: 'cline-pass/kimi-k2.7-code',
|
|
@@ -109,6 +112,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
109
112
|
category: 'reasoning',
|
|
110
113
|
recommended: false,
|
|
111
114
|
isCustom: false,
|
|
115
|
+
reasoningEfforts: ['low', 'medium', 'high'],
|
|
112
116
|
},
|
|
113
117
|
{
|
|
114
118
|
id: 'cline-pass/mimo-v2.5',
|
|
@@ -229,3 +233,71 @@ export function getActiveModelIds(allModels = [], disabledModelIds = []) {
|
|
|
229
233
|
.filter((id) => Boolean(id && !disabledSet.has(id)))
|
|
230
234
|
}
|
|
231
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Format context length cleanly (e.g. 200000 -> '200K', 128000 -> '128K').
|
|
238
|
+
*/
|
|
239
|
+
export function formatModelContext(contextLength) {
|
|
240
|
+
const num = Number(contextLength) || 200000
|
|
241
|
+
if (num >= 1000000) return `${Math.round(num / 1000000)}M`
|
|
242
|
+
if (num >= 1000) return `${Math.round(num / 1000)}K`
|
|
243
|
+
return String(num)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Format model description with compact tags for DSH model picker.
|
|
248
|
+
*/
|
|
249
|
+
export function formatModelDescription(model) {
|
|
250
|
+
const parts = []
|
|
251
|
+
const ctx = formatModelContext(model.contextLength || model.contextWindow)
|
|
252
|
+
if (ctx) parts.push(ctx)
|
|
253
|
+
if (model.input?.includes('image') || model.input?.includes('vision')) {
|
|
254
|
+
parts.push('Vision')
|
|
255
|
+
}
|
|
256
|
+
if (model.category && model.category !== 'general') {
|
|
257
|
+
const cat = model.category.charAt(0).toUpperCase() + model.category.slice(1)
|
|
258
|
+
parts.push(cat)
|
|
259
|
+
}
|
|
260
|
+
const prefix = parts.length > 0 ? `[${parts.join(' · ')}] ` : ''
|
|
261
|
+
const baseDesc = model.description || model.name || model.id
|
|
262
|
+
return `${prefix}${baseDesc}`
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Check if a model supports vision input.
|
|
267
|
+
*/
|
|
268
|
+
export function isVisionModel(id, dynamicModels = []) {
|
|
269
|
+
const m = findModel(id, dynamicModels)
|
|
270
|
+
if (!m) return false
|
|
271
|
+
return Boolean(m.input?.includes('image') || m.input?.includes('vision'))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Disk caching for discovered models.
|
|
276
|
+
*/
|
|
277
|
+
export async function saveModelsDiskCache(cachePath, models = []) {
|
|
278
|
+
if (!cachePath || !Array.isArray(models) || !models.length) return false
|
|
279
|
+
try {
|
|
280
|
+
const fs = await import('node:fs/promises')
|
|
281
|
+
const path = await import('node:path')
|
|
282
|
+
const dir = path.dirname(cachePath)
|
|
283
|
+
await fs.mkdir(dir, { recursive: true })
|
|
284
|
+
const payload = JSON.stringify({ savedAt: Date.now(), models }, null, 2)
|
|
285
|
+
await fs.writeFile(cachePath, payload, 'utf8')
|
|
286
|
+
return true
|
|
287
|
+
} catch {
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function loadModelsDiskCache(cachePath) {
|
|
293
|
+
if (!cachePath) return null
|
|
294
|
+
try {
|
|
295
|
+
const fs = await import('node:fs/promises')
|
|
296
|
+
const raw = await fs.readFile(cachePath, 'utf8')
|
|
297
|
+
const parsed = JSON.parse(raw)
|
|
298
|
+
return Array.isArray(parsed?.models) ? parsed.models : null
|
|
299
|
+
} catch {
|
|
300
|
+
return null
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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",
|