@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.
@@ -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,8 @@ import {
16
16
  fetchUsageLimits,
17
17
  buildPiAiProvider,
18
18
  sessionStats,
19
+ getLastRotation,
20
+ usageCache,
19
21
  } from './cline-client.js'
20
22
 
21
23
  export function resolvePathWithHome(p) {
@@ -29,13 +31,27 @@ export function resolvePathWithHome(p) {
29
31
 
30
32
  export async function checkRegisteredInPiAi(ctx) {
31
33
  const settings = ctx?.get?.('settings')
32
- if (!settings?.get) return false
33
34
  try {
34
- const piAi = settings.get(LLM_PI_AI_NS)
35
- return !!piAi?.providers?.[PROVIDER_ID]
35
+ if (typeof settings?.describe === 'function') {
36
+ const descriptors = settings.describe()
37
+ const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
38
+ if (piAiDesc?.value?.providers?.[PROVIDER_ID]) return true
39
+ }
40
+ if (typeof settings?.get === 'function') {
41
+ const piAi = settings.get(LLM_PI_AI_NS)
42
+ if (piAi?.providers?.[PROVIDER_ID]) return true
43
+ }
44
+ const llm = ctx?.get?.('llm')
45
+ if (typeof llm?.listProviders === 'function') {
46
+ const list = await llm.listProviders()
47
+ if (Array.isArray(list) && list.some((p) => (p?.id || p) === PROVIDER_ID)) {
48
+ return true
49
+ }
50
+ }
36
51
  } catch {
37
52
  return false
38
53
  }
54
+ return false
39
55
  }
40
56
 
41
57
  export async function resolveActiveAccountKey(ctx, cfg, pool = null) {
@@ -100,19 +116,26 @@ export async function buildStatus(ctx, cfg) {
100
116
  present: !!(activeAcc.value || key.value),
101
117
  source: activeAcc.source || key.source,
102
118
  },
103
- accounts: pool.map((acc) => ({
104
- id: acc.id,
105
- label: acc.label,
106
- apiKeyEnv: acc.apiKeyEnv,
107
- present: acc.present,
108
- source: acc.source,
109
- isPinned: acc.isPinned,
110
- })),
119
+ accounts: pool.map((acc) => {
120
+ const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
121
+ const cached = usageCache.get(cacheKey)?.data
122
+ const pct = cached?.windows?.fiveHour?.percentUsed
123
+ return {
124
+ id: acc.id,
125
+ label: acc.label,
126
+ apiKeyEnv: acc.apiKeyEnv,
127
+ present: acc.present,
128
+ source: acc.source,
129
+ isPinned: acc.isPinned,
130
+ percentUsed: typeof pct === 'number' ? pct : null,
131
+ }
132
+ }),
111
133
  activeAccount: activeAcc.apiKeyEnv,
112
134
  health,
113
135
  usage: publicUsage(usage),
114
136
  quotaWarning,
115
137
  sessionStats: { ...sessionStats },
138
+ lastRotation: getLastRotation(),
116
139
  isRegistered,
117
140
  availableModels: allModels,
118
141
  }
@@ -129,9 +152,11 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
129
152
  const allowedSet = new Set(activeModelIds || pub.enabledModels)
130
153
  const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
131
154
 
155
+ const activeAcc = await resolveActiveAccountKey(ctx, cfg)
156
+
132
157
  const providerObj = buildPiAiProvider({
133
158
  baseUrl: pub.baseUrl,
134
- apiKeyEnv: pub.apiKeyEnv,
159
+ apiKeyEnv: activeAcc?.apiKeyEnv || pub.apiKeyEnv,
135
160
  models: modelsToRegister.length ? modelsToRegister : allModels,
136
161
  customModels: pub.dynamicModels,
137
162
  displayName: PROVIDER_DISPLAY_NAME,
@@ -154,12 +179,19 @@ export async function removePiAiProvider(ctx) {
154
179
  throw new Error('DSH settings service unavailable')
155
180
  }
156
181
 
157
- await settings.mutate(LLM_PI_AI_NS, [
158
- {
159
- op: 'remove',
160
- path: ['providers', PROVIDER_ID],
161
- },
162
- ])
182
+ try {
183
+ await settings.mutate(LLM_PI_AI_NS, [
184
+ {
185
+ op: 'remove',
186
+ path: ['providers', PROVIDER_ID],
187
+ },
188
+ ])
189
+ } catch (err) {
190
+ const msg = String(err?.message || err).toLowerCase()
191
+ if (!msg.includes('not found') && !msg.includes('absent') && !msg.includes('enoent') && !msg.includes('does not exist')) {
192
+ throw err
193
+ }
194
+ }
163
195
  return { ok: true }
164
196
  }
165
197
 
@@ -180,7 +212,11 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
180
212
  if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
181
213
  const fromDisk = await loadModelsDiskCache(cacheFile)
182
214
  if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
183
- const next = Config({ ...live(), dynamicModels: fromDisk })
215
+ const next = Config({
216
+ ...plainConfig(live()),
217
+ dynamicModels: fromDisk,
218
+ planSyncedAt: fromDisk.planSyncedAt || Date.now(),
219
+ })
184
220
  await settingsApi.replace(next)
185
221
  await syncProviderState(next)
186
222
  }
@@ -200,14 +236,16 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
200
236
  const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
201
237
 
202
238
  if (hasNew && settingsApi?.replace) {
239
+ const now = Date.now()
203
240
  const next = Config({
204
- ...live(),
241
+ ...plainConfig(live()),
205
242
  dynamicModels: usageData.dynamicModels,
243
+ planSyncedAt: now,
206
244
  })
207
245
  await settingsApi.replace(next)
208
246
  await syncProviderState(next)
209
247
  if (cacheFile) {
210
- await saveModelsDiskCache(cacheFile, usageData.dynamicModels)
248
+ await saveModelsDiskCache(cacheFile, usageData.dynamicModels, now)
211
249
  }
212
250
  }
213
251
  }
@@ -1,10 +1,178 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
- import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
3
- import { publicConfig, Config } from '../config.js'
2
+ import { assertTrustedSettingsRequest } from '../access.js'
3
+ import { publicConfig, plainConfig, Config } from '../config.js'
4
4
  import { upsertPiAiProvider, removePiAiProvider } from '../provider-sync.js'
5
- import { clearUsageCache, clearProbeCache } from '../cline-client.js'
5
+ import {
6
+ clearUsageCache,
7
+ clearProbeCache,
8
+ saveCredentialKey,
9
+ deleteCredentialKey,
10
+ smokeChat,
11
+ DEFAULT_API_KEY_ENV,
12
+ } from '../cline-client.js'
6
13
 
7
14
  export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
15
+ // POST /dsh-clinebot/accounts — add or update account in pool
16
+ ctx.effect(() => ctx.webServer.register({
17
+ kind: 'exact',
18
+ path: '/dsh-clinebot/accounts',
19
+ handler: async (req, res) => {
20
+ if (req.method === 'DELETE') {
21
+ return handleDeleteAccount(req, res)
22
+ }
23
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST or DELETE only' })
24
+ if (!assertTrustedSettingsRequest(req, res)) return
25
+ const settingsApi = getSettingsApi()
26
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
27
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
28
+ }
29
+ try {
30
+ const bodyBuf = await readBody(req)
31
+ let body = {}
32
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
33
+
34
+ const apiKey = String(body.apiKey || '').trim()
35
+ if (!apiKey) {
36
+ return writeJson(res, 400, { ok: false, error: 'API key is required' })
37
+ }
38
+
39
+ const curr = plainConfig(live())
40
+ const existingAccounts = Array.isArray(curr.accounts) ? [...curr.accounts] : []
41
+
42
+ let targetEnv = String(body.apiKeyEnv || '').trim()
43
+ if (!targetEnv) {
44
+ let maxN = 1
45
+ for (const acc of existingAccounts) {
46
+ const m = String(acc?.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
47
+ if (m) {
48
+ const num = parseInt(m[1], 10)
49
+ if (num >= maxN) maxN = num + 1
50
+ }
51
+ }
52
+ if (curr.apiKeyEnv && curr.apiKeyEnv.startsWith('CLINEBOT_API_KEY_')) {
53
+ const m = curr.apiKeyEnv.match(/^CLINEBOT_API_KEY_(\d+)$/)
54
+ if (m) {
55
+ const num = parseInt(m[1], 10)
56
+ if (num >= maxN) maxN = num + 1
57
+ }
58
+ }
59
+ targetEnv = `CLINEBOT_API_KEY_${maxN}`
60
+ }
61
+
62
+ if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)) {
63
+ return writeJson(res, 400, {
64
+ ok: false,
65
+ error: `Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$`,
66
+ })
67
+ }
68
+
69
+ const label = String(body.label || '').trim() || targetEnv
70
+
71
+ await saveCredentialKey(ctx, targetEnv, apiKey)
72
+
73
+ const idx = existingAccounts.findIndex((a) => a && a.apiKeyEnv === targetEnv)
74
+ const accountEntry = { label, apiKeyEnv: targetEnv }
75
+ if (idx >= 0) {
76
+ existingAccounts[idx] = accountEntry
77
+ } else {
78
+ existingAccounts.push(accountEntry)
79
+ }
80
+
81
+ const next = Config({ ...curr, accounts: existingAccounts })
82
+ await settingsApi.replace(next)
83
+ await syncProviderState(next)
84
+
85
+ let validation = { ok: false, error: 'Skipped validation: baseUrl is not https' }
86
+ try {
87
+ const parsed = new URL(curr.baseUrl)
88
+ if (parsed.protocol === 'https:') {
89
+ validation = await smokeChat(curr.baseUrl, apiKey, {
90
+ model: curr.defaultModel,
91
+ timeoutMs: 15000,
92
+ })
93
+ }
94
+ } catch {
95
+ validation = { ok: false, error: 'Invalid baseUrl' }
96
+ }
97
+
98
+ clearUsageCache()
99
+ clearProbeCache()
100
+
101
+ writeJson(res, 200, {
102
+ ok: true,
103
+ account: accountEntry,
104
+ validated: validation.ok,
105
+ latencyMs: validation.latencyMs,
106
+ validationError: validation.ok ? null : validation.error,
107
+ })
108
+ } catch (err) {
109
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
110
+ }
111
+ },
112
+ }), 'dsh-clinebot: /accounts')
113
+
114
+ async function handleDeleteAccount(req, res, targetEnvFromUrl = '') {
115
+ if (!assertTrustedSettingsRequest(req, res)) return
116
+ const settingsApi = getSettingsApi()
117
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
118
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
119
+ }
120
+ try {
121
+ let body = {}
122
+ if (req.method === 'POST' || req.method === 'DELETE') {
123
+ try {
124
+ const bodyBuf = await readBody(req)
125
+ body = JSON.parse(bodyBuf.toString('utf8'))
126
+ } catch {
127
+ body = {}
128
+ }
129
+ }
130
+ const targetEnv = String(targetEnvFromUrl || body.apiKeyEnv || '').trim()
131
+ if (!targetEnv) {
132
+ return writeJson(res, 400, { ok: false, error: 'apiKeyEnv is required' })
133
+ }
134
+
135
+ const curr = plainConfig(live())
136
+ if (targetEnv === curr.apiKeyEnv || targetEnv === DEFAULT_API_KEY_ENV) {
137
+ return writeJson(res, 400, { ok: false, error: 'Cannot delete the primary account' })
138
+ }
139
+
140
+ const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
141
+ const nextAccounts = existingAccounts.filter((a) => a && a.apiKeyEnv !== targetEnv)
142
+ let activeAccount = curr.activeAccount
143
+ if (activeAccount === targetEnv) {
144
+ activeAccount = ''
145
+ }
146
+
147
+ const next = Config({ ...curr, accounts: nextAccounts, activeAccount })
148
+ await settingsApi.replace(next)
149
+ await syncProviderState(next)
150
+
151
+ if (body.deleteSecret) {
152
+ await deleteCredentialKey(ctx, targetEnv)
153
+ }
154
+
155
+ clearUsageCache()
156
+ clearProbeCache()
157
+
158
+ writeJson(res, 200, { ok: true, removed: targetEnv })
159
+ } catch (err) {
160
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
161
+ }
162
+ }
163
+
164
+ // POST /dsh-clinebot/accounts/delete — delete account endpoint
165
+ ctx.effect(() => ctx.webServer.register({
166
+ kind: 'exact',
167
+ path: '/dsh-clinebot/accounts/delete',
168
+ handler: async (req, res) => {
169
+ if (req.method !== 'POST' && req.method !== 'DELETE') {
170
+ return writeJson(res, 405, { ok: false, error: 'POST or DELETE only' })
171
+ }
172
+ return handleDeleteAccount(req, res)
173
+ },
174
+ }), 'dsh-clinebot: /accounts/delete')
175
+
8
176
  // POST /dsh-clinebot/accounts/active — switch or pin active account
