@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.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +5 -5
- package/lib/config.js +283 -283
- package/lib/http.js +2 -2
- package/lib/index.js +427 -426
- package/lib/path-safety.js +57 -4
- package/lib/price-sync.js +2 -5
- package/lib/provider-sync.js +258 -275
- package/lib/stats-storage.js +2 -27
- package/lib/transport/cline-transport.js +342 -343
- package/lib/updater.js +4 -9
- package/package.json +117 -117
package/lib/path-safety.js
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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) {
|
package/lib/provider-sync.js
CHANGED
|
@@ -1,275 +1,258 @@
|
|
|
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 } from './path-safety.js'
|
|
26
|
-
|
|
27
|
-
export function resolvePathWithHome(p) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
+
}
|