@goodandready/dsh-clinebot 0.6.29 → 0.6.31

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,6 +6,60 @@ export function dshHome() {
6
6
  return path.resolve(process.env.DSH_HOME || path.join(process.env.HOME || os.homedir(), '.dsh'))
7
7
  }
8
8
 
9
+ export function defaultStatsPath() {
10
+ return path.join(dshHome(), 'clinebot-stats.json')
11
+ }
12
+
13
+ export function defaultEventLogPath() {
14
+ return path.join(dshHome(), 'clinebot-events.json')
15
+ }
16
+
17
+ export function defaultPriceCatalogPath() {
18
+ return path.join(dshHome(), 'clinebot-prices.json')
19
+ }
20
+
21
+ export function defaultModelsCachePath() {
22
+ return path.join(dshHome(), 'clinebot-models-cache.json')
23
+ }
24
+
25
+ export function unwrapPathValue(target) {
26
+ let guard = 0
27
+ let current = target
28
+ while (guard < 5 && current) {
29
+ if (typeof current === 'function') {
30
+ try { current = current() } catch { current = ''; break }
31
+ guard += 1
32
+ } else if (typeof current === 'object' && typeof current.get === 'function') {
33
+ try { current = current.get() } catch { current = ''; break }
34
+ guard += 1
35
+ } else {
36
+ break
37
+ }
38
+ }
39
+ return typeof current === 'string' ? current : ''
40
+ }
41
+
42
+ export function resolveDataPath(targetPath, defaultName = '') {
43
+ const unwrapped = unwrapPathValue(targetPath)
44
+ const raw = String(unwrapped || '').trim()
45
+ if (!raw) {
46
+ return defaultName ? path.join(dshHome(), defaultName) : ''
47
+ }
48
+ if (raw === '~/.dsh') {
49
+ return dshHome()
50
+ }
51
+ if (raw.startsWith('~/.dsh/') || raw.startsWith('~/.dsh\\') || raw.startsWith('~\\.dsh\\')) {
52
+ return path.join(dshHome(), raw.slice(7))
53
+ }
54
+ if (raw === '~') {
55
+ return process.env.HOME || os.homedir()
56
+ }
57
+ if (raw.startsWith('~/') || raw.startsWith('~\\')) {
58
+ return path.join(process.env.HOME || os.homedir(), raw.slice(2))
59
+ }
60
+ return path.resolve(raw)
61
+ }
62
+
9
63
  function realPath(target) {
10
64
  let current = target
11
65
  const missing = []
@@ -28,12 +82,11 @@ function realPath(target) {
28
82
  }
29
83
 
30
84
  export function isSafeDataPath(value) {
31
- const raw = String(value ?? '').trim()
85
+ const unwrapped = unwrapPathValue(value)
86
+ const raw = String(unwrapped ?? '').trim()
32
87
  if (!raw) return true
33
88
  if (raw.includes('\0')) return false
34
- const resolved = raw === '~' || raw.startsWith('~/')
35
- ? path.resolve(process.env.HOME || os.homedir(), raw.slice(1))
36
- : path.resolve(raw)
89
+ const resolved = resolveDataPath(raw, '')
37
90
  const home = realPath(dshHome())
38
91
  const realResolved = realPath(resolved)
39
92
  if (!home || !realResolved) return false
package/lib/price-sync.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs'
2
- import { homedir } from 'node:os'
2
+ import { resolveDataPath, isSafeDataPath } from './path-safety.js'
3
3
  import path from 'node:path'
4
4
  import { normalizeModelKey, getPublicPriceCatalog, setPublicPriceCatalog } from './model-prices.js'
5
5
  import { safeDict } from './safe-dict.js'
@@ -48,10 +48,7 @@ const PROVIDER_PRIORITY = {
48
48
  }
49
49
 
50
50
  function expandHome(target) {
51
- const value = String(target || '')
52
- if (value === '~') return homedir()
53
- if (value.startsWith('~/')) return path.join(homedir(), value.slice(2))
54
- return value || path.join(homedir(), '.dsh', 'clinebot-prices.json')
51
+ return resolveDataPath(target, 'clinebot-prices.json')
55
52
  }
56
53
 
57
54
  function priority(provider) {
@@ -1,275 +1,258 @@
1
- import { getStatsSummary } from './stats-storage.js'
2
- import os from 'node:os'
3
- import path from 'node:path'
4
- import {
5
- PROVIDER_ID,
6
- PROVIDER_DISPLAY_NAME,
7
- getAllModels,
8
- saveModelsDiskCache,
9
- loadModelsDiskCache,
10
- OBSOLETE_MODEL_ID_MAP,
11
- migrateModelEntry,
12
- isCatalogDifferent,
13
- } from './models.js'
14
- import { ensurePublicPricesForModels } from './price-sync.js'
15
- import {
16
- probeHealth,
17
- fetchUsageLimits,
18
- sessionStats,
19
- DEFAULT_API_KEY_ENV,
20
- } from './cline-client.js'
21
- import { publicConfig, plainConfig, Config } from './config.js'
22
- import { publicUsage } from './http.js'
23
- import { resolveKeyValue } from './credential-refs.js'
24
- import { resolveAccountPool, getLastRotation } from './account-pool.js'
25
- import { isSafeDataPath } from './path-safety.js'
26
-
27
- export function resolvePathWithHome(p) {
28
- let guard = 0
29
- while (guard < 5 && p) {
30
- if (typeof p === 'function') {
31
- try { p = p() } catch { p = ''; break }
32
- guard += 1
33
- } else if (typeof p === 'object' && typeof p.get === 'function') {
34
- try { p = p.get() } catch { p = ''; break }
35
- guard += 1
36
- } else {
37
- break
38
- }
39
- }
40
- if (typeof p !== 'string') return ''
41
- if (p.startsWith('~/') || p === '~') {
42
- const home = process.env.HOME || os.homedir()
43
- return path.join(home, p.slice(1))
44
- }
45
- return p
46
- }
47
-
48
- async function checkRegisteredInLlm(ctx) {
49
- try {
50
- const llm = ctx?.get?.('llm')
51
- if (llm && typeof llm.listProviders === 'function') {
52
- const providers = await llm.listProviders()
53
- if (Array.isArray(providers) && providers.some((entry) => (entry?.id || entry) === PROVIDER_ID)) {
54
- return true
55
- }
56
- }
57
- } catch {
58
- /* llm service may not be mounted */
59
- }
60
- return false
61
- }
62
-
63
- export async function resolveActiveAccountKey(ctx, cfg, pool = null) {
64
- const accountPool = pool || await resolveAccountPool(ctx, cfg)
65
- const configured = accountPool.filter((acc) => acc.present && acc.value)
66
- if (!configured.length) {
67
- const fallbackEnv = publicConfig(cfg).apiKeyEnv || DEFAULT_API_KEY_ENV
68
- return { envName: fallbackEnv, apiKeyEnv: fallbackEnv, value: '', source: 'none', id: 'default' }
69
- }
70
- const pub = publicConfig(cfg)
71
- let chosen = configured[0]
72
- if (pub.activeAccount) {
73
- const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
74
- if (pinned) chosen = pinned
75
- }
76
- const keyEnv = chosen.apiKeyEnv || chosen.envName || DEFAULT_API_KEY_ENV
77
- return {
78
- ...chosen,
79
- envName: keyEnv,
80
- apiKeyEnv: keyEnv,
81
- }
82
- }
83
-
84
- export async function buildStatus(ctx, cfg) {
85
- const pub = publicConfig(cfg)
86
- const probeTimeout = Math.min(2500, pub.timeoutMs || 2500)
87
-
88
- const [key, pool, isRegistered, health] = await Promise.all([
89
- resolveKeyValue(ctx, pub.apiKeyEnv),
90
- resolveAccountPool(ctx, cfg),
91
- checkRegisteredInLlm(ctx),
92
- probeHealth(pub.baseUrl, { timeoutMs: probeTimeout }),
93
- ])
94
-
95
- const activeAcc = await resolveActiveAccountKey(ctx, cfg, pool)
96
- const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
97
-
98
- let usage = null
99
- const keyToUse = activeAcc.value || key.value
100
- if (keyToUse) {
101
- usage = await fetchUsageLimits(pub.baseUrl, keyToUse, { timeoutMs: probeTimeout }).catch(() => null)
102
- }
103
-
104
- let quotaWarning = null
105
- if (usage?.windows) {
106
- const fiveHour = usage.windows.fiveHour
107
- const weekly = usage.windows.weekly
108
- const monthly = usage.windows.monthly
109
-
110
- if (fiveHour && typeof fiveHour.percentUsed === 'number') {
111
- if (fiveHour.percentUsed >= 95) {
112
- quotaWarning = {
113
- level: 'exhausted',
114
- code: 'quota.exhausted_5h',
115
- params: { percentUsed: fiveHour.percentUsed },
116
- message: `5-hour rolling limit is almost exhausted (${fiveHour.percentUsed}%). New requests may be rejected until quota reset.`,
117
- resetsAt: fiveHour.resetsAt,
118
- }
119
- } else if (fiveHour.percentUsed >= 90) {
120
- quotaWarning = {
121
- level: 'warning',
122
- code: 'quota.warning_5h',
123
- params: { percentUsed: fiveHour.percentUsed },
124
- message: `Low quota warning: ${fiveHour.percentUsed}% of 5-hour limit consumed (<10% remaining).`,
125
- resetsAt: fiveHour.resetsAt,
126
- }
127
- }
128
- }
129
-
130
- if (!quotaWarning && weekly && typeof weekly.percentUsed === 'number' && weekly.percentUsed >= 90) {
131
- const remainingPercent = weekly.remainingPercent ?? (100 - weekly.percentUsed)
132
- quotaWarning = {
133
- level: weekly.percentUsed >= 99 ? 'exhausted' : 'warning',
134
- code: 'quota.warning_weekly',
135
- params: { percentUsed: weekly.percentUsed, remainingPercent },
136
- message: `Low weekly quota warning: ${weekly.percentUsed}% consumed (${remainingPercent}% remaining).`,
137
- resetsAt: weekly.resetsAt,
138
- }
139
- }
140
-
141
- if (!quotaWarning && monthly && typeof monthly.percentUsed === 'number' && monthly.percentUsed >= 90) {
142
- const remainingPercent = monthly.remainingPercent ?? (100 - monthly.percentUsed)
143
- quotaWarning = {
144
- level: monthly.percentUsed >= 99 ? 'exhausted' : 'warning',
145
- code: 'quota.warning_monthly',
146
- params: { percentUsed: monthly.percentUsed, remainingPercent },
147
- message: `Low monthly quota warning: ${monthly.percentUsed}% consumed (${remainingPercent}% remaining).`,
148
- resetsAt: monthly.resetsAt,
149
- }
150
- }
151
- }
152
-
153
- return {
154
- ok: true,
155
- providerId: PROVIDER_ID,
156
- displayName: PROVIDER_DISPLAY_NAME,
157
- config: pub,
158
- key: {
159
- envName: activeAcc.apiKeyEnv || key.envName,
160
- present: !!(activeAcc.value || key.value),
161
- source: activeAcc.source || key.source,
162
- },
163
- accounts: pool.map((acc) => {
164
- return {
165
- id: acc.id,
166
- label: acc.label,
167
- apiKeyEnv: acc.apiKeyEnv,
168
- present: acc.present,
169
- source: acc.source,
170
- isPinned: acc.isPinned,
171
- percentUsed: typeof acc.percentUsed === 'number' ? acc.percentUsed : null,
172
- }
173
- }),
174
- activeAccount: activeAcc.apiKeyEnv,
175
- health,
176
- usage: publicUsage(usage),
177
- quotaWarning,
178
- sessionStats: { ...sessionStats },
179
- statsSummary: getStatsSummary(),
180
- lastRotation: getLastRotation(),
181
- isRegistered,
182
- availableModels: allModels,
183
- }
184
- }
185
-
186
- export function formatProgressBar(pct, totalWidth = 10) {
187
- const clamped = Math.max(0, Math.min(100, pct || 0))
188
- const filled = Math.round((clamped / 100) * totalWidth)
189
- const empty = Math.max(0, totalWidth - filled)
190
- return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${clamped}%`
191
- }
192
-
193
- export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncProviderState }) {
194
- try {
195
- const cfg = live()
196
- const pub = publicConfig(cfg)
197
- const cacheFile = isSafeDataPath(pub.modelsCachePath) ? resolvePathWithHome(pub.modelsCachePath) : ''
198
- const settingsApi = getSettingsApi()
199
-
200
- // Auto-migrate obsolete IDs (e.g. deepseek-v41-flash -> deepseek-v4.1-flash)
201
- const existingDynamic = pub.dynamicModels || []
202
- const hasObsolete = existingDynamic.some((m) => Boolean(OBSOLETE_MODEL_ID_MAP[m?.id]))
203
- if (hasObsolete && settingsApi?.replace) {
204
- const migrated = existingDynamic.map(migrateModelEntry)
205
- const next = plainConfig(Config({
206
- ...plainConfig(live()),
207
- dynamicModels: migrated,
208
- }))
209
- await settingsApi.replace(next)
210
- await syncProviderState(next)
211
- }
212
-
213
- const activeAcc = await resolveActiveAccountKey(ctx, cfg)
214
- if (!activeAcc.value) return
215
-
216
- const cacheIdentity = {
217
- endpoint: pub.baseUrl,
218
- accountRef: activeAcc.envName || activeAcc.id || pub.apiKeyEnv,
219
- }
220
-
221
- let usageData = null
222
- try {
223
- usageData = await fetchUsageLimits(pub.baseUrl, activeAcc.value, {
224
- timeoutMs: Math.min(pub.timeoutMs, 5000),
225
- bypassCache: true,
226
- includeCatalog: true,
227
- })
228
- } catch {
229
- usageData = null
230
- }
231
-
232
- if (usageData?.ok && Array.isArray(usageData.dynamicModels)) {
233
- const existingDynamic = pub.dynamicModels || []
234
- const isDifferent = isCatalogDifferent(existingDynamic, usageData.dynamicModels)
235
- const now = Date.now()
236
-
237
- if (isDifferent && settingsApi?.replace) {
238
- const next = plainConfig(Config({
239
- ...plainConfig(live()),
240
- dynamicModels: usageData.dynamicModels,
241
- planSyncedAt: now,
242
- }))
243
- await settingsApi.replace(next)
244
- await syncProviderState(next)
245
- }
246
- if (cacheFile) {
247
- await saveModelsDiskCache(cacheFile, usageData.dynamicModels, now, cacheIdentity)
248
- }
249
- if (isDifferent) {
250
- await ensurePublicPricesForModels(usageData.dynamicModels.map((model) => model.id), {
251
- url: pub.priceCatalogUrl,
252
- cachePath: isSafeDataPath(pub.priceCatalogPath) ? pub.priceCatalogPath : undefined,
253
- enabled: pub.priceAutoSync !== false,
254
- }).catch(() => {})
255
- }
256
- return
257
- }
258
-
259
- if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
260
- const fromDisk = await loadModelsDiskCache(cacheFile, cacheIdentity)
261
- if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
262
- const migratedDisk = fromDisk.map(migrateModelEntry)
263
- const next = plainConfig(Config({
264
- ...plainConfig(live()),
265
- dynamicModels: migratedDisk,
266
- planSyncedAt: 0,
267
- }))
268
- await settingsApi.replace(next)
269
- await syncProviderState(next)
270
- }
271
- }
272
- } catch {
273
- /* best-effort discovery */
274
- }
275
- }
1
+ import { getStatsSummary } from './stats-storage.js'
2
+
3
+ import path from 'node:path'
4
+ import {
5
+ PROVIDER_ID,
6
+ PROVIDER_DISPLAY_NAME,
7
+ getAllModels,
8
+ saveModelsDiskCache,
9
+ loadModelsDiskCache,
10
+ OBSOLETE_MODEL_ID_MAP,
11
+ migrateModelEntry,
12
+ isCatalogDifferent,
13
+ } from './models.js'
14
+ import { ensurePublicPricesForModels } from './price-sync.js'
15
+ import {
16
+ probeHealth,
17
+ fetchUsageLimits,
18
+ sessionStats,
19
+ DEFAULT_API_KEY_ENV,
20
+ } from './cline-client.js'
21
+ import { publicConfig, plainConfig, Config } from './config.js'
22
+ import { publicUsage } from './http.js'
23
+ import { resolveKeyValue } from './credential-refs.js'
24
+ import { resolveAccountPool, getLastRotation } from './account-pool.js'
25
+ import { isSafeDataPath, resolveDataPath } from './path-safety.js'
26
+
27
+ export function resolvePathWithHome(p) {
28
+ return resolveDataPath(p, '')
29
+ }
30
+
31
+ async function checkRegisteredInLlm(ctx) {
32
+ try {
33
+ const llm = ctx?.get?.('llm')
34
+ if (llm && typeof llm.listProviders === 'function') {
35
+ const providers = await llm.listProviders()
36
+ if (Array.isArray(providers) && providers.some((entry) => (entry?.id || entry) === PROVIDER_ID)) {
37
+ return true
38
+ }
39
+ }
40
+ } catch {
41
+ /* llm service may not be mounted */
42
+ }
43
+ return false
44
+ }
45
+
46
+ export async function resolveActiveAccountKey(ctx, cfg, pool = null) {
47
+ const accountPool = pool || await resolveAccountPool(ctx, cfg)
48
+ const configured = accountPool.filter((acc) => acc.present && acc.value)
49
+ if (!configured.length) {
50
+ const fallbackEnv = publicConfig(cfg).apiKeyEnv || DEFAULT_API_KEY_ENV
51
+ return { envName: fallbackEnv, apiKeyEnv: fallbackEnv, value: '', source: 'none', id: 'default' }
52
+ }
53
+ const pub = publicConfig(cfg)
54
+ let chosen = configured[0]
55
+ if (pub.activeAccount) {
56
+ const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
57
+ if (pinned) chosen = pinned
58
+ }
59
+ const keyEnv = chosen.apiKeyEnv || chosen.envName || DEFAULT_API_KEY_ENV
60
+ return {
61
+ ...chosen,
62
+ envName: keyEnv,
63
+ apiKeyEnv: keyEnv,
64
+ }
65
+ }
66
+
67
+ export async function buildStatus(ctx, cfg) {
68
+ const pub = publicConfig(cfg)
69
+ const probeTimeout = Math.min(2500, pub.timeoutMs || 2500)
70
+
71
+ const [key, pool, isRegistered, health] = await Promise.all([
72
+ resolveKeyValue(ctx, pub.apiKeyEnv),
73
+ resolveAccountPool(ctx, cfg),
74
+ checkRegisteredInLlm(ctx),
75
+ probeHealth(pub.baseUrl, { timeoutMs: probeTimeout }),
76
+ ])
77
+
78
+ const activeAcc = await resolveActiveAccountKey(ctx, cfg, pool)
79
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
80
+
81
+ let usage = null
82
+ const keyToUse = activeAcc.value || key.value
83
+ if (keyToUse) {
84
+ usage = await fetchUsageLimits(pub.baseUrl, keyToUse, { timeoutMs: probeTimeout }).catch(() => null)
85
+ }
86
+
87
+ let quotaWarning = null
88
+ if (usage?.windows) {
89
+ const fiveHour = usage.windows.fiveHour
90
+ const weekly = usage.windows.weekly
91
+ const monthly = usage.windows.monthly
92
+
93
+ if (fiveHour && typeof fiveHour.percentUsed === 'number') {
94
+ if (fiveHour.percentUsed >= 95) {
95
+ quotaWarning = {
96
+ level: 'exhausted',
97
+ code: 'quota.exhausted_5h',
98
+ params: { percentUsed: fiveHour.percentUsed },
99
+ message: `5-hour rolling limit is almost exhausted (${fiveHour.percentUsed}%). New requests may be rejected until quota reset.`,
100
+ resetsAt: fiveHour.resetsAt,
101
+ }
102
+ } else if (fiveHour.percentUsed >= 90) {
103
+ quotaWarning = {
104
+ level: 'warning',
105
+ code: 'quota.warning_5h',
106
+ params: { percentUsed: fiveHour.percentUsed },
107
+ message: `Low quota warning: ${fiveHour.percentUsed}% of 5-hour limit consumed (<10% remaining).`,
108
+ resetsAt: fiveHour.resetsAt,
109
+ }
110
+ }
111
+ }
112
+
113
+ if (!quotaWarning && weekly && typeof weekly.percentUsed === 'number' && weekly.percentUsed >= 90) {
114
+ const remainingPercent = weekly.remainingPercent ?? (100 - weekly.percentUsed)
115
+ quotaWarning = {
116
+ level: weekly.percentUsed >= 99 ? 'exhausted' : 'warning',
117
+ code: 'quota.warning_weekly',
118
+ params: { percentUsed: weekly.percentUsed, remainingPercent },
119
+ message: `Low weekly quota warning: ${weekly.percentUsed}% consumed (${remainingPercent}% remaining).`,
120
+ resetsAt: weekly.resetsAt,
121
+ }
122
+ }
123
+
124
+ if (!quotaWarning && monthly && typeof monthly.percentUsed === 'number' && monthly.percentUsed >= 90) {
125
+ const remainingPercent = monthly.remainingPercent ?? (100 - monthly.percentUsed)
126
+ quotaWarning = {
127
+ level: monthly.percentUsed >= 99 ? 'exhausted' : 'warning',
128
+ code: 'quota.warning_monthly',
129
+ params: { percentUsed: monthly.percentUsed, remainingPercent },
130
+ message: `Low monthly quota warning: ${monthly.percentUsed}% consumed (${remainingPercent}% remaining).`,
131
+ resetsAt: monthly.resetsAt,
132
+ }
133
+ }
134
+ }
135
+
136
+ return {
137
+ ok: true,
138
+ providerId: PROVIDER_ID,
139
+ displayName: PROVIDER_DISPLAY_NAME,
140
+ config: pub,
141
+ key: {
142
+ envName: activeAcc.apiKeyEnv || key.envName,
143
+ present: !!(activeAcc.value || key.value),
144
+ source: activeAcc.source || key.source,
145
+ },
146
+ accounts: pool.map((acc) => {
147
+ return {
148
+ id: acc.id,
149
+ label: acc.label,
150
+ apiKeyEnv: acc.apiKeyEnv,
151
+ present: acc.present,
152
+ source: acc.source,
153
+ isPinned: acc.isPinned,
154
+ percentUsed: typeof acc.percentUsed === 'number' ? acc.percentUsed : null,
155
+ }
156
+ }),
157
+ activeAccount: activeAcc.apiKeyEnv,
158
+ health,
159
+ usage: publicUsage(usage),
160
+ quotaWarning,
161
+ sessionStats: { ...sessionStats },
162
+ statsSummary: getStatsSummary(),
163
+ lastRotation: getLastRotation(),
164
+ isRegistered,
165
+ availableModels: allModels,
166
+ }
167
+ }
168
+
169
+ export function formatProgressBar(pct, totalWidth = 10) {
170
+ const clamped = Math.max(0, Math.min(100, pct || 0))
171
+ const filled = Math.round((clamped / 100) * totalWidth)
172
+ const empty = Math.max(0, totalWidth - filled)
173
+ return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${clamped}%`
174
+ }
175
+
176
+ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncProviderState }) {
177
+ try {
178
+ const cfg = live()
179
+ const pub = publicConfig(cfg)
180
+ const cacheFile = isSafeDataPath(pub.modelsCachePath) ? resolvePathWithHome(pub.modelsCachePath) : ''
181
+ const settingsApi = getSettingsApi()
182
+
183
+ // Auto-migrate obsolete IDs (e.g. deepseek-v41-flash -> deepseek-v4.1-flash)
184
+ const existingDynamic = pub.dynamicModels || []
185
+ const hasObsolete = existingDynamic.some((m) => Boolean(OBSOLETE_MODEL_ID_MAP[m?.id]))
186
+ if (hasObsolete && settingsApi?.replace) {
187
+ const migrated = existingDynamic.map(migrateModelEntry)
188
+ const next = plainConfig(Config({
189
+ ...plainConfig(live()),
190
+ dynamicModels: migrated,
191
+ }))
192
+ await settingsApi.replace(next)
193
+ await syncProviderState(next)
194
+ }
195
+
196
+ const activeAcc = await resolveActiveAccountKey(ctx, cfg)
197
+ if (!activeAcc.value) return
198
+
199
+ const cacheIdentity = {
200
+ endpoint: pub.baseUrl,
201
+ accountRef: activeAcc.envName || activeAcc.id || pub.apiKeyEnv,
202
+ }
203
+
204
+ let usageData = null
205
+ try {
206
+ usageData = await fetchUsageLimits(pub.baseUrl, activeAcc.value, {
207
+ timeoutMs: Math.min(pub.timeoutMs, 5000),
208
+ bypassCache: true,
209
+ includeCatalog: true,
210
+ })
211
+ } catch {
212
+ usageData = null
213
+ }
214
+
215
+ if (usageData?.ok && Array.isArray(usageData.dynamicModels)) {
216
+ const existingDynamic = pub.dynamicModels || []
217
+ const isDifferent = isCatalogDifferent(existingDynamic, usageData.dynamicModels)
218
+ const now = Date.now()
219
+
220
+ if (isDifferent && settingsApi?.replace) {
221
+ const next = plainConfig(Config({
222
+ ...plainConfig(live()),
223
+ dynamicModels: usageData.dynamicModels,
224
+ planSyncedAt: now,
225
+ }))
226
+ await settingsApi.replace(next)
227
+ await syncProviderState(next)
228
+ }
229
+ if (cacheFile) {
230
+ await saveModelsDiskCache(cacheFile, usageData.dynamicModels, now, cacheIdentity)
231
+ }
232
+ if (isDifferent) {
233
+ await ensurePublicPricesForModels(usageData.dynamicModels.map((model) => model.id), {
234
+ url: pub.priceCatalogUrl,
235
+ cachePath: isSafeDataPath(pub.priceCatalogPath) ? pub.priceCatalogPath : undefined,
236
+ enabled: pub.priceAutoSync !== false,
237
+ }).catch(() => {})
238
+ }
239
+ return
240
+ }
241
+
242
+ if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
243
+ const fromDisk = await loadModelsDiskCache(cacheFile, cacheIdentity)
244
+ if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
245
+ const migratedDisk = fromDisk.map(migrateModelEntry)
246
+ const next = plainConfig(Config({
247
+ ...plainConfig(live()),
248
+ dynamicModels: migratedDisk,
249
+ planSyncedAt: 0,
250
+ }))
251
+ await settingsApi.replace(next)
252
+ await syncProviderState(next)
253
+ }
254
+ }
255
+ } catch {
256
+ /* best-effort discovery */
257
+ }
258
+ }