@goodandready/dsh-clinebot 0.5.33 → 0.6.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.
@@ -1,387 +1,404 @@
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 {
15
- probeHealth,
16
- fetchUsageLimits,
17
- sessionStats,
18
- usageCache,
19
- buildPiAiProvider,
20
- DEFAULT_API_KEY_ENV,
21
- } from './cline-client.js'
22
- import { publicConfig, plainConfig, Config, LLM_PI_AI_NS } from './config.js'
23
- import { publicUsage } from './http.js'
24
- import { resolveKeyValue } from './credential-refs.js'
25
- import { resolveAccountPool, getLastRotation } from './account-pool.js'
26
- import { getLocalProxyToken, LOCAL_PROXY_KEY_ENV } from './proxy-token.js'
27
- import { saveCredentialKey } from './credential-refs.js'
28
- import { getProxyPort, detectHostWebServerFence } from './proxy-server.js'
29
-
30
- export function resolvePathWithHome(p) {
31
- let guard = 0
32
- while (guard < 5 && p) {
33
- if (typeof p === 'function') {
34
- try { p = p() } catch { p = ''; break }
35
- guard += 1
36
- } else if (typeof p === 'object' && typeof p.get === 'function') {
37
- try { p = p.get() } catch { p = ''; break }
38
- guard += 1
39
- } else {
40
- break
41
- }
42
- }
43
- if (typeof p !== 'string') return ''
44
- if (p.startsWith('~/') || p === '~') {
45
- const home = process.env.HOME || os.homedir()
46
- return path.join(home, p.slice(1))
47
- }
48
- return p
49
- }
50
-
51
- export async function checkRegisteredInPiAi(ctx) {
52
- const settings = ctx?.get?.('settings')
53
- try {
54
- if (typeof settings?.describe === 'function') {
55
- const descriptors = settings.describe()
56
- const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
57
- if (piAiDesc?.value?.providers?.[PROVIDER_ID]) return true
58
- }
59
-
60
- const llm = ctx?.get?.('llm')
61
- if (typeof llm?.listProviders === 'function') {
62
- const list = await llm.listProviders()
63
- if (Array.isArray(list) && list.some((p) => (p?.id || p) === PROVIDER_ID)) {
64
- return true
65
- }
66
- }
67
- } catch {
68
- return false
69
- }
70
- return false
71
- }
72
-
73
- export async function resolveActiveAccountKey(ctx, cfg, pool = null) {
74
- const accountPool = pool || await resolveAccountPool(ctx, cfg)
75
- const configured = accountPool.filter((acc) => acc.present && acc.value)
76
- if (!configured.length) {
77
- const fallbackEnv = publicConfig(cfg).apiKeyEnv || DEFAULT_API_KEY_ENV
78
- return { envName: fallbackEnv, apiKeyEnv: fallbackEnv, value: '', source: 'none', id: 'default' }
79
- }
80
- const pub = publicConfig(cfg)
81
- let chosen = configured[0]
82
- if (pub.activeAccount) {
83
- const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
84
- if (pinned) chosen = pinned
85
- }
86
- const keyEnv = chosen.apiKeyEnv || chosen.envName || DEFAULT_API_KEY_ENV
87
- return {
88
- ...chosen,
89
- envName: keyEnv,
90
- apiKeyEnv: keyEnv,
91
- }
92
- }
93
-
94
- export async function buildStatus(ctx, cfg) {
95
- const pub = publicConfig(cfg)
96
- const probeTimeout = Math.min(2500, pub.timeoutMs || 2500)
97
-
98
- const [key, pool, isRegistered, health] = await Promise.all([
99
- resolveKeyValue(ctx, pub.apiKeyEnv),
100
- resolveAccountPool(ctx, cfg),
101
- checkRegisteredInPiAi(ctx),
102
- probeHealth(pub.baseUrl, { timeoutMs: probeTimeout }),
103
- ])
104
-
105
- const activeAcc = await resolveActiveAccountKey(ctx, cfg, pool)
106
- const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
107
-
108
- let usage = null
109
- const keyToUse = activeAcc.value || key.value
110
- if (keyToUse) {
111
- usage = await fetchUsageLimits(pub.baseUrl, keyToUse, { timeoutMs: probeTimeout }).catch(() => null)
112
- }
113
-
114
- let quotaWarning = null
115
- if (usage?.windows) {
116
- const fiveHour = usage.windows.fiveHour
117
- const weekly = usage.windows.weekly
118
- const monthly = usage.windows.monthly
119
-
120
- if (fiveHour && typeof fiveHour.percentUsed === 'number') {
121
- if (fiveHour.percentUsed >= 95) {
122
- quotaWarning = {
123
- level: 'exhausted',
124
- code: 'quota.exhausted_5h',
125
- params: { percentUsed: fiveHour.percentUsed },
126
- message: `5-hour rolling limit is almost exhausted (${fiveHour.percentUsed}%). New requests may be rejected until quota reset.`,
127
- resetsAt: fiveHour.resetsAt,
128
- }
129
- } else if (fiveHour.percentUsed >= 90) {
130
- quotaWarning = {
131
- level: 'warning',
132
- code: 'quota.warning_5h',
133
- params: { percentUsed: fiveHour.percentUsed },
134
- message: `Low quota warning: ${fiveHour.percentUsed}% of 5-hour limit consumed (<10% remaining).`,
135
- resetsAt: fiveHour.resetsAt,
136
- }
137
- }
138
- }
139
-
140
- if (!quotaWarning && weekly && typeof weekly.percentUsed === 'number' && weekly.percentUsed >= 90) {
141
- const remainingPercent = weekly.remainingPercent ?? (100 - weekly.percentUsed)
142
- quotaWarning = {
143
- level: weekly.percentUsed >= 99 ? 'exhausted' : 'warning',
144
- code: 'quota.warning_weekly',
145
- params: { percentUsed: weekly.percentUsed, remainingPercent },
146
- message: `Low weekly quota warning: ${weekly.percentUsed}% consumed (${remainingPercent}% remaining).`,
147
- resetsAt: weekly.resetsAt,
148
- }
149
- }
150
-
151
- if (!quotaWarning && monthly && typeof monthly.percentUsed === 'number' && monthly.percentUsed >= 90) {
152
- const remainingPercent = monthly.remainingPercent ?? (100 - monthly.percentUsed)
153
- quotaWarning = {
154
- level: monthly.percentUsed >= 99 ? 'exhausted' : 'warning',
155
- code: 'quota.warning_monthly',
156
- params: { percentUsed: monthly.percentUsed, remainingPercent },
157
- message: `Low monthly quota warning: ${monthly.percentUsed}% consumed (${remainingPercent}% remaining).`,
158
- resetsAt: monthly.resetsAt,
159
- }
160
- }
161
- }
162
-
163
- return {
164
- ok: true,
165
- providerId: PROVIDER_ID,
166
- displayName: PROVIDER_DISPLAY_NAME,
167
- config: pub,
168
- key: {
169
- envName: activeAcc.apiKeyEnv || key.envName,
170
- present: !!(activeAcc.value || key.value),
171
- source: activeAcc.source || key.source,
172
- },
173
- accounts: pool.map((acc) => {
174
- return {
175
- id: acc.id,
176
- label: acc.label,
177
- apiKeyEnv: acc.apiKeyEnv,
178
- present: acc.present,
179
- source: acc.source,
180
- isPinned: acc.isPinned,
181
- percentUsed: typeof acc.percentUsed === 'number' ? acc.percentUsed : null,
182
- }
183
- }),
184
- activeAccount: activeAcc.apiKeyEnv,
185
- health,
186
- usage: publicUsage(usage),
187
- quotaWarning,
188
- sessionStats: { ...sessionStats },
189
- statsSummary: getStatsSummary(),
190
- lastRotation: getLastRotation(),
191
- isRegistered,
192
- availableModels: allModels,
193
- }
194
- }
195
-
196
- export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
197
- const settings = ctx?.get?.('settings')
198
- if (!settings?.mutate) {
199
- throw new Error('DSH settings service unavailable')
200
- }
201
-
202
- const pub = publicConfig(cfg)
203
- const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
204
- const allowedSet = new Set(activeModelIds || pub.enabledModels)
205
- const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
206
-
207
- const activeAcc = await resolveActiveAccountKey(ctx, cfg)
208
-
209
- let isProxy = pub.proxyMode !== false
210
- let effectiveBaseUrl = pub.baseUrl
211
- let proxyPort = getProxyPort()
212
-
213
- if (isProxy) {
214
- if (!proxyPort) {
215
- proxyPort = ctx?.webServer?.port || ctx?.get?.('webServer')?.port || process.env.PORT || 3080
216
- const isFenced = await detectHostWebServerFence(proxyPort)
217
- if (isFenced) {
218
- ctx?.logger?.warn?.(
219
- `[dsh-clinebot] DesktopWebServer security fence detected on port ${proxyPort} and dedicated loopback proxy is not active. Falling back to direct upstream mode.`
220
- )
221
- isProxy = false
222
- }
223
- }
224
- }
225
-
226
- if (isProxy && proxyPort) {
227
- effectiveBaseUrl = `http://127.0.0.1:${proxyPort}/dsh-clinebot/v1`
228
- } else {
229
- isProxy = false
230
- effectiveBaseUrl = pub.baseUrl
231
- }
232
-
233
- const localToken = isProxy ? getLocalProxyToken() : undefined
234
- if (isProxy && localToken) {
235
- try {
236
- await saveCredentialKey(ctx, LOCAL_PROXY_KEY_ENV, localToken)
237
- } catch {
238
- /* credentials service might be absent/read-only in tests; process.env is set */
239
- }
240
- }
241
-
242
- let existingModels = []
243
- try {
244
- if (typeof settings.describe === 'function') {
245
- const descriptors = settings.describe()
246
- const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
247
- const existingProv = piAiDesc?.value?.providers?.[PROVIDER_ID]
248
- if (Array.isArray(existingProv?.models)) existingModels = existingProv.models
249
- } else if (typeof settings.get === 'function') {
250
- const piAiVal = settings.get(LLM_PI_AI_NS)
251
- const existingProv = piAiVal?.providers?.[PROVIDER_ID]
252
- if (Array.isArray(existingProv?.models)) existingModels = existingProv.models
253
- }
254
- } catch {
255
- /* ignore read error */
256
- }
257
-
258
- const providerObj = buildPiAiProvider({
259
- baseUrl: effectiveBaseUrl,
260
- apiKeyEnv: isProxy ? LOCAL_PROXY_KEY_ENV : (activeAcc?.apiKeyEnv || pub.apiKeyEnv),
261
- models: modelsToRegister.length ? modelsToRegister : allModels,
262
- dynamicModels: pub.dynamicModels,
263
- modelContextOverrides: pub.modelContextOverrides,
264
- customModels: pub.customModels,
265
- existingModels,
266
- displayName: PROVIDER_DISPLAY_NAME,
267
- })
268
-
269
- await settings.mutate(LLM_PI_AI_NS, [
270
- {
271
- op: 'set',
272
- path: ['providers', PROVIDER_ID],
273
- value: providerObj,
274
- },
275
- ])
276
-
277
- ctx?.logger?.info?.(
278
- `[dsh-clinebot] Synchronized llm-pi-ai provider "${PROVIDER_ID}" with ${providerObj.models.length} models.`
279
- )
280
-
281
- return providerObj
282
- }
283
-
284
- export async function removePiAiProvider(ctx) {
285
- const settings = ctx?.get?.('settings')
286
- if (!settings?.mutate) {
287
- throw new Error('DSH settings service unavailable')
288
- }
289
-
290
- try {
291
- await settings.mutate(LLM_PI_AI_NS, [
292
- {
293
- op: 'remove',
294
- path: ['providers', PROVIDER_ID],
295
- },
296
- ])
297
- } catch (err) {
298
- const msg = String(err?.message || err).toLowerCase()
299
- if (!msg.includes('not found') && !msg.includes('absent') && !msg.includes('enoent') && !msg.includes('does not exist')) {
300
- throw err
301
- }
302
- }
303
- return { ok: true }
304
- }
305
-
306
- export function formatProgressBar(pct, totalWidth = 10) {
307
- const clamped = Math.max(0, Math.min(100, pct || 0))
308
- const filled = Math.round((clamped / 100) * totalWidth)
309
- const empty = Math.max(0, totalWidth - filled)
310
- return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${clamped}%`
311
- }
312
-
313
- export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncProviderState }) {
314
- try {
315
- const cfg = live()
316
- const pub = publicConfig(cfg)
317
- const cacheFile = resolvePathWithHome(pub.modelsCachePath)
318
- const settingsApi = getSettingsApi()
319
-
320
- // Auto-migrate obsolete IDs (e.g. deepseek-v41-flash -> deepseek-v4.1-flash)
321
- const existingDynamic = pub.dynamicModels || []
322
- const hasObsolete = existingDynamic.some((m) => Boolean(OBSOLETE_MODEL_ID_MAP[m?.id]))
323
- if (hasObsolete && settingsApi?.replace) {
324
- const migrated = existingDynamic.map(migrateModelEntry)
325
- const next = plainConfig(Config({
326
- ...plainConfig(live()),
327
- dynamicModels: migrated,
328
- }))
329
- await settingsApi.replace(next)
330
- await syncProviderState(next)
331
- }
332
-
333
- const activeAcc = await resolveActiveAccountKey(ctx, cfg)
334
- if (!activeAcc.value) return
335
-
336
- const cacheIdentity = {
337
- endpoint: pub.baseUrl,
338
- accountRef: activeAcc.envName || activeAcc.id || pub.apiKeyEnv,
339
- }
340
-
341
- let usageData = null
342
- try {
343
- usageData = await fetchUsageLimits(pub.baseUrl, activeAcc.value, {
344
- timeoutMs: Math.min(pub.timeoutMs, 5000),
345
- bypassCache: true,
346
- })
347
- } catch {
348
- usageData = null
349
- }
350
-
351
- if (usageData?.ok && Array.isArray(usageData.dynamicModels)) {
352
- const existingDynamic = pub.dynamicModels || []
353
- const isDifferent = isCatalogDifferent(existingDynamic, usageData.dynamicModels)
354
- const now = Date.now()
355
-
356
- if (isDifferent && settingsApi?.replace) {
357
- const next = plainConfig(Config({
358
- ...plainConfig(live()),
359
- dynamicModels: usageData.dynamicModels,
360
- planSyncedAt: now,
361
- }))
362
- await settingsApi.replace(next)
363
- await syncProviderState(next)
364
- }
365
- if (cacheFile) {
366
- await saveModelsDiskCache(cacheFile, usageData.dynamicModels, now, cacheIdentity)
367
- }
368
- return
369
- }
370
-
371
- if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
372
- const fromDisk = await loadModelsDiskCache(cacheFile, cacheIdentity)
373
- if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
374
- const migratedDisk = fromDisk.map(migrateModelEntry)
375
- const next = plainConfig(Config({
376
- ...plainConfig(live()),
377
- dynamicModels: migratedDisk,
378
- planSyncedAt: 0,
379
- }))
380
- await settingsApi.replace(next)
381
- await syncProviderState(next)
382
- }
383
- }
384
- } catch {
385
- /* best-effort discovery */
386
- }
387
- }
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 {
15
+ probeHealth,
16
+ fetchUsageLimits,
17
+ sessionStats,
18
+ usageCache,
19
+ buildPiAiProvider,
20
+ DEFAULT_API_KEY_ENV,
21
+ } from './cline-client.js'
22
+ import { publicConfig, plainConfig, Config, LLM_PI_AI_NS } from './config.js'
23
+ import { publicUsage } from './http.js'
24
+ import { resolveKeyValue } from './credential-refs.js'
25
+ import { resolveAccountPool, getLastRotation } from './account-pool.js'
26
+ import { getLocalProxyToken, LOCAL_PROXY_KEY_ENV } from './proxy-token.js'
27
+ import { saveCredentialKey } from './credential-refs.js'
28
+ import { getProxyPort, detectHostWebServerFence } from './proxy-server.js'
29
+
30
+ export function resolvePathWithHome(p) {
31
+ let guard = 0
32
+ while (guard < 5 && p) {
33
+ if (typeof p === 'function') {
34
+ try { p = p() } catch { p = ''; break }
35
+ guard += 1
36
+ } else if (typeof p === 'object' && typeof p.get === 'function') {
37
+ try { p = p.get() } catch { p = ''; break }
38
+ guard += 1
39
+ } else {
40
+ break
41
+ }
42
+ }
43
+ if (typeof p !== 'string') return ''
44
+ if (p.startsWith('~/') || p === '~') {
45
+ const home = process.env.HOME || os.homedir()
46
+ return path.join(home, p.slice(1))
47
+ }
48
+ return p
49
+ }
50
+
51
+ export async function checkRegisteredInPiAi(ctx) {
52
+ const settings = ctx?.get?.('settings')
53
+ try {
54
+ if (typeof settings?.describe === 'function') {
55
+ const descriptors = settings.describe()
56
+ const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
57
+ if (piAiDesc?.value?.providers?.[PROVIDER_ID]) return true
58
+ }
59
+
60
+ const llm = ctx?.get?.('llm')
61
+ if (typeof llm?.listProviders === 'function') {
62
+ const list = await llm.listProviders()
63
+ if (Array.isArray(list) && list.some((p) => (p?.id || p) === PROVIDER_ID)) {
64
+ return true
65
+ }
66
+ }
67
+ } catch {
68
+ return false
69
+ }
70
+ return false
71
+ }
72
+
73
+ export async function checkRegisteredInLlm(ctx) {
74
+ try {
75
+ const llm = ctx?.get?.('llm')
76
+ if (llm && typeof llm.listProviders === 'function') {
77
+ const providers = await llm.listProviders()
78
+ if (Array.isArray(providers) && providers.some((entry) => (entry?.id || entry) === PROVIDER_ID)) {
79
+ return true
80
+ }
81
+ }
82
+ } catch {
83
+ /* llm service may not be mounted */
84
+ }
85
+ return false
86
+ }
87
+
88
+ export async function resolveActiveAccountKey(ctx, cfg, pool = null) {
89
+ const accountPool = pool || await resolveAccountPool(ctx, cfg)
90
+ const configured = accountPool.filter((acc) => acc.present && acc.value)
91
+ if (!configured.length) {
92
+ const fallbackEnv = publicConfig(cfg).apiKeyEnv || DEFAULT_API_KEY_ENV
93
+ return { envName: fallbackEnv, apiKeyEnv: fallbackEnv, value: '', source: 'none', id: 'default' }
94
+ }
95
+ const pub = publicConfig(cfg)
96
+ let chosen = configured[0]
97
+ if (pub.activeAccount) {
98
+ const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
99
+ if (pinned) chosen = pinned
100
+ }
101
+ const keyEnv = chosen.apiKeyEnv || chosen.envName || DEFAULT_API_KEY_ENV
102
+ return {
103
+ ...chosen,
104
+ envName: keyEnv,
105
+ apiKeyEnv: keyEnv,
106
+ }
107
+ }
108
+
109
+ export async function buildStatus(ctx, cfg) {
110
+ const pub = publicConfig(cfg)
111
+ const probeTimeout = Math.min(2500, pub.timeoutMs || 2500)
112
+
113
+ const [key, pool, isRegisteredInPiAi, isRegisteredInLlm, health] = await Promise.all([
114
+ resolveKeyValue(ctx, pub.apiKeyEnv),
115
+ resolveAccountPool(ctx, cfg),
116
+ checkRegisteredInPiAi(ctx),
117
+ checkRegisteredInLlm(ctx),
118
+ probeHealth(pub.baseUrl, { timeoutMs: probeTimeout }),
119
+ ])
120
+ const isRegistered = isRegisteredInLlm || isRegisteredInPiAi
121
+
122
+ const activeAcc = await resolveActiveAccountKey(ctx, cfg, pool)
123
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
124
+
125
+ let usage = null
126
+ const keyToUse = activeAcc.value || key.value
127
+ if (keyToUse) {
128
+ usage = await fetchUsageLimits(pub.baseUrl, keyToUse, { timeoutMs: probeTimeout }).catch(() => null)
129
+ }
130
+
131
+ let quotaWarning = null
132
+ if (usage?.windows) {
133
+ const fiveHour = usage.windows.fiveHour
134
+ const weekly = usage.windows.weekly
135
+ const monthly = usage.windows.monthly
136
+
137
+ if (fiveHour && typeof fiveHour.percentUsed === 'number') {
138
+ if (fiveHour.percentUsed >= 95) {
139
+ quotaWarning = {
140
+ level: 'exhausted',
141
+ code: 'quota.exhausted_5h',
142
+ params: { percentUsed: fiveHour.percentUsed },
143
+ message: `5-hour rolling limit is almost exhausted (${fiveHour.percentUsed}%). New requests may be rejected until quota reset.`,
144
+ resetsAt: fiveHour.resetsAt,
145
+ }
146
+ } else if (fiveHour.percentUsed >= 90) {
147
+ quotaWarning = {
148
+ level: 'warning',
149
+ code: 'quota.warning_5h',
150
+ params: { percentUsed: fiveHour.percentUsed },
151
+ message: `Low quota warning: ${fiveHour.percentUsed}% of 5-hour limit consumed (<10% remaining).`,
152
+ resetsAt: fiveHour.resetsAt,
153
+ }
154
+ }
155
+ }
156
+
157
+ if (!quotaWarning && weekly && typeof weekly.percentUsed === 'number' && weekly.percentUsed >= 90) {
158
+ const remainingPercent = weekly.remainingPercent ?? (100 - weekly.percentUsed)
159
+ quotaWarning = {
160
+ level: weekly.percentUsed >= 99 ? 'exhausted' : 'warning',
161
+ code: 'quota.warning_weekly',
162
+ params: { percentUsed: weekly.percentUsed, remainingPercent },
163
+ message: `Low weekly quota warning: ${weekly.percentUsed}% consumed (${remainingPercent}% remaining).`,
164
+ resetsAt: weekly.resetsAt,
165
+ }
166
+ }
167
+
168
+ if (!quotaWarning && monthly && typeof monthly.percentUsed === 'number' && monthly.percentUsed >= 90) {
169
+ const remainingPercent = monthly.remainingPercent ?? (100 - monthly.percentUsed)
170
+ quotaWarning = {
171
+ level: monthly.percentUsed >= 99 ? 'exhausted' : 'warning',
172
+ code: 'quota.warning_monthly',
173
+ params: { percentUsed: monthly.percentUsed, remainingPercent },
174
+ message: `Low monthly quota warning: ${monthly.percentUsed}% consumed (${remainingPercent}% remaining).`,
175
+ resetsAt: monthly.resetsAt,
176
+ }
177
+ }
178
+ }
179
+
180
+ return {
181
+ ok: true,
182
+ providerId: PROVIDER_ID,
183
+ displayName: PROVIDER_DISPLAY_NAME,
184
+ config: pub,
185
+ key: {
186
+ envName: activeAcc.apiKeyEnv || key.envName,
187
+ present: !!(activeAcc.value || key.value),
188
+ source: activeAcc.source || key.source,
189
+ },
190
+ accounts: pool.map((acc) => {
191
+ return {
192
+ id: acc.id,
193
+ label: acc.label,
194
+ apiKeyEnv: acc.apiKeyEnv,
195
+ present: acc.present,
196
+ source: acc.source,
197
+ isPinned: acc.isPinned,
198
+ percentUsed: typeof acc.percentUsed === 'number' ? acc.percentUsed : null,
199
+ }
200
+ }),
201
+ activeAccount: activeAcc.apiKeyEnv,
202
+ health,
203
+ usage: publicUsage(usage),
204
+ quotaWarning,
205
+ sessionStats: { ...sessionStats },
206
+ statsSummary: getStatsSummary(),
207
+ lastRotation: getLastRotation(),
208
+ isRegistered,
209
+ availableModels: allModels,
210
+ }
211
+ }
212
+
213
+ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
214
+ const settings = ctx?.get?.('settings')
215
+ if (!settings?.mutate) {
216
+ throw new Error('DSH settings service unavailable')
217
+ }
218
+
219
+ const pub = publicConfig(cfg)
220
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
221
+ const allowedSet = new Set(activeModelIds || pub.enabledModels)
222
+ const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
223
+
224
+ const activeAcc = await resolveActiveAccountKey(ctx, cfg)
225
+
226
+ let isProxy = pub.proxyMode !== false
227
+ let effectiveBaseUrl = pub.baseUrl
228
+ let proxyPort = getProxyPort()
229
+
230
+ if (isProxy) {
231
+ if (!proxyPort) {
232
+ proxyPort = ctx?.webServer?.port || ctx?.get?.('webServer')?.port || process.env.PORT || 3080
233
+ const isFenced = await detectHostWebServerFence(proxyPort)
234
+ if (isFenced) {
235
+ ctx?.logger?.warn?.(
236
+ `[dsh-clinebot] DesktopWebServer security fence detected on port ${proxyPort} and dedicated loopback proxy is not active. Falling back to direct upstream mode.`
237
+ )
238
+ isProxy = false
239
+ }
240
+ }
241
+ }
242
+
243
+ if (isProxy && proxyPort) {
244
+ effectiveBaseUrl = `http://127.0.0.1:${proxyPort}/dsh-clinebot/v1`
245
+ } else {
246
+ isProxy = false
247
+ effectiveBaseUrl = pub.baseUrl
248
+ }
249
+
250
+ const localToken = isProxy ? getLocalProxyToken() : undefined
251
+ if (isProxy && localToken) {
252
+ try {
253
+ await saveCredentialKey(ctx, LOCAL_PROXY_KEY_ENV, localToken)
254
+ } catch {
255
+ /* credentials service might be absent/read-only in tests; process.env is set */
256
+ }
257
+ }
258
+
259
+ let existingModels = []
260
+ try {
261
+ if (typeof settings.describe === 'function') {
262
+ const descriptors = settings.describe()
263
+ const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
264
+ const existingProv = piAiDesc?.value?.providers?.[PROVIDER_ID]
265
+ if (Array.isArray(existingProv?.models)) existingModels = existingProv.models
266
+ } else if (typeof settings.get === 'function') {
267
+ const piAiVal = settings.get(LLM_PI_AI_NS)
268
+ const existingProv = piAiVal?.providers?.[PROVIDER_ID]
269
+ if (Array.isArray(existingProv?.models)) existingModels = existingProv.models
270
+ }
271
+ } catch {
272
+ /* ignore read error */
273
+ }
274
+
275
+ const providerObj = buildPiAiProvider({
276
+ baseUrl: effectiveBaseUrl,
277
+ apiKeyEnv: isProxy ? LOCAL_PROXY_KEY_ENV : (activeAcc?.apiKeyEnv || pub.apiKeyEnv),
278
+ models: modelsToRegister.length ? modelsToRegister : allModels,
279
+ dynamicModels: pub.dynamicModels,
280
+ modelContextOverrides: pub.modelContextOverrides,
281
+ customModels: pub.customModels,
282
+ existingModels,
283
+ displayName: PROVIDER_DISPLAY_NAME,
284
+ })
285
+
286
+ await settings.mutate(LLM_PI_AI_NS, [
287
+ {
288
+ op: 'set',
289
+ path: ['providers', PROVIDER_ID],
290
+ value: providerObj,
291
+ },
292
+ ])
293
+
294
+ ctx?.logger?.info?.(
295
+ `[dsh-clinebot] Synchronized llm-pi-ai provider "${PROVIDER_ID}" with ${providerObj.models.length} models.`
296
+ )
297
+
298
+ return providerObj
299
+ }
300
+
301
+ export async function removePiAiProvider(ctx) {
302
+ const settings = ctx?.get?.('settings')
303
+ if (!settings?.mutate) {
304
+ throw new Error('DSH settings service unavailable')
305
+ }
306
+
307
+ try {
308
+ await settings.mutate(LLM_PI_AI_NS, [
309
+ {
310
+ op: 'remove',
311
+ path: ['providers', PROVIDER_ID],
312
+ },
313
+ ])
314
+ } catch (err) {
315
+ const msg = String(err?.message || err).toLowerCase()
316
+ if (!msg.includes('not found') && !msg.includes('absent') && !msg.includes('enoent') && !msg.includes('does not exist')) {
317
+ throw err
318
+ }
319
+ }
320
+ return { ok: true }
321
+ }
322
+
323
+ export function formatProgressBar(pct, totalWidth = 10) {
324
+ const clamped = Math.max(0, Math.min(100, pct || 0))
325
+ const filled = Math.round((clamped / 100) * totalWidth)
326
+ const empty = Math.max(0, totalWidth - filled)
327
+ return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${clamped}%`
328
+ }
329
+
330
+ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncProviderState }) {
331
+ try {
332
+ const cfg = live()
333
+ const pub = publicConfig(cfg)
334
+ const cacheFile = resolvePathWithHome(pub.modelsCachePath)
335
+ const settingsApi = getSettingsApi()
336
+
337
+ // Auto-migrate obsolete IDs (e.g. deepseek-v41-flash -> deepseek-v4.1-flash)
338
+ const existingDynamic = pub.dynamicModels || []
339
+ const hasObsolete = existingDynamic.some((m) => Boolean(OBSOLETE_MODEL_ID_MAP[m?.id]))
340
+ if (hasObsolete && settingsApi?.replace) {
341
+ const migrated = existingDynamic.map(migrateModelEntry)
342
+ const next = plainConfig(Config({
343
+ ...plainConfig(live()),
344
+ dynamicModels: migrated,
345
+ }))
346
+ await settingsApi.replace(next)
347
+ await syncProviderState(next)
348
+ }
349
+
350
+ const activeAcc = await resolveActiveAccountKey(ctx, cfg)
351
+ if (!activeAcc.value) return
352
+
353
+ const cacheIdentity = {
354
+ endpoint: pub.baseUrl,
355
+ accountRef: activeAcc.envName || activeAcc.id || pub.apiKeyEnv,
356
+ }
357
+
358
+ let usageData = null
359
+ try {
360
+ usageData = await fetchUsageLimits(pub.baseUrl, activeAcc.value, {
361
+ timeoutMs: Math.min(pub.timeoutMs, 5000),
362
+ bypassCache: true,
363
+ })
364
+ } catch {
365
+ usageData = null
366
+ }
367
+
368
+ if (usageData?.ok && Array.isArray(usageData.dynamicModels)) {
369
+ const existingDynamic = pub.dynamicModels || []
370
+ const isDifferent = isCatalogDifferent(existingDynamic, usageData.dynamicModels)
371
+ const now = Date.now()
372
+
373
+ if (isDifferent && settingsApi?.replace) {
374
+ const next = plainConfig(Config({
375
+ ...plainConfig(live()),
376
+ dynamicModels: usageData.dynamicModels,
377
+ planSyncedAt: now,
378
+ }))
379
+ await settingsApi.replace(next)
380
+ await syncProviderState(next)
381
+ }
382
+ if (cacheFile) {
383
+ await saveModelsDiskCache(cacheFile, usageData.dynamicModels, now, cacheIdentity)
384
+ }
385
+ return
386
+ }
387
+
388
+ if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
389
+ const fromDisk = await loadModelsDiskCache(cacheFile, cacheIdentity)
390
+ if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
391
+ const migratedDisk = fromDisk.map(migrateModelEntry)
392
+ const next = plainConfig(Config({
393
+ ...plainConfig(live()),
394
+ dynamicModels: migratedDisk,
395
+ planSyncedAt: 0,
396
+ }))
397
+ await settingsApi.replace(next)
398
+ await syncProviderState(next)
399
+ }
400
+ }
401
+ } catch {
402
+ /* best-effort discovery */
403
+ }
404
+ }