9
177
  ctx.effect(() => ctx.webServer.register({
10
178
  kind: 'exact',
@@ -21,14 +189,14 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
21
189
  clearUsageCache()
22
190
  clearProbeCache()
23
191
  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 })
192
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
193
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
31
194
  }
195
+
196
+ const next = Config({ ...plainConfig(live()), activeAccount: account })
197
+ await settingsApi.replace(next)
198
+ await syncProviderState(next)
199
+ writeJson(res, 200, { ok: true, activeAccount: account })
32
200
  } catch (err) {
33
201
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
34
202
  }
@@ -1,51 +1,94 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
- import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
2
+ import { 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
+ let parsedBase
33
+ try {
34
+ parsedBase = new URL(base)
35
+ } catch {
36
+ return writeJson(res, 400, { ok: false, valid: false, error: 'Invalid baseUrl' })
37
+ }
38
+ if (parsedBase.protocol !== 'https:' && parsedBase.hostname !== 'localhost' && parsedBase.hostname !== '127.0.0.1') {
39
+ return writeJson(res, 400, {
40
+ ok: false,
41
+ valid: false,
42
+ error: 'Insecure baseUrl: key verification requires https://',
43
+ })
44
+ }
45
+
46
+ const ac = new AbortController()
47
+ const timer = setTimeout(() => ac.abort(), 6000)
48
+ try {
49
+ const [meRes, planRes] = await Promise.all([
50
+ fetch(`${base}/users/me`, {
51
+ headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
52
+ signal: ac.signal,
53
+ }).catch((err) => ({ ok: false, status: 500, error: err })),
54
+ fetch(`${base}/users/me/plan`, {
55
+ headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
56
+ signal: ac.signal,
57
+ }).catch(() => null),
58
+ ])
59
+
60
+ if (!meRes.ok) {
61
+ return writeJson(res, 200, {
62
+ ok: false,
63
+ valid: false,
64
+ error: meRes.status === 401 ? 'Invalid API key (HTTP 401 Unauthorized)' : `Upstream returned HTTP ${meRes.status}`,
65
+ })
66
+ }
67
+
68
+ const meData = await meRes.json().catch(() => ({}))
69
+ const email = meData?.data?.email || meData?.email || 'authenticated user'
70
+
71
+ let planName = 'ClinePass'
72
+ if (planRes && planRes.ok) {
73
+ const planData = await planRes.json().catch(() => ({}))
74
+ const plan = planData?.data?.plan || planData?.data || planData?.plan || planData
75
+ planName = plan?.displayName || plan?.title || plan?.name || 'ClinePass'
76
+ }
77
+
78
+ return writeJson(res, 200, {
79
+ ok: true,
80
+ valid: true,
81
+ email,
82
+ plan: planName,
83
+ })
84
+ } finally {
85
+ clearTimeout(timer)
23
86
  }
24
- writeJson(res, 200, {
25
- ok: true,
26
- status: authSession.state,
27
- authUrl,
28
- })
29
87
  } catch (err) {
30
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
88
+ return writeJson(res, 500, { ok: false, valid: false, error: String(err?.message || err) })
31
89
  }
