@goodandready/dsh-clinebot 0.3.25 → 0.4.0

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/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,
@@ -43,8 +45,8 @@ export function apply(ctx, config) {
43
45
  }
44
46
  return
45
47
  }
46
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
47
- if (key.value) {
48
+ const activeKey = await resolveActiveAccountKey(ctx, cfg)
49
+ if (activeKey.value) {
48
50
  await upsertPiAiProvider(ctx, cfg, pub.enabledModels)
49
51
  }
50
52
  } catch {
@@ -116,20 +118,6 @@ export function apply(ctx, config) {
116
118
  settingsApi = undefined
117
119
  })
118
120
  })
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
121
  }
134
122
 
135
123
  syncProviderState(live())
@@ -138,8 +126,6 @@ export function apply(ctx, config) {
138
126
  const timer = setTimeout(triggerAutoDiscover, 500)
139
127
  return () => clearTimeout(timer)
140
128
  }, 'dsh-clinebot: auto-discover')
141
- } else {
142
- setTimeout(triggerAutoDiscover, 500)
143
129
  }
144
130
 
145
131
  if (ctx.webServer?.register) {
@@ -171,35 +157,118 @@ export function apply(ctx, config) {
171
157
  syncProviderState,
172
158
  })
173
159
 
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
- }
160
+ let lastFailoverAt = 0
161
+
162
+ const handleStream429 = async () => {
163
+ const now = Date.now()
164
+ if (now - lastFailoverAt < 30000) {
165
+ ctx?.logger?.warn?.('[dsh-clinebot] Suppressing rapid failover rotation within 30s storm window')
166
+ return
167
+ }
168
+ lastFailoverAt = now
169
+ try {
170
+ const failover = await rotateToNextAccount(ctx, live(), 'stream_429', settingsApi)
171
+ if (failover?.rotated) {
172
+ await syncProviderState(live())
201
173
  }
202
- return res
203
- },
174
+ } catch (err) {
175
+ ctx?.logger?.warn?.('[dsh-clinebot] Auto-failover rotation failed: ' + (err?.message || err))
176
+ }
177
+ }
178
+
179
+ if (typeof ctx.on === 'function') {
180
+ const unlisten = ctx.on('llm/stream', (options, next) => {
181
+ const isCline = options?.provider === PROVIDER_ID
182
+ const stream = next()
183
+ const startTime = Date.now()
184
+ let promptTokens = 0
185
+ let completionTokens = 0
186
+ let finished = false
187
+
188
+ return (async function* () {
189
+ try {
190
+ for await (const chunk of stream) {
191
+ if (isCline && chunk?.type === 'usage' && chunk?.usage) {
192
+ const u = chunk.usage
193
+ promptTokens += (Number(u.inputTokens) || 0) + (Number(u.cacheReadTokens) || 0) + (Number(u.cacheWriteTokens) || 0)
194
+ completionTokens += Number(u.outputTokens) || 0
195
+ }
196
+ if (isCline && chunk?.type === 'finish' && chunk?.reason) {
197
+ finished = true
198
+ const latencyMs = Date.now() - startTime
199
+ const reason = chunk.reason
200
+ const failure = reason.failure || {}
201
+ const isError = reason.kind === 'error'
202
+ const isAborted = reason.kind === 'aborted'
203
+ const is429 = failure.status === 429 || failure.code === 429 || failure.code === 'rate_limit_exceeded'
204
+ const isExceeded = isQuotaExceededError(`${failure.message || ''} ${failure.code || ''}`)
205
+
206
+ if (isError) {
207
+ recordSessionRequest({
208
+ latencyMs,
209
+ ok: false,
210
+ error: failure.message || 'Stream error',
211
+ promptTokens,
212
+ completionTokens,
213
+ model: options?.model,
214
+ })
215
+ if (is429 || isExceeded) {
216
+ handleStream429()
217
+ }
218
+ } else if (isAborted) {
219
+ recordSessionRequest({
220
+ latencyMs,
221
+ ok: true,
222
+ aborted: true,
223
+ promptTokens,
224
+ completionTokens,
225
+ model: options?.model,
226
+ })
227
+ } else {
228
+ recordSessionRequest({
229
+ latencyMs,
230
+ ok: true,
231
+ promptTokens,
232
+ completionTokens,
233
+ model: options?.model,
234
+ })
235
+ }
236
+ }
237
+ yield chunk
238
+ }
239
+ if (isCline && !finished) {
240
+ recordSessionRequest({
241
+ latencyMs: Date.now() - startTime,
242
+ ok: true,
243
+ promptTokens,
244
+ completionTokens,
245
+ model: options?.model,
246
+ })
247
+ }
248
+ } catch (err) {
249
+ if (isCline) {
250
+ const latencyMs = Date.now() - startTime
251
+ const status = err?.status || err?.statusCode
252
+ const msg = String(err?.message || err)
253
+ recordSessionRequest({
254
+ latencyMs,
255
+ ok: false,
256
+ error: msg,
257
+ promptTokens,
258
+ completionTokens,
259
+ model: options?.model,
260
+ })
261
+ if (status === 429 || isQuotaExceededError(msg)) {
262
+ handleStream429()
263
+ }
264
+ }
265
+ throw err
266
+ }
267
+ })()
268
+ }, { global: true, prepend: true })
269
+
270
+ if (typeof ctx.effect === 'function') {
271
+ ctx.effect(() => () => unlisten?.(), 'dsh-clinebot: llm/stream failover')
272
+ }
204
273
  }
