@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.
@@ -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
- // In-memory runtime session metrics for ClinePass requests
55
- export const sessionStats = {
56
- totalRequests: 0,
57
- successfulRequests: 0,
58
- failedRequests: 0,
59
- promptTokensEst: 0,
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 ($9.99/mo)'
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
- if (plan?.displayName || plan?.title || plan?.name) {
301
- planDisplayName = `${plan.displayName || plan.title || plan.name} ($${((plan.pricePerSeatCents || plan.priceInCents || 999) / 100).toFixed(2)}/mo)`
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 allDefaultIds = getDefaultModelIds(dynamic)
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 (!Array.isArray(cfg?.disabledModels) || (cfg.disabledModels.length === 0 && Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length > 0)) {
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
- const activeIds = getActiveModelIds(allDefaultIds, disabledList)
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: cfg?.defaultModel || DEFAULT_MODEL_ID,
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
- if (await checkRegisteredInPiAi(ctx)) {
42
- await removePiAiProvider(ctx)
43
- }
43
+ await removePiAiProvider(ctx)
44
44
  return
45
45
  }
46
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
47
- if (key.value) {
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
- return {
175
- getStatus: () => buildStatus(ctx, live()),
176
- registerProvider: (models) => upsertPiAiProvider(ctx, live(), models),
177
- unregisterProvider: () => removePiAiProvider(ctx),
178
- recordRequestMetrics: (metrics) => recordSessionRequest(metrics),
179
- getSessionStats: () => ({ ...sessionStats }),
180
- getUsageLimits: async () => {
181
- const pub = publicConfig(live())
182
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
183
- return fetchUsageLimits(pub.baseUrl, key.value)
184
- },
185
- runSmokeTest: async (model) => {
186
- const pub = publicConfig(live())
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
- return res
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: 200000,
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: 200000,
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: 200000,
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: 200000,
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: 200000,
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: 200000,
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: 200000,
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(/\./g, '')
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.toLowerCase().replace(/[\s_]+/g, '-')
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 from known catalog.
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
- const result = [...CLINE_MODELS]
222
- const seenIds = new Set(result.map((m) => m.id))
223
-
224
- if (Array.isArray(dynamicModels)) {
225
- for (const item of dynamicModels) {
226
- if (item && item.id && !seenIds.has(item.id)) {
227
- result.push(item)
228
- seenIds.add(item.id)
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 result
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
- return all.find((m) => m.id === id) || null
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
- return Array.isArray(parsed?.models) ? parsed.models : null
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
  }