32
90
  },
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')
91
+ }), 'dsh-clinebot: /key/verify')
49
92
 
50
93
  // POST /dsh-clinebot/smoke — live ping test
51
94
  ctx.effect(() => ctx.webServer.register({
@@ -73,12 +116,11 @@ export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderStat
73
116
  model: modelToTest,
74
117
  timeoutMs: pub.smokeTimeoutMs,
75
118
  })
76
- recordSessionRequest({
119
+ recordSmokeTest({
77
120
  latencyMs: outcome.latencyMs,
78
121
  ok: outcome.ok,
79
122
  error: outcome.error,
80
- promptTokens: outcome.promptTokens || 5,
81
- completionTokens: outcome.completionTokens || 10,
123
+ model: modelToTest,
82
124
  })
83
125
 
84
126
  let failover = null
@@ -91,7 +133,7 @@ export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderStat
91
133
 
92
134
  writeJson(res, outcome.ok ? 200 : 502, { ...outcome, failover })
93
135
  } catch (err) {
94
- recordSessionRequest({ ok: false, error: String(err?.message || err) })
136
+ recordSmokeTest({ ok: false, error: String(err?.message || err) })
95
137
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
96
138
  }
97
139
  },
@@ -1,6 +1,6 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
- import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
3
- import { publicConfig, Config } from '../config.js'
2
+ import { assertTrustedSettingsRequest } from '../access.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
  }