205
274
  }
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',
@@ -215,27 +215,49 @@ export function parsePlanIncludedModels(includedInput) {
215
215
  }
216
216
 
217
217
  /**
218
- * Get all models from known catalog.
218
+ * Get all models:
219
+ * 1. When plan is synchronized (dynamicModels is a non-empty array):
220
+ * Registers strictly the models included in the active plan, enriching them
221
+ * with curated capabilities, reasoning efforts, and descriptions from CLINE_MODELS.
222
+ * 2. When plan is unsynced (dynamicModels is empty):
223
+ * Falls back to the full curated CLINE_MODELS catalog marked with unverified: true.
219
224
  */
220
225
  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)
226
+ if (Array.isArray(dynamicModels) && dynamicModels.length > 0) {
227
+ const normalize = (s) => String(s || '').toLowerCase().replace(/[\s._-]+/g, '')
228
+ return dynamicModels.map((dm) => {
229
+ const targetNorm = normalize(dm.id.replace(/^cline-pass\//, ''))
230
+ const curated = CLINE_MODELS.find(
231
+ (cm) => cm.id === dm.id || normalize(cm.id.replace(/^cline-pass\//, '')) === targetNorm || normalize(cm.name) === targetNorm
232
+ )
233
+ if (curated) {
234
+ return {
235
+ ...curated,
236
+ ...dm,
237
+ description: dm.description || curated.description,
238
+ contextLength: dm.contextLength || curated.contextLength,
239
+ maxTokens: dm.maxTokens || curated.maxTokens,
240
+ input: dm.input && dm.input.length ? dm.input : curated.input,
241
+ category: dm.category || curated.category,
242
+ reasoningEfforts: dm.reasoningEfforts !== undefined ? dm.reasoningEfforts : curated.reasoningEfforts,
243
+ unverified: false,
244
+ }
229
245
  }
230
- }
246
+ return {
247
+ ...dm,
248
+ unverified: false,
249
+ }
250
+ })
231
251
  }
232
252
 
233
- return result
253
+ return CLINE_MODELS.map((m) => ({ ...m, unverified: true }))
234
254
  }
235
255
 
236
256
  export function findModel(id, dynamicModels = []) {
237
257
  const all = getAllModels(dynamicModels)
238
- return all.find((m) => m.id === id) || null
258
+ const found = all.find((m) => m.id === id)
259
+ if (found) return found
260
+ return CLINE_MODELS.find((m) => m.id === id) || null
239
261
  }
240
262
 
241
263
  export function isSupportedModel(id, dynamicModels = []) {
@@ -298,14 +320,14 @@ export function isVisionModel(id, dynamicModels = []) {
298
320
  /**
299
321
  * Disk caching for discovered models.
300
322
  */
301
- export async function saveModelsDiskCache(cachePath, models = []) {
323
+ export async function saveModelsDiskCache(cachePath, models = [], planSyncedAt = Date.now()) {
302
324
  if (!cachePath || !Array.isArray(models) || !models.length) return false
303
325
  try {
304
326
  const fs = await import('node:fs/promises')
305
327
  const path = await import('node:path')
306
328
  const dir = path.dirname(cachePath)
307
329
  await fs.mkdir(dir, { recursive: true })
308
- const payload = JSON.stringify({ savedAt: Date.now(), models }, null, 2)
330
+ const payload = JSON.stringify({ savedAt: Date.now(), planSyncedAt, models }, null, 2)
309
331
  await fs.writeFile(cachePath, payload, 'utf8')
310
332
  return true
311
333
  } catch {
@@ -319,7 +341,10 @@ export async function loadModelsDiskCache(cachePath) {
319
341
  const fs = await import('node:fs/promises')
320
342
  const raw = await fs.readFile(cachePath, 'utf8')
321
343
  const parsed = JSON.parse(raw)
322
- return Array.isArray(parsed?.models) ? parsed.models : null
344
+ const list = Array.isArray(parsed?.models) ? parsed.models : (Array.isArray(parsed) ? parsed : null)
345
+ if (!list) return null
346
+ list.planSyncedAt = typeof parsed?.planSyncedAt === 'number' ? parsed.planSyncedAt : (typeof parsed?.savedAt === 'number' ? parsed.savedAt : 0)
347
+ return list
323
348
  } catch {
324
349
  return null
325
350
  }
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os'
2
2
  import path from 'node:path'
3
- import { publicConfig, Config, LLM_PI_AI_NS } from './config.js'
3
+ import { publicConfig, plainConfig, Config, LLM_PI_AI_NS } from './config.js'
4
4
  import { publicUsage } from './http.js'
5
5
  import {
6
6
  PROVIDER_ID,
@@ -16,6 +16,7 @@ import {
16
16
  fetchUsageLimits,
17
17
  buildPiAiProvider,
18
18
  sessionStats,
19
+ getLastRotation,
19
20
  } from './cline-client.js'
20
21
 
21
22
  export function resolvePathWithHome(p) {
@@ -113,6 +114,7 @@ export async function buildStatus(ctx, cfg) {
113
114
  usage: publicUsage(usage),
114
115
  quotaWarning,
115
116
  sessionStats: { ...sessionStats },
117
+ lastRotation: getLastRotation(),
116
118
  isRegistered,
117
119
  availableModels: allModels,
118
120
  }
@@ -129,9 +131,11 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
129
131
  const allowedSet = new Set(activeModelIds || pub.enabledModels)
130
132
  const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
131
133
 
134
+ const activeAcc = await resolveActiveAccountKey(ctx, cfg)
135
+
132
136
  const providerObj = buildPiAiProvider({
133
137
  baseUrl: pub.baseUrl,
134
- apiKeyEnv: pub.apiKeyEnv,
138
+ apiKeyEnv: activeAcc?.apiKeyEnv || pub.apiKeyEnv,
135
139
  models: modelsToRegister.length ? modelsToRegister : allModels,
136
140
  customModels: pub.dynamicModels,
137
141
  displayName: PROVIDER_DISPLAY_NAME,
@@ -180,7 +184,11 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
180
184
  if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
181
185
  const fromDisk = await loadModelsDiskCache(cacheFile)
182
186
  if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
183
- const next = Config({ ...live(), dynamicModels: fromDisk })
187
+ const next = Config({
188
+ ...plainConfig(live()),
189
+ dynamicModels: fromDisk,
190
+ planSyncedAt: fromDisk.planSyncedAt || Date.now(),
191
+ })
184
192
  await settingsApi.replace(next)
185
193
  await syncProviderState(next)
186
194
  }
@@ -200,14 +208,16 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
200
208
  const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
201
209
 
202
210
  if (hasNew && settingsApi?.replace) {
211
+ const now = Date.now()
203
212
  const next = Config({
204
- ...live(),
213
+ ...plainConfig(live()),
205
214
  dynamicModels: usageData.dynamicModels,
215
+ planSyncedAt: now,
206
216
  })
207
217
  await settingsApi.replace(next)
208
218
  await syncProviderState(next)
209
219
  if (cacheFile) {
210
- await saveModelsDiskCache(cacheFile, usageData.dynamicModels)
220
+ await saveModelsDiskCache(cacheFile, usageData.dynamicModels, now)
211
221
  }
212
222
  }
213
223
  }
@@ -1,6 +1,6 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
2
  import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
3
- import { publicConfig, Config } from '../config.js'
3
+ import { publicConfig, plainConfig, Config } from '../config.js'
4
4
  import { upsertPiAiProvider, removePiAiProvider } from '../provider-sync.js'
5
5
  import { clearUsageCache, clearProbeCache } from '../cline-client.js'
6
6
 
@@ -21,14 +21,14 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
21
21
  clearUsageCache()
22
22
  clearProbeCache()
23
23
  const settingsApi = getSettingsApi()
24
- if (settingsApi?.replace) {
25
- const next = Config({ ...live(), activeAccount: account })
26
- await settingsApi.replace(next)
27
- await syncProviderState(next)
28
- writeJson(res, 200, { ok: true, activeAccount: next.activeAccount })
29
- } else {
30
- writeJson(res, 200, { ok: true, activeAccount: account })
24
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
25
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
31
26
  }
27
+
28
+ const next = Config({ ...plainConfig(live()), activeAccount: account })
29
+ await settingsApi.replace(next)
30
+ await syncProviderState(next)
31
+ writeJson(res, 200, { ok: true, activeAccount: account })
32
32
  } catch (err) {
33
33
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
34
34
  }
@@ -2,50 +2,79 @@ import { writeJson, readBody } from '../http.js'
2
2
  import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
3
3
  import { publicConfig } from '../config.js'
4
4
  import { resolveActiveAccountKey } from '../provider-sync.js'
5
- import { smokeChat, recordSessionRequest, rotateToNextAccount, DEFAULT_MODEL_ID } from '../cline-client.js'
5
+ import {
6
+ smokeChat,
7
+ recordSmokeTest,
8
+ rotateToNextAccount,
9
+ DEFAULT_MODEL_ID,
10
+ normalizeBaseUrl,
11
+ } from '../cline-client.js'
6
12
 
7
13
  export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
8
- let authSession = null
9
-
10
- // POST /dsh-clinebot/auth/begin — start loopback auth listener
14
+ // POST /dsh-clinebot/key/verify — on-the-fly verification of Cline API key
11
15
  ctx.effect(() => ctx.webServer.register({
12
16
  kind: 'exact',
13
- path: '/dsh-clinebot/auth/begin',
17
+ path: '/dsh-clinebot/key/verify',
14
18
  handler: async (req, res) => {
15
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
16
19
  if (!assertTrustedSettingsRequest(req, res)) return
20
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
17
21
  try {
18
- const authUrl = 'https://app.cline.bot'
19
- authSession = {
20
- state: 'waiting',
21
- startedAt: Date.now(),
22
- authUrl,
22
+ const bodyBuf = await readBody(req)
23
+ let body = {}
24
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
25
+ const key = String(body.key || '').trim()
26
+ if (!key) {
27
+ return writeJson(res, 400, { ok: false, valid: false, error: 'Key is empty' })
28
+ }
29
+ const pub = publicConfig(live())
30
+ const base = normalizeBaseUrl(pub.baseUrl)
31
+
32
+ const ac = new AbortController()
33
+ const timer = setTimeout(() => ac.abort(), 6000)
34
+ try {
35
+ const [meRes, planRes] = await Promise.all([
36
+ fetch(`${base}/users/me`, {
37
+ headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
38
+ signal: ac.signal,
39
+ }).catch((err) => ({ ok: false, status: 500, error: err })),
40
+ fetch(`${base}/users/me/plan`, {
41
+ headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
42
+ signal: ac.signal,
43
+ }).catch(() => null),
44
+ ])
45
+
46
+ if (!meRes.ok) {
47
+ return writeJson(res, 200, {
48
+ ok: false,
49
+ valid: false,
50
+ error: meRes.status === 401 ? 'Invalid API key (HTTP 401 Unauthorized)' : `Upstream returned HTTP ${meRes.status}`,
51
+ })
52
+ }
53
+
54
+ const meData = await meRes.json().catch(() => ({}))
55
+ const email = meData?.data?.email || meData?.email || 'authenticated user'
56
+
57
+ let planName = 'ClinePass'
58
+ if (planRes && planRes.ok) {
59
+ const planData = await planRes.json().catch(() => ({}))
60
+ const plan = planData?.data?.plan || planData?.data || planData?.plan || planData
61
+ planName = plan?.displayName || plan?.title || plan?.name || 'ClinePass'
62
+ }
63
+
64
+ return writeJson(res, 200, {
65
+ ok: true,
66
+ valid: true,
67
+ email,
68
+ plan: planName,
69
+ })
70
+ } finally {
71
+ clearTimeout(timer)
23
72
  }
24
- writeJson(res, 200, {
25
- ok: true,
26
- status: authSession.state,
27
- authUrl,
28
- })
29
73
  } catch (err) {
30
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
74
+ return writeJson(res, 500, { ok: false, valid: false, error: String(err?.message || err) })
31
75
  }
32
76
  },
33
- }), 'dsh-clinebot: /auth/begin')
34
-
35
- // GET /dsh-clinebot/auth/status — query current fast auth state
36
- ctx.effect(() => ctx.webServer.register({
37
- kind: 'exact',
38
- path: '/dsh-clinebot/auth/status',
39
- handler: async (req, res) => {
40
- if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
41
- if (!assertTrustedSettingsRequest(req, res)) return
42
- writeJson(res, 200, {
43
- ok: true,
44
- status: authSession?.state || 'idle',
45
- authUrl: authSession?.authUrl || 'https://app.cline.bot',
46
- })
47
- },
48
- }), 'dsh-clinebot: /auth/status')
77
+ }), 'dsh-clinebot: /key/verify')
49
78
 
50
79
  // POST /dsh-clinebot/smoke — live ping test
51
80
  ctx.effect(() => ctx.webServer.register({
@@ -73,12 +102,11 @@ export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderStat
73
102
  model: modelToTest,
74
103
  timeoutMs: pub.smokeTimeoutMs,
75
104
  })
76
- recordSessionRequest({
105
+ recordSmokeTest({
77
106
  latencyMs: outcome.latencyMs,
78
107
  ok: outcome.ok,
79
108
  error: outcome.error,
80
- promptTokens: outcome.promptTokens || 5,
81
- completionTokens: outcome.completionTokens || 10,
109
+ model: modelToTest,
82
110
  })
83
111
 
84
112
  let failover = null
@@ -91,7 +119,7 @@ export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderStat
91
119
 
92
120
  writeJson(res, outcome.ok ? 200 : 502, { ...outcome, failover })
93
121
  } catch (err) {
94
- recordSessionRequest({ ok: false, error: String(err?.message || err) })
122
+ recordSmokeTest({ ok: false, error: String(err?.message || err) })
95
123
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
96
124
  }
97
125
  },
@@ -1,6 +1,6 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
2
  import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
3
- import { publicConfig, Config } from '../config.js'
3
+ import { publicConfig, plainConfig, Config } from '../config.js'
4
4
  import { resolveActiveAccountKey, resolvePathWithHome } from '../provider-sync.js'
5
5
  import { getAllModels, saveModelsDiskCache } from '../models.js'
6
6
  import { fetchUsageLimits } from '../cline-client.js'
@@ -35,18 +35,21 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
35
35
  const dynamicModels = Array.isArray(usageData.dynamicModels) ? usageData.dynamicModels : []
36
36
  const allModels = getAllModels(dynamicModels)
37
37
  const settingsApi = getSettingsApi()
38
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
39
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
40
+ }
38
41
 
39
- if (settingsApi?.replace) {
40
- const next = Config({
41
- ...live(),
42
- dynamicModels,
43
- })
44
- await settingsApi.replace(next)
45
- await syncProviderState(next)
46
- const cacheFile = resolvePathWithHome(pub.modelsCachePath)
47
- if (cacheFile) {
48
- await saveModelsDiskCache(cacheFile, dynamicModels)
49
- }
42
+ const now = Date.now()
43
+ const next = Config({
44
+ ...plainConfig(live()),
45
+ dynamicModels,
46
+ planSyncedAt: now,
47
+ })
48
+ await settingsApi.replace(next)
49
+ await syncProviderState(next)
50
+ const cacheFile = resolvePathWithHome(pub.modelsCachePath)
51
+ if (cacheFile) {
52
+ await saveModelsDiskCache(cacheFile, dynamicModels, now)
50
53
  }
51
54
 
52
55
  return writeJson(res, 200, {
@@ -75,23 +78,23 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
75
78
  try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
76
79
 
77
80
  const settingsApi = getSettingsApi()
78
- if (settingsApi?.replace) {
79
- const patch = {}
80
- if (Array.isArray(body.disabledModels)) {
81
- patch.disabledModels = body.disabledModels
82
- } else if (Array.isArray(body.enabledModels)) {
83
- const allModels = getAllModels(live().dynamicModels)
84
- const enabledSet = new Set(body.enabledModels)
85
- patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
86
- }
87
- if (body.defaultModel) patch.defaultModel = body.defaultModel
88
- const next = Config({ ...live(), ...patch })
89
- await settingsApi.replace(next)
90
- await syncProviderState(next)
91
- writeJson(res, 200, { ok: true, disabledModels: next.disabledModels, enabledModels: publicConfig(next).enabledModels })
92
- } else {
93
- writeJson(res, 200, { ok: true })
81
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
82
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
83
+ }
84
+
85
+ const patch = {}
86
+ if (Array.isArray(body.disabledModels)) {
87
+ patch.disabledModels = body.disabledModels
88
+ } else if (Array.isArray(body.enabledModels)) {
89
+ const allModels = getAllModels(publicConfig(live()).dynamicModels)
90
+ const enabledSet = new Set(body.enabledModels)
91
+ patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
94
92
  }
93
+ if (body.defaultModel) patch.defaultModel = body.defaultModel
94
+ const next = Config({ ...plainConfig(live()), ...patch })
95
+ await settingsApi.replace(next)
96
+ await syncProviderState(next)
97
+ writeJson(res, 200, { ok: true, disabledModels: next.disabledModels, enabledModels: publicConfig(next).enabledModels })
95
98
  } catch (err) {
96
99
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
97
100
  }