@goodandready/dsh-clinebot 0.3.25 → 0.4.1
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 +32 -0
- package/README.md +21 -5
- package/README.ru.md +21 -5
- package/README.zh.md +21 -5
- package/lib/account-pool.js +59 -4
- package/lib/client.js +596 -200
- package/lib/cline-client.js +45 -45
- package/lib/config.js +25 -4
- package/lib/http.js +15 -2
- package/lib/index.js +117 -50
- package/lib/models.js +57 -25
- package/lib/provider-sync.js +60 -22
- package/lib/routes/accounts.js +178 -10
- package/lib/routes/auth.js +80 -38
- package/lib/routes/models.js +32 -29
- package/lib/routes/settings.js +83 -11
- package/lib/slash-command.js +91 -14
- package/lib/telemetry.js +78 -0
- package/package.json +4 -1
package/lib/cline-client.js
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
CLINE_MODELS,
|
|
10
9
|
DEFAULT_MODEL_ID,
|
|
11
10
|
PROVIDER_ID,
|
|
12
11
|
PROVIDER_DISPLAY_NAME,
|
|
@@ -51,46 +50,12 @@ export function normalizeBaseUrl(raw) {
|
|
|
51
50
|
return s
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
completionTokensEst: 0,
|
|
61
|
-
totalTokensEst: 0,
|
|
62
|
-
lastLatencyMs: null,
|
|
63
|
-
lastRequestAt: null,
|
|
64
|
-
lastError: null,
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function recordSessionRequest({ latencyMs, ok, error, promptTokens = 0, completionTokens = 0 }) {
|
|
68
|
-
sessionStats.totalRequests += 1
|
|
69
|
-
if (ok) {
|
|
70
|
-
sessionStats.successfulRequests += 1
|
|
71
|
-
sessionStats.lastLatencyMs = typeof latencyMs === 'number' ? latencyMs : null
|
|
72
|
-
sessionStats.lastError = null
|
|
73
|
-
} else {
|
|
74
|
-
sessionStats.failedRequests += 1
|
|
75
|
-
sessionStats.lastError = error || 'Request failed'
|
|
76
|
-
}
|
|
77
|
-
sessionStats.lastRequestAt = Date.now()
|
|
78
|
-
sessionStats.promptTokensEst += Number(promptTokens) || 0
|
|
79
|
-
sessionStats.completionTokensEst += Number(completionTokens) || 0
|
|
80
|
-
sessionStats.totalTokensEst += (Number(promptTokens) || 0) + (Number(completionTokens) || 0)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function resetSessionStats() {
|
|
84
|
-
sessionStats.totalRequests = 0
|
|
85
|
-
sessionStats.successfulRequests = 0
|
|
86
|
-
sessionStats.failedRequests = 0
|
|
87
|
-
sessionStats.promptTokensEst = 0
|
|
88
|
-
sessionStats.completionTokensEst = 0
|
|
89
|
-
sessionStats.totalTokensEst = 0
|
|
90
|
-
sessionStats.lastLatencyMs = null
|
|
91
|
-
sessionStats.lastRequestAt = null
|
|
92
|
-
sessionStats.lastError = null
|
|
93
|
-
}
|
|
53
|
+
export {
|
|
54
|
+
sessionStats,
|
|
55
|
+
recordSessionRequest,
|
|
56
|
+
recordSmokeTest,
|
|
57
|
+
resetSessionStats,
|
|
58
|
+
} from './telemetry.js'
|
|
94
59
|
|
|
95
60
|
/**
|
|
96
61
|
* Resolve API key from environment variables.
|
|
@@ -124,6 +89,26 @@ export async function saveCredentialKey(ctx, apiKeyEnv, apiKey) {
|
|
|
124
89
|
return { ok: true, envName: name }
|
|
125
90
|
}
|
|
126
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Delete API key from DSH credentials service.
|
|
94
|
+
*/
|
|
95
|
+
export async function deleteCredentialKey(ctx, apiKeyEnv) {
|
|
96
|
+
const name = String(apiKeyEnv || '').trim()
|
|
97
|
+
if (!name) return { ok: false }
|
|
98
|
+
const credentials = ctx?.credentials || ctx?.get?.('credentials')
|
|
99
|
+
if (!credentials) return { ok: false }
|
|
100
|
+
try {
|
|
101
|
+
const ref = await toCredentialRef(name)
|
|
102
|
+
if (typeof credentials.unset === 'function') {
|
|
103
|
+
await credentials.unset(ref)
|
|
104
|
+
return { ok: true, envName: name }
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn('[dsh-clinebot] Failed unsetting credential:', err)
|
|
108
|
+
}
|
|
109
|
+
return { ok: false }
|
|
110
|
+
}
|
|
111
|
+
|
|
127
112
|
function abortAfter(ms) {
|
|
128
113
|
const ac = new AbortController()
|
|
129
114
|
const timer = setTimeout(() => ac.abort(), Math.max(1, Number(ms) || DEFAULT_TIMEOUT_MS))
|
|
@@ -279,7 +264,7 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
|
|
|
279
264
|
|
|
280
265
|
let userEmail = null
|
|
281
266
|
let createdAt = null
|
|
282
|
-
let planDisplayName = 'ClinePass
|
|
267
|
+
let planDisplayName = 'ClinePass'
|
|
283
268
|
let dynamicModels = []
|
|
284
269
|
|
|
285
270
|
try {
|
|
@@ -297,8 +282,14 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
|
|
|
297
282
|
if (planRes?.ok) {
|
|
298
283
|
const planData = await planRes.json().catch(() => ({}))
|
|
299
284
|
const plan = planData?.data?.plan || planData?.data || planData?.plan || planData
|
|
300
|
-
|
|
301
|
-
|
|
285
|
+
const name = plan?.displayName || plan?.title || plan?.name || 'ClinePass'
|
|
286
|
+
const priceCents = typeof plan?.pricePerSeatCents === 'number'
|
|
287
|
+
? plan.pricePerSeatCents
|
|
288
|
+
: (typeof plan?.priceInCents === 'number' ? plan.priceInCents : null)
|
|
289
|
+
if (priceCents !== null) {
|
|
290
|
+
planDisplayName = `${name} ($${(priceCents / 100).toFixed(2)}/mo)`
|
|
291
|
+
} else {
|
|
292
|
+
planDisplayName = name
|
|
302
293
|
}
|
|
303
294
|
const featuresIncluded = plan?.features?.included || plan?.includedModels
|
|
304
295
|
if (featuresIncluded) {
|
|
@@ -434,6 +425,15 @@ export async function smokeChat(baseUrl, apiKey, {
|
|
|
434
425
|
fetchImpl = fetch,
|
|
435
426
|
} = {}) {
|
|
436
427
|
const base = normalizeBaseUrl(baseUrl)
|
|
428
|
+
try {
|
|
429
|
+
const parsed = new URL(base)
|
|
430
|
+
if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
|
|
431
|
+
return { ok: false, error: 'Insecure protocol: smokeChat requires https: for remote endpoints' }
|
|
432
|
+
}
|
|
433
|
+
} catch (err) {
|
|
434
|
+
return { ok: false, error: `Invalid baseUrl: ${err?.message || err}` }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
437
|
if (!apiKey) {
|
|
438
438
|
return { ok: false, error: 'Missing API key. Set credential or environment variable.' }
|
|
439
439
|
}
|
|
@@ -587,4 +587,4 @@ export async function resolveKeyValue(ctx, apiKeyEnv) {
|
|
|
587
587
|
}
|
|
588
588
|
|
|
589
589
|
|
|
590
|
-
export { resolveAccountPool, isAccountQuotaExhausted, rotateToNextAccount } from './account-pool.js'
|
|
590
|
+
export { resolveAccountPool, isAccountQuotaExhausted, rotateToNextAccount, getLastRotation, setLastRotation, isQuotaExceededError } from './account-pool.js'
|
package/lib/config.js
CHANGED
|
@@ -51,6 +51,8 @@ export const Config = z.object({
|
|
|
51
51
|
reasoningEfforts: z.any().default(undefined),
|
|
52
52
|
})).default([])
|
|
53
53
|
.description('Models automatically discovered from the official ClinePass subscription plan.'),
|
|
54
|
+
planSyncedAt: z.number().default(0)
|
|
55
|
+
.description('Timestamp when models were last synchronized with active subscription plan.').volatile(),
|
|
54
56
|
timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
|
|
55
57
|
.description('HTTP probe timeout in milliseconds.').volatile(),
|
|
56
58
|
smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
|
|
@@ -99,22 +101,41 @@ export function volatileConfig(cfg) {
|
|
|
99
101
|
export function publicConfig(cfg) {
|
|
100
102
|
cfg = plainConfig(cfg)
|
|
101
103
|
const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
|
|
102
|
-
const
|
|
104
|
+
const isSynced = dynamic.length > 0
|
|
105
|
+
const allModels = getAllModels(dynamic)
|
|
106
|
+
const allDefaultIds = allModels.map((m) => m.id)
|
|
103
107
|
|
|
104
108
|
let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels : []
|
|
105
|
-
if (
|
|
109
|
+
if (cfg?.disabledModels === undefined && Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length > 0) {
|
|
106
110
|
const enabledSet = new Set(cfg.enabledModels)
|
|
107
111
|
disabledList = allDefaultIds.filter((id) => !enabledSet.has(id))
|
|
108
112
|
}
|
|
109
113
|
|
|
110
|
-
|
|
114
|
+
let activeIds = getActiveModelIds(allDefaultIds, disabledList)
|
|
115
|
+
if (!activeIds.length && allDefaultIds.length) {
|
|
116
|
+
activeIds = [allDefaultIds[0]]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let defaultModel = cfg?.defaultModel || DEFAULT_MODEL_ID
|
|
120
|
+
let defaultModelWarning = null
|
|
121
|
+
|
|
122
|
+
if (allDefaultIds.length > 0 && !allDefaultIds.includes(defaultModel)) {
|
|
123
|
+
const fallbackModel = activeIds[0] || allDefaultIds[0]
|
|
124
|
+
defaultModelWarning = `Configured default model "${defaultModel}" is not available in your active plan. Using "${fallbackModel}" instead.`
|
|
125
|
+
defaultModel = fallbackModel
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const planSyncedAt = Number(cfg?.planSyncedAt) || (isSynced ? (dynamic.planSyncedAt || 0) : 0)
|
|
111
129
|
|
|
112
130
|
return {
|
|
113
131
|
enabled: !!cfg?.enabled,
|
|
114
132
|
baseUrl: normalizeBaseUrl(cfg?.baseUrl),
|
|
115
133
|
apiKeyEnv: cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV,
|
|
116
|
-
defaultModel
|
|
134
|
+
defaultModel,
|
|
135
|
+
defaultModelWarning,
|
|
117
136
|
dynamicModels: dynamic,
|
|
137
|
+
planSynced: isSynced,
|
|
138
|
+
planSyncedAt,
|
|
118
139
|
disabledModels: disabledList,
|
|
119
140
|
enabledModels: activeIds,
|
|
120
141
|
timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
|
package/lib/http.js
CHANGED
|
@@ -10,7 +10,20 @@ export function writeJson(res, code, body) {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function readBody(req, maxBytes = 256 * 1024) {
|
|
13
|
+
export async function readBody(req, maxBytes = 256 * 1024) {
|
|
14
|
+
if (typeof req?.[Symbol.asyncIterator] === 'function') {
|
|
15
|
+
const chunks = []
|
|
16
|
+
let size = 0
|
|
17
|
+
for await (const chunk of req) {
|
|
18
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
19
|
+
size += buf.length
|
|
20
|
+
if (size > maxBytes) {
|
|
21
|
+
throw new Error('body too large')
|
|
22
|
+
}
|
|
23
|
+
chunks.push(buf)
|
|
24
|
+
}
|
|
25
|
+
return Buffer.concat(chunks)
|
|
26
|
+
}
|
|
14
27
|
return new Promise((resolve, reject) => {
|
|
15
28
|
const chunks = []
|
|
16
29
|
let size = 0
|
|
@@ -18,7 +31,7 @@ export function readBody(req, maxBytes = 256 * 1024) {
|
|
|
18
31
|
size += c.length
|
|
19
32
|
if (size > maxBytes) {
|
|
20
33
|
reject(new Error('body too large'))
|
|
21
|
-
req.destroy()
|
|
34
|
+
req.destroy?.()
|
|
22
35
|
return
|
|
23
36
|
}
|
|
24
37
|
chunks.push(c)
|
package/lib/index.js
CHANGED
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
resolveKeyValue,
|
|
9
9
|
smokeChat,
|
|
10
10
|
fetchUsageLimits,
|
|
11
|
+
isQuotaExceededError,
|
|
11
12
|
} from './cline-client.js'
|
|
13
|
+
import { PROVIDER_ID } from './models.js'
|
|
12
14
|
import {
|
|
13
15
|
checkRegisteredInPiAi,
|
|
14
16
|
upsertPiAiProvider,
|
|
@@ -38,13 +40,11 @@ export function apply(ctx, config) {
|
|
|
38
40
|
try {
|
|
39
41
|
const pub = publicConfig(cfg)
|
|
40
42
|
if (!pub.enabled) {
|
|
41
|
-
|
|
42
|
-
await removePiAiProvider(ctx)
|
|
43
|
-
}
|
|
43
|
+
await removePiAiProvider(ctx)
|
|
44
44
|
return
|
|
45
45
|
}
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
46
|
+
const activeKey = await resolveActiveAccountKey(ctx, cfg)
|
|
47
|
+
if (activeKey.value) {
|
|
48
48
|
await upsertPiAiProvider(ctx, cfg, pub.enabledModels)
|
|
49
49
|
}
|
|
50
50
|
} catch {
|
|
@@ -116,20 +116,6 @@ export function apply(ctx, config) {
|
|
|
116
116
|
settingsApi = undefined
|
|
117
117
|
})
|
|
118
118
|
})
|
|
119
|
-
} else {
|
|
120
|
-
const settingsService = (ctx?.get && ctx.get('settings')) || ctx?.settings
|
|
121
|
-
if (typeof settingsService?.register === 'function') {
|
|
122
|
-
const scope = settingsService.register(NS, Config, { base: config })
|
|
123
|
-
settingsApi = scope
|
|
124
|
-
getConfig = () => (scope?.get?.() ?? config) ?? config
|
|
125
|
-
if (typeof ctx.effect === 'function') {
|
|
126
|
-
ctx.effect(() => scope.watch((next) => {
|
|
127
|
-
syncProviderState(live())
|
|
128
|
-
}), 'dsh-clinebot: settings')
|
|
129
|
-
}
|
|
130
|
-
} else {
|
|
131
|
-
settingsApi = createSettingsAdapter(settingsService)
|
|
132
|
-
}
|
|
133
119
|
}
|
|
134
120
|
|
|
135
121
|
syncProviderState(live())
|
|
@@ -138,8 +124,6 @@ export function apply(ctx, config) {
|
|
|
138
124
|
const timer = setTimeout(triggerAutoDiscover, 500)
|
|
139
125
|
return () => clearTimeout(timer)
|
|
140
126
|
}, 'dsh-clinebot: auto-discover')
|
|
141
|
-
} else {
|
|
142
|
-
setTimeout(triggerAutoDiscover, 500)
|
|
143
127
|
}
|
|
144
128
|
|
|
145
129
|
if (ctx.webServer?.register) {
|
|
@@ -171,35 +155,118 @@ export function apply(ctx, config) {
|
|
|
171
155
|
syncProviderState,
|
|
172
156
|
})
|
|
173
157
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
188
|
-
const res = await smokeChat(pub.baseUrl, activeKey.value, { model: model || pub.defaultModel })
|
|
189
|
-
recordSessionRequest({
|
|
190
|
-
latencyMs: res.latencyMs,
|
|
191
|
-
ok: res.ok,
|
|
192
|
-
error: res.error,
|
|
193
|
-
promptTokens: res.promptTokens || 5,
|
|
194
|
-
completionTokens: res.completionTokens || 10,
|
|
195
|
-
})
|
|
196
|
-
if (res.status === 429) {
|
|
197
|
-
const failover = await rotateToNextAccount(ctx, live(), 'service_429', settingsApi)
|
|
198
|
-
if (failover.rotated) {
|
|
199
|
-
await syncProviderState(live())
|
|
200
|
-
}
|
|
158
|
+
let lastFailoverAt = 0
|
|
159
|
+
|
|
160
|
+
const handleStream429 = async () => {
|
|
161
|
+
const now = Date.now()
|
|
162
|
+
if (now - lastFailoverAt < 30000) {
|
|
163
|
+
ctx?.logger?.warn?.('[dsh-clinebot] Suppressing rapid failover rotation within 30s storm window')
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
lastFailoverAt = now
|
|
167
|
+
try {
|
|
168
|
+
const failover = await rotateToNextAccount(ctx, live(), 'stream_429', settingsApi)
|
|
169
|
+
if (failover?.rotated) {
|
|
170
|
+
await syncProviderState(live())
|
|
201
171
|
}
|
|
202
|
-
|
|
203
|
-
|
|
172
|
+
} catch (err) {
|
|
173
|
+
ctx?.logger?.warn?.('[dsh-clinebot] Auto-failover rotation failed: ' + (err?.message || err))
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (typeof ctx.on === 'function') {
|
|
178
|
+
const unlisten = ctx.on('llm/stream', (options, next) => {
|
|
179
|
+
const isCline = options?.provider === PROVIDER_ID
|
|
180
|
+
const stream = next()
|
|
181
|
+
const startTime = Date.now()
|
|
182
|
+
let promptTokens = 0
|
|
183
|
+
let completionTokens = 0
|
|
184
|
+
let finished = false
|
|
185
|
+
|
|
186
|
+
return (async function* () {
|
|
187
|
+
try {
|
|
188
|
+
for await (const chunk of stream) {
|
|
189
|
+
if (isCline && chunk?.type === 'usage' && chunk?.usage) {
|
|
190
|
+
const u = chunk.usage
|
|
191
|
+
promptTokens += (Number(u.inputTokens) || 0) + (Number(u.cacheReadTokens) || 0) + (Number(u.cacheWriteTokens) || 0)
|
|
192
|
+
completionTokens += Number(u.outputTokens) || 0
|
|
193
|
+
}
|
|
194
|
+
if (isCline && chunk?.type === 'finish' && chunk?.reason) {
|
|
195
|
+
finished = true
|
|
196
|
+
const latencyMs = Date.now() - startTime
|
|
197
|
+
const reason = chunk.reason
|
|
198
|
+
const failure = reason.failure || {}
|
|
199
|
+
const isError = reason.kind === 'error'
|
|
200
|
+
const isAborted = reason.kind === 'aborted'
|
|
201
|
+
const is429 = failure.status === 429 || failure.code === 429 || failure.code === 'rate_limit_exceeded'
|
|
202
|
+
const isExceeded = isQuotaExceededError(`${failure.message || ''} ${failure.code || ''}`)
|
|
203
|
+
|
|
204
|
+
if (isError) {
|
|
205
|
+
recordSessionRequest({
|
|
206
|
+
latencyMs,
|
|
207
|
+
ok: false,
|
|
208
|
+
error: failure.message || 'Stream error',
|
|
209
|
+
promptTokens,
|
|
210
|
+
completionTokens,
|
|
211
|
+
model: options?.model,
|
|
212
|
+
})
|
|
213
|
+
if (is429 || isExceeded) {
|
|
214
|
+
handleStream429()
|
|
215
|
+
}
|
|
216
|
+
} else if (isAborted) {
|
|
217
|
+
recordSessionRequest({
|
|
218
|
+
latencyMs,
|
|
219
|
+
ok: true,
|
|
220
|
+
aborted: true,
|
|
221
|
+
promptTokens,
|
|
222
|
+
completionTokens,
|
|
223
|
+
model: options?.model,
|
|
224
|
+
})
|
|
225
|
+
} else {
|
|
226
|
+
recordSessionRequest({
|
|
227
|
+
latencyMs,
|
|
228
|
+
ok: true,
|
|
229
|
+
promptTokens,
|
|
230
|
+
completionTokens,
|
|
231
|
+
model: options?.model,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
yield chunk
|
|
236
|
+
}
|
|
237
|
+
if (isCline && !finished) {
|
|
238
|
+
recordSessionRequest({
|
|
239
|
+
latencyMs: Date.now() - startTime,
|
|
240
|
+
ok: true,
|
|
241
|
+
promptTokens,
|
|
242
|
+
completionTokens,
|
|
243
|
+
model: options?.model,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (isCline) {
|
|
248
|
+
const latencyMs = Date.now() - startTime
|
|
249
|
+
const status = err?.status || err?.statusCode
|
|
250
|
+
const msg = String(err?.message || err)
|
|
251
|
+
recordSessionRequest({
|
|
252
|
+
latencyMs,
|
|
253
|
+
ok: false,
|
|
254
|
+
error: msg,
|
|
255
|
+
promptTokens,
|
|
256
|
+
completionTokens,
|
|
257
|
+
model: options?.model,
|
|
258
|
+
})
|
|
259
|
+
if (status === 429 || isQuotaExceededError(msg)) {
|
|
260
|
+
handleStream429()
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
throw err
|
|
264
|
+
}
|
|
265
|
+
})()
|
|
266
|
+
}, { global: true, prepend: true })
|
|
267
|
+
|
|
268
|
+
if (typeof ctx.effect === 'function') {
|
|
269
|
+
ctx.effect(() => () => unlisten?.(), 'dsh-clinebot: llm/stream failover')
|
|
270
|
+
}
|
|
204
271
|
}
|
|
205
272
|
}
|
package/lib/models.js
CHANGED
|
@@ -18,7 +18,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
18
18
|
id: 'cline-pass/deepseek-v4-flash',
|
|
19
19
|
name: 'DeepSeek V4 Flash',
|
|
20
20
|
description: 'High-speed reasoning & code completion model optimized for agentic loops.',
|
|
21
|
-
contextLength:
|
|
21
|
+
contextLength: 128000,
|
|
22
22
|
maxTokens: 8192,
|
|
23
23
|
input: ['text', 'image'],
|
|
24
24
|
category: 'coding',
|
|
@@ -30,7 +30,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
30
30
|
id: 'cline-pass/deepseek-v4-pro',
|
|
31
31
|
name: 'DeepSeek V4 Pro',
|
|
32
32
|
description: 'Flagship reasoning and multi-turn architectural coding model.',
|
|
33
|
-
contextLength:
|
|
33
|
+
contextLength: 128000,
|
|
34
34
|
maxTokens: 8192,
|
|
35
35
|
input: ['text', 'image'],
|
|
36
36
|
category: 'coding',
|
|
@@ -42,7 +42,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
42
42
|
id: 'cline-pass/glm-5.2',
|
|
43
43
|
name: 'GLM 5.2',
|
|
44
44
|
description: 'Bilingual general & coding model with strong instruction following.',
|
|
45
|
-
contextLength:
|
|
45
|
+
contextLength: 128000,
|
|
46
46
|
maxTokens: 8192,
|
|
47
47
|
input: ['text'],
|
|
48
48
|
category: 'general',
|
|
@@ -88,7 +88,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
88
88
|
id: 'cline-pass/qwen3.7-max',
|
|
89
89
|
name: 'Qwen 3.7 Max',
|
|
90
90
|
description: 'Large-scale multimodal foundation model from Alibaba Cloud with reasoning support.',
|
|
91
|
-
contextLength:
|
|
91
|
+
contextLength: 128000,
|
|
92
92
|
maxTokens: 8192,
|
|
93
93
|
input: ['text', 'image'],
|
|
94
94
|
category: 'multimodal',
|
|
@@ -100,7 +100,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
100
100
|
id: 'cline-pass/qwen3.7-plus',
|
|
101
101
|
name: 'Qwen 3.7 Plus',
|
|
102
102
|
description: 'Fast, capable multimodal model with strong multilingual skills and reasoning.',
|
|
103
|
-
contextLength:
|
|
103
|
+
contextLength: 128000,
|
|
104
104
|
maxTokens: 8192,
|
|
105
105
|
input: ['text', 'image'],
|
|
106
106
|
category: 'multimodal',
|
|
@@ -124,7 +124,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
124
124
|
id: 'cline-pass/mimo-v2.5',
|
|
125
125
|
name: 'MiMo V2.5',
|
|
126
126
|
description: 'Xiaomi MiMo efficient instruction model with reasoning and multimodal support.',
|
|
127
|
-
contextLength:
|
|
127
|
+
contextLength: 128000,
|
|
128
128
|
maxTokens: 8192,
|
|
129
129
|
input: ['text', 'image'],
|
|
130
130
|
category: 'general',
|
|
@@ -136,7 +136,7 @@ export const CLINE_MODELS = Object.freeze([
|
|
|
136
136
|
id: 'cline-pass/mimo-v2.5-pro',
|
|
137
137
|
name: 'MiMo V2.5 Pro',
|
|
138
138
|
description: 'Xiaomi MiMo advanced agentic reasoning model with multimodal support.',
|
|
139
|
-
contextLength:
|
|
139
|
+
contextLength: 128000,
|
|
140
140
|
maxTokens: 8192,
|
|
141
141
|
input: ['text', 'image'],
|
|
142
142
|
category: 'coding',
|
|
@@ -168,12 +168,12 @@ export function parsePlanIncludedModels(includedInput) {
|
|
|
168
168
|
const clean = includedText
|
|
169
169
|
.replace(/^includes\s+/i, '')
|
|
170
170
|
.replace(/\band\b/gi, ',')
|
|
171
|
-
.replace(
|
|
171
|
+
.replace(/[.;]+$/g, '')
|
|
172
172
|
.trim()
|
|
173
173
|
|
|
174
174
|
const parts = clean
|
|
175
175
|
.split(',')
|
|
176
|
-
.map((p) => p.trim())
|
|
176
|
+
.map((p) => p.trim().replace(/[.;]+$/g, '').trim())
|
|
177
177
|
.filter(Boolean)
|
|
178
178
|
|
|
179
179
|
const normalize = (s) =>
|
|
@@ -193,7 +193,14 @@ export function parsePlanIncludedModels(includedInput) {
|
|
|
193
193
|
matched.push(found)
|
|
194
194
|
} else {
|
|
195
195
|
// Dynamic fallback for newly introduced models mentioned in plan
|
|
196
|
-
const idPart = name
|
|
196
|
+
const idPart = name
|
|
197
|
+
.toLowerCase()
|
|
198
|
+
.trim()
|
|
199
|
+
.replace(/^cline-pass\//, '')
|
|
200
|
+
.replace(/qwen\s+([0-9])/i, 'qwen$1')
|
|
201
|
+
.replace(/glm\s+([0-9])/i, 'glm-$1')
|
|
202
|
+
.replace(/[\s_]+/g, '-')
|
|
203
|
+
.replace(/-+/g, '-')
|
|
197
204
|
const isVision = /(vision|vl|multimodal|omni|image)/i.test(name)
|
|
198
205
|
const hasReasoning = /(reason|think|r1|pro|flash|max|plus|k3|m3|glm|qwen|mimo)/i.test(name)
|
|
199
206
|
matched.push({
|
|
@@ -215,27 +222,49 @@ export function parsePlanIncludedModels(includedInput) {
|
|
|
215
222
|
}
|
|
216
223
|
|
|
217
224
|
/**
|
|
218
|
-
* Get all models
|
|
225
|
+
* Get all models:
|
|
226
|
+
* 1. When plan is synchronized (dynamicModels is a non-empty array):
|
|
227
|
+
* Registers strictly the models included in the active plan, enriching them
|
|
228
|
+
* with curated capabilities, reasoning efforts, and descriptions from CLINE_MODELS.
|
|
229
|
+
* 2. When plan is unsynced (dynamicModels is empty):
|
|
230
|
+
* Falls back to the full curated CLINE_MODELS catalog marked with unverified: true.
|
|
219
231
|
*/
|
|
220
232
|
export function getAllModels(dynamicModels = []) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
233
|
+
if (Array.isArray(dynamicModels) && dynamicModels.length > 0) {
|
|
234
|
+
const normalize = (s) => String(s || '').toLowerCase().replace(/[\s._-]+/g, '')
|
|
235
|
+
return dynamicModels.map((dm) => {
|
|
236
|
+
const targetNorm = normalize(dm.id.replace(/^cline-pass\//, ''))
|
|
237
|
+
const curated = CLINE_MODELS.find(
|
|
238
|
+
(cm) => cm.id === dm.id || normalize(cm.id.replace(/^cline-pass\//, '')) === targetNorm || normalize(cm.name) === targetNorm
|
|
239
|
+
)
|
|
240
|
+
if (curated) {
|
|
241
|
+
return {
|
|
242
|
+
...curated,
|
|
243
|
+
...dm,
|
|
244
|
+
description: dm.description || curated.description,
|
|
245
|
+
contextLength: dm.contextLength || curated.contextLength,
|
|
246
|
+
maxTokens: dm.maxTokens || curated.maxTokens,
|
|
247
|
+
input: dm.input && dm.input.length ? dm.input : curated.input,
|
|
248
|
+
category: dm.category || curated.category,
|
|
249
|
+
reasoningEfforts: dm.reasoningEfforts !== undefined ? dm.reasoningEfforts : curated.reasoningEfforts,
|
|
250
|
+
unverified: false,
|
|
251
|
+
}
|
|
229
252
|
}
|
|
230
|
-
|
|
253
|
+
return {
|
|
254
|
+
...dm,
|
|
255
|
+
unverified: false,
|
|
256
|
+
}
|
|
257
|
+
})
|
|
231
258
|
}
|
|
232
259
|
|
|
233
|
-
return
|
|
260
|
+
return CLINE_MODELS.map((m) => ({ ...m, unverified: true }))
|
|
234
261
|
}
|
|
235
262
|
|
|
236
263
|
export function findModel(id, dynamicModels = []) {
|
|
237
264
|
const all = getAllModels(dynamicModels)
|
|
238
|
-
|
|
265
|
+
const found = all.find((m) => m.id === id)
|
|
266
|
+
if (found) return found
|
|
267
|
+
return CLINE_MODELS.find((m) => m.id === id) || null
|
|
239
268
|
}
|
|
240
269
|
|
|
241
270
|
export function isSupportedModel(id, dynamicModels = []) {
|
|
@@ -298,14 +327,14 @@ export function isVisionModel(id, dynamicModels = []) {
|
|
|
298
327
|
/**
|
|
299
328
|
* Disk caching for discovered models.
|
|
300
329
|
*/
|
|
301
|
-
export async function saveModelsDiskCache(cachePath, models = []) {
|
|
330
|
+
export async function saveModelsDiskCache(cachePath, models = [], planSyncedAt = Date.now()) {
|
|
302
331
|
if (!cachePath || !Array.isArray(models) || !models.length) return false
|
|
303
332
|
try {
|
|
304
333
|
const fs = await import('node:fs/promises')
|
|
305
334
|
const path = await import('node:path')
|
|
306
335
|
const dir = path.dirname(cachePath)
|
|
307
336
|
await fs.mkdir(dir, { recursive: true })
|
|
308
|
-
const payload = JSON.stringify({ savedAt: Date.now(), models }, null, 2)
|
|
337
|
+
const payload = JSON.stringify({ savedAt: Date.now(), planSyncedAt, models }, null, 2)
|
|
309
338
|
await fs.writeFile(cachePath, payload, 'utf8')
|
|
310
339
|
return true
|
|
311
340
|
} catch {
|
|
@@ -319,7 +348,10 @@ export async function loadModelsDiskCache(cachePath) {
|
|
|
319
348
|
const fs = await import('node:fs/promises')
|
|
320
349
|
const raw = await fs.readFile(cachePath, 'utf8')
|
|
321
350
|
const parsed = JSON.parse(raw)
|
|
322
|
-
|
|
351
|
+
const list = Array.isArray(parsed?.models) ? parsed.models : (Array.isArray(parsed) ? parsed : null)
|
|
352
|
+
if (!list) return null
|
|
353
|
+
list.planSyncedAt = typeof parsed?.planSyncedAt === 'number' ? parsed.planSyncedAt : (typeof parsed?.savedAt === 'number' ? parsed.savedAt : 0)
|
|
354
|
+
return list
|
|
323
355
|
} catch {
|
|
324
356
|
return null
|
|
325
357
|
}
|