@goodandready/dsh-subscriptions 0.6.21 → 0.6.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,96 +1,76 @@
1
+ import { readFileSync } from 'node:fs'
1
2
  import { bestEffort } from './utils.js'
2
3
  export { Config, publicConfig, Slot, defaultSlots } from './config-schema.js'
3
- import { Config, publicConfig } from './config-schema.js'
4
+ import { Config } from './config-schema.js'
4
5
  import { registerRoutes } from './routes.js'
5
- import { analyzeSessionEvents } from './analyze-session.js'
6
- import { discoverLocalCliSessions, loadLocalCliBlob } from './import-auth.js'
7
- import { readFileSync } from 'node:fs'
8
- import z from '@deepseek-ai/schemastery'
9
- import { PROVIDERS, oauthRef, parseOauthRef, isProvider, displayName, droppedCredentialRefs } from './refs.js'
10
- import { createPkce } from './pkce.js'
11
- import { parseCallbackInput, requestOrigin, webCallbackUri } from './oauth.js'
12
- import { writeJson, writeHtml, readBody, isTrustedSettingsRequest, queryOf } from './http.js'
6
+ import { oauthRef, parseOauthRef, isProvider, displayName } from './refs.js'
7
+ import { webCallbackUri } from './oauth.js'
13
8
  import { getVendor, registerCustomVendor, clearCustomVendors } from './vendors/index.js'
14
- import { SubscriptionAdapter } from './adapter.js'
15
9
  import { generateOnce, SIZES as IMAGE_SIZES } from './images.js'
16
10
  import { parseBlob } from './blob.js'
17
11
  import { createVendorFromProfile, validateProfile } from './vendor-factory.js'
18
12
  import { registerCustomProviderIds, registerDisplayName } from './refs.js'
19
13
  import { createSubscriptionsService } from './subscriptions.js'
20
- import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
21
- import { quotaSnapshot } from './ratelimit.js'
22
14
  import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
23
- import { credentialRef } from '@deepseek-ai/dsh-credentials'
24
- import { startLoopback } from './loopback.js'
25
15
  import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.js'
26
16
  import { createResetCreditService } from './reset-credits.js'
27
- import { maskEmail, maskLabel, maskText } from './mask.js'
28
- import { proxyFetch, pickFetch } from './proxy.js'
17
+ import { maskEmail, maskLabel } from './mask.js'
18
+ import { proxyFetch } from './proxy.js'
29
19
  import { HistoryStore, DEFAULT_HISTORY_DIR } from './history.js'
20
+ import { createDiagnosticsReport } from './diagnostics.js'
21
+ import { createAccountsView } from './accounts-view.js'
22
+ import { createAdapterManager } from './adapter-manager.js'
23
+
30
24
  export { observeForecast, estimateForecast } from './forecast.js'
31
25
  export { formatRelativeReset } from './relative-time.js'
32
- import {
33
- inspectGoogleAccount,
34
- antigravityMetadata,
35
- antigravityIdentityHeaders,
36
- } from './code-assist.js'
37
-
38
- import { resolveFallbackVendor, mapFallbackModel, DEFAULT_CASCADE_CHAINS } from './cascade.js'
39
- import { exportVault, importVault } from './vault.js'
40
- import { recordAlert, getRecentAlerts, checkQuotaThresholds, notifySessionExpired } from './alerts.js'
41
- import { calculateBurnRatePerHour, computePacingRisk } from './forecast.js'
42
- import { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure } from './quarantine.js'
43
26
  export { resolveFallbackVendor, mapFallbackModel, DEFAULT_CASCADE_CHAINS } from './cascade.js'
44
27
  export { exportVault, importVault } from './vault.js'
45
28
  export { recordAlert, getRecentAlerts, checkQuotaThresholds, notifySessionExpired } from './alerts.js'
46
29
  export { calculateBurnRatePerHour, computePacingRisk } from './forecast.js'
47
30
  export { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure } from './quarantine.js'
31
+
48
32
  export const name = '@goodandready/dsh-subscriptions'
49
33
  export const inject = ['llm', 'credentials', 'webServer', 'settings']
50
34
 
51
35
  const NS = 'dsh-subscriptions'
52
36
 
53
37
  let pkgVersion = ''
54
- try { pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '' } catch (err) { /* ignore package read errors at startup */ }
38
+ try { pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '' } catch { /* ignore package read errors at startup */ }
55
39
  const PENDING_TTL_MS = 15 * 60 * 1000
56
40
 
57
- function redirectFor(provider, cfg, origin) {
58
- const overlay = vendorConfig(provider, cfg)
59
- if (cfg.useWebCallback) return webCallbackUri(origin)
60
- return overlay.redirectUri || webCallbackUri(origin)
61
- }
62
-
63
- const OK_HTML = '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Signed in. You can close this tab and return to Settings.</p>'
64
-
65
- export function apply(ctx, config) {
66
- const logger = ctx.logger ? ctx.logger('subscriptions') : console
41
+ const OK_HTML = `<!doctype html>
42
+ <html>
43
+ <head>
44
+ <meta charset="utf-8">
45
+ <title>dsh-subscriptions - connected</title>
46
+ <style>
47
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
48
+ background: #0d1117; color: #c9d1d9; display: flex; align-items: center;
49
+ justify-content: center; height: 100vh; margin: 0; }
50
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px;
51
+ padding: 24px 32px; text-align: center; max-width: 360px; }
52
+ h1 { font-size: 18px; margin: 0 0 8px; color: #58a6ff; }
53
+ p { font-size: 13px; line-height: 1.5; margin: 0 0 16px; color: #8b949e; }
54
+ .btn { display: inline-block; background: #238636; color: #fff; padding: 6px 14px;
55
+ border-radius: 6px; text-decoration: none; font-size: 13px; font-weight: 500; }
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <div class="card">
60
+ <h1>Account connected</h1>
61
+ <p>Authentication token was saved. You can close this tab and return to DeepSeek Harness.</p>
62
+ <a class="btn" href="javascript:window.close()">Close window</a>
63
+ </div>
64
+ </body>
65
+ </html>`
66
+
67
+ export function apply(ctx, rawConfig) {
68
+ const logger = ctx.logger ? ctx.logger('subscriptions') : undefined
67
69
  let settingsApi
68
- // #367: maintain a single immutable config snapshot without structuredClone on hot path
69
- let cachedConfig = Config(config ?? {}) ?? config
70
-
71
- function syncSnapshot(raw) {
72
- if (raw !== undefined && raw !== null) {
73
- try {
74
- cachedConfig = Config(raw ?? {}) ?? config
75
- } catch {
76
- cachedConfig = config
77
- }
78
- } else if (settingsApi && typeof settingsApi.get === 'function') {
79
- try {
80
- const rawSettings = settingsApi.get()
81
- cachedConfig = Config(rawSettings ?? {}) ?? config
82
- } catch {
83
- cachedConfig = config
84
- }
85
- }
86
- syncCustomVendors()
87
- return cachedConfig
88
- }
89
70
 
71
+ let cachedConfig = Config(rawConfig || {})
90
72
  const live = () => cachedConfig
91
73
 
92
- // Registering custom vendors from Config. Runs at startup and on
93
- // every config change (replace) - the registry is rebuilt from scratch.
94
74
  function syncCustomVendors() {
95
75
  let profiles
96
76
  try {
@@ -111,60 +91,70 @@ export function apply(ctx, config) {
111
91
  }
112
92
  }
113
93
  }
114
-
115
94
  syncCustomVendors()
116
95
 
117
- ctx.inject(['settings'], (sctx) => {
118
- const scope = sctx.settings.register(NS, Config, { base: config })
119
- settingsApi = {
120
- ...scope,
121
- get: () => (typeof scope.get === 'function' ? scope.get() : cachedConfig),
122
- async replace(next) {
123
- syncSnapshot(next)
124
- if (typeof scope.replace === 'function') {
125
- return await scope.replace(next)
126
- }
127
- },
128
- }
96
+ const syncSnapshot = (next) => {
97
+ try {
98
+ const fresh = next !== undefined ? next : (typeof settingsApi?.get === 'function' ? settingsApi.get() : cachedConfig)
99
+ cachedConfig = Config(fresh || {})
100
+ syncCustomVendors()
101
+ syncAdapter().catch(() => {})
102
+ syncOllama().catch(() => {})
103
+ } catch { /* keep existing snapshot on error */ }
104
+ }
129
105
 
130
- if (typeof scope.get === 'function') {
131
- syncSnapshot(scope.get())
132
- }
106
+ if (typeof ctx.inject === 'function') {
107
+ ctx.inject(['settings'], (sctx) => {
108
+ const scope = sctx.settings.register(NS, Config, { base: rawConfig })
109
+ settingsApi = {
110
+ ...scope,
111
+ get: () => (typeof scope.get === 'function' ? scope.get() : cachedConfig),
112
+ async replace(next) {
113
+ syncSnapshot(next)
114
+ if (typeof scope.replace === 'function') {
115
+ return await scope.replace(next)
116
+ }
117
+ },
118
+ }
133
119
 
134
- if (typeof scope.watch === 'function') {
135
- try {
136
- const unwatch = scope.watch((next) => syncSnapshot(next))
137
- if (typeof unwatch === 'function') {
138
- sctx.effect(() => unwatch)
139
- }
140
- } catch { /* ignore watch error */ }
141
- }
120
+ if (typeof scope.get === 'function') {
121
+ syncSnapshot(scope.get())
122
+ }
142
123
 
143
- Promise.resolve().then(() => {
144
- reconcileSlots().catch((err) => {
145
- if (logger && logger.warn) logger.warn('[dsh-subscriptions] reconcileSlots initial error: ' + (err && err.message || err))
124
+ if (typeof scope.watch === 'function') {
125
+ try {
126
+ const unwatch = scope.watch((next) => syncSnapshot(next))
127
+ if (typeof unwatch === 'function') {
128
+ sctx.effect(() => unwatch)
129
+ }
130
+ } catch { /* ignore watch error */ }
131
+ }
132
+
133
+ Promise.resolve().then(() => {
134
+ reconcileSlots().catch((err) => {
135
+ if (logger && logger.warn) logger.warn('[dsh-subscriptions] reconcileSlots initial error: ' + (err && err.message || err))
136
+ })
146
137
  })
147
- })
148
138
 
149
- const onDocUpdated = (ns) => {
150
- if (!ns || ns === NS) {
151
- syncSnapshot()
139
+ const onDocUpdated = (ns) => {
140
+ if (!ns || ns === NS) {
141
+ syncSnapshot()
142
+ }
143
+ }
144
+ if (typeof sctx.on === 'function') {
145
+ sctx.on('settings/document-updated', onDocUpdated)
146
+ } else if (typeof ctx.on === 'function') {
147
+ ctx.on('settings/document-updated', onDocUpdated)
152
148
  }
153
- }
154
- if (typeof sctx.on === 'function') {
155
- sctx.on('settings/document-updated', onDocUpdated)
156
- } else if (typeof ctx.on === 'function') {
157
- ctx.on('settings/document-updated', onDocUpdated)
158
- }
159
149
 
160
- sctx.effect(() => () => {
161
- settingsApi = undefined
162
- cachedConfig = Config(config ?? {}) ?? config
163
- syncCustomVendors()
150
+ sctx.effect(() => () => {
151
+ settingsApi = undefined
152
+ cachedConfig = Config(rawConfig || {})
153
+ syncCustomVendors()
154
+ })
164
155
  })
165
- })
156
+ }
166
157
 
167
- // #88: fetch bound to the per-account proxy (slot.proxyUrl), or null for direct.
168
158
  const fetchForRef = (ref) => {
169
159
  const parsed = parseOauthRef(ref)
170
160
  if (!parsed) return null
@@ -173,6 +163,16 @@ export function apply(ctx, config) {
173
163
  return proxyFetch(slot.proxyUrl)
174
164
  }
175
165
 
166
+ function redirectFor(ref, provider) {
167
+ if (live().useWebCallback) {
168
+ const origin = (ctx.webServer && typeof ctx.webServer.url === 'string')
169
+ ? ctx.webServer.url
170
+ : ''
171
+ return webCallbackUri(origin, ref)
172
+ }
173
+ return getVendor(provider).redirectUri(vendorConfig(provider, live()))
174
+ }
175
+
176
176
  const store = createAccountStore({
177
177
  credentials: ctx.credentials,
178
178
  getConfig: live,
@@ -186,6 +186,9 @@ export function apply(ctx, config) {
186
186
  },
187
187
  })
188
188
  const history = new HistoryStore(DEFAULT_HISTORY_DIR, 7 * 24 * 60 * 60 * 1000, 1000)
189
+ if (typeof ctx?.on === 'function') {
190
+ ctx.on('dispose', () => history.dispose())
191
+ }
189
192
  const recordHistory = (entry) => history.add(entry)
190
193
 
191
194
  // #85: host-only reset credit service for Codex accounts.
@@ -195,8 +198,11 @@ export function apply(ctx, config) {
195
198
  return slot ? slot.ref : null
196
199
  }
197
200
 
198
- // #91: local Ollama - native provider + seamless fallback when the whole
199
- // pool is exhausted and nothing has been streamed yet.
201
+ function stripLegacySlots(slots) {
202
+ return (slots || []).filter((slot) => isProvider(slot.provider))
203
+ }
204
+
205
+ // Local Ollama adapter and fallback
200
206
  const ollamaAdapter = new OllamaAdapter({
201
207
  baseUrl: () => ollamaBase(live()),
202
208
  fallbackModel: () => live().ollamaFallbackModel || '',
@@ -212,6 +218,7 @@ export function apply(ctx, config) {
212
218
  ollamaHandle = undefined
213
219
  }
214
220
  }
221
+
215
222
  async function* ollamaFallbackStream({ options, provider, err }) {
216
223
  const cfg = live()
217
224
  const models = await ollamaModels(ollamaBase(cfg), fetch).catch(() => [])
@@ -229,10 +236,27 @@ export function apply(ctx, config) {
229
236
  status: 200,
230
237
  kind: 'fallback',
231
238
  })
232
- } catch (err) { if (logger && logger.debug) logger.debug('[history] fallback record failed: ' + (err && err.message || err)) }
239
+ } catch { /* best-effort */ }
233
240
  yield* ollamaAdapter.stream({ ...options, provider: 'ollama', model })
234
241
  }
235
242
 
243
+ // LLM Adapter lifecycle manager
244
+ const adapterManager = createAdapterManager({
245
+ ctx,
246
+ live,
247
+ store,
248
+ logger,
249
+ fetch,
250
+ fetchForRef,
251
+ recordHistory,
252
+ getSettingsApi: () => settingsApi,
253
+ syncCustomVendors,
254
+ syncOllama,
255
+ stripLegacySlots,
256
+ })
257
+ const { syncAdapter, reconcileSlots, refreshModels, cascadeFallbackStream } = adapterManager
258
+ adapterManager.registerAdapterLifecycle()
259
+
236
260
  const subscriptions = createSubscriptionsService({
237
261
  listAccounts: (provider) => store.listAccounts(provider),
238
262
  loadBlob: (ref) => store.loadBlob(ref),
@@ -259,24 +283,13 @@ export function apply(ctx, config) {
259
283
  hideDeprecatedModels: () => !!live().hideDeprecatedModels,
260
284
  })
261
285
 
262
- // Subscription-backed image generation service.
263
- //
264
- // An action is exposed, not a token: a network route would hand a live
265
- // access key to anyone reaching the harness, while the service lives inside
266
- // the process and is visible only to sibling plugins. Token refresh stays
267
- // with a single owner - this plugin.
268
286
  ctx.effect(() => ctx.provide('subscriptions', subscriptions), 'dsh-subscriptions: subscriptions service')
269
287
  ctx.effect(() => ctx.provide('subscriptionImages', {
270
- /** Providers with an active session right now. */
271
288
  async available() {
272
289
  const logged = await store.loggedInProviders()
273
290
  return ['codex', 'grok'].filter((name) => logged && logged[name])
274
291
  },
275
292
  sizes: IMAGE_SIZES,
276
- /**
277
- * @param request {{provider, prompt, size, quality, signal}}
278
- * @returns [{ b64_json, revisedPrompt? }]
279
- */
280
293
  async generate(request) {
281
294
  const provider = request && request.provider
282
295
  if (provider !== 'codex' && provider !== 'grok') {
@@ -299,209 +312,12 @@ export function apply(ctx, config) {
299
312
  })
300
313
  },
301
314
  }), 'dsh-subscriptions: image generation service')
302
- const pending = new Map()
303
- async function* cascadeFallbackStream({ options, provider, err, visited = new Set() }) {
304
- const cfg = live()
305
- if (!cfg.cascadingFallback) throw err
306
- const logged = await store.loggedInProviders()
307
- const available = Object.keys(logged || {}).filter((p) => logged[p] && p !== provider)
308
- visited.add(provider)
309
- const nextVendor = resolveFallbackVendor(provider, available, cfg.cascadingChain, visited)
310
- if (!nextVendor) throw err
311
- visited.add(nextVendor)
312
- const targetModel = mapFallbackModel(provider, options && options.model, nextVendor)
313
- if (logger && logger.warn) {
314
- logger.warn('[dsh-subscriptions] ' + provider + ': all accounts exhausted, cascading fallback to ' + nextVendor + '/' + targetModel)
315
- }
316
- yield* adapter.stream({ ...options, provider: nextVendor, model: targetModel }, visited)
317
- try {
318
- recordHistory({
319
- provider: nextVendor,
320
- ref: String(nextVendor).toUpperCase() + '_CASCADE_FALLBACK',
321
- model: targetModel,
322
- path: '/v1/chat/completions',
323
- method: 'POST',
324
- status: 200,
325
- kind: 'cascade',
326
- })
327
- } catch (e) { /* ignore cascade history recording error */ }
328
- }
329
-
330
- const adapter = new SubscriptionAdapter({
331
- listAccounts: (provider) => store.listAccounts(provider),
332
- loadBlob: (ref) => store.loadBlob(ref),
333
- ensureFresh: (provider, blob, ref) => store.ensureFresh(provider, blob, ref),
334
- vendorConfig: (provider) => vendorConfig(provider, live()),
335
- cooldownMs: () => live().cooldownMs,
336
- switchAtRemaining: () => live().switchAtRemaining,
337
- rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
338
- rememberQuarantine: (ref, reason, until) => store.rememberQuarantine(ref, reason, until),
339
- rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
340
- getQuota: (ref) => store.getQuota(ref),
341
- refreshUsage: (provider) => store.refreshUsage(provider),
342
- saveBlob: (ref, blob) => store.saveBlob(ref, blob),
343
- recordHistory,
344
- fetchImpl: fetch,
345
- fetchForRef,
346
- })
347
-
348
- let handle
349
- let adapterDisposed = false
350
- let registeredByUs = new Set()
351
- function disposeAdapterHandle(h) {
352
- if (!h) return
353
- try {
354
- if (typeof h === 'function') h()
355
- else if (typeof h.dispose === 'function') h.dispose()
356
- } catch (err) {
357
- if (logger && logger.debug) logger.debug('[dsh-subscriptions] dispose handle error: ' + (err && err.message || err))
358
- }
359
- }
360
-
361
- async function syncAdapter() {
362
- if (adapterDisposed) return
363
- const desired = await store.loggedInProviders()
364
- const globalProviders = ctx.llm && typeof ctx.llm.listProviders === 'function'
365
- ? ctx.llm.listProviders().map((p) => p.id)
366
- : []
367
- const globalSet = new Set(globalProviders)
368
-
369
- const claimable = desired.filter((p) => !globalSet.has(p) || registeredByUs.has(p))
370
- const skipped = desired.filter((p) => globalSet.has(p) && !registeredByUs.has(p))
371
315
 
372
- if (skipped.length && logger && logger.info) {
373
- logger.info(`[dsh-subscriptions] syncAdapter skipping already-bound providers: ${skipped.join(', ')}`)
374
- }
375
-
376
- if (!handle && claimable.length) {
377
- handle = ctx.llm.registerAdapter(claimable, adapter)
378
- registeredByUs = new Set(claimable)
379
- return
380
- }
381
- if (!handle) {
382
- registeredByUs.clear()
383
- return
384
- }
385
- if (!claimable.length) {
386
- disposeAdapterHandle(handle)
387
- handle = undefined
388
- registeredByUs.clear()
389
- return
390
- }
391
- if (typeof handle.replace === 'function') {
392
- try {
393
- handle.replace(claimable)
394
- registeredByUs = new Set(claimable)
395
- return
396
- } catch (err) {
397
- if (logger && logger.warn) logger.warn('[dsh-subscriptions] handle.replace failed: ' + (err && err.message || err))
398
- disposeAdapterHandle(handle)
399
- handle = undefined
400
- registeredByUs.clear()
401
- }
402
- } else {
403
- disposeAdapterHandle(handle)
404
- handle = undefined
405
- registeredByUs.clear()
406
- }
407
-
408
- const currentGlobal = new Set((ctx.llm && typeof ctx.llm.listProviders === 'function' ? ctx.llm.listProviders() : []).map((p) => p.id))
409
- const reClaimable = desired.filter((p) => !currentGlobal.has(p))
410
- if (reClaimable.length) {
411
- handle = ctx.llm.registerAdapter(reClaimable, adapter)
412
- registeredByUs = new Set(reClaimable)
413
- }
414
- }
415
-
416
- async function reconcileSlots() {
417
- const api = settingsApi
418
- if (!api || typeof api.replace !== 'function') return
419
- const creds = ctx.credentials
420
- if (!creds || typeof creds.describe !== 'function') return
421
-
422
- try {
423
- const existing = normalizeSlots(live().slots)
424
- const existingRefs = new Set(existing.map((s) => s.ref))
425
- const candidateProviders = PROVIDERS.filter((p) => isProvider(p))
426
- const candidates = []
427
- for (const provider of candidateProviders) {
428
- for (let index = 1; index <= 10; index++) {
429
- const ref = oauthRef(provider, index)
430
- if (!existingRefs.has(ref)) {
431
- candidates.push({ provider, index, ref })
432
- }
433
- }
434
- }
435
-
436
- const additionResults = await Promise.all(
437
- candidates.map(async ({ provider, index, ref }) => {
438
- let d
439
- try {
440
- d = await creds.describe(credentialRef(ref))
441
- } catch (err) {
442
- if (logger && logger.debug) logger.debug(`[dsh-subscriptions reconcile] describe ${ref} error: ` + (err && err.message || err))
443
- return null
444
- }
445
- if (d && d.configured) {
446
- let label = d.label || ''
447
- try {
448
- const blob = await store.loadBlob(ref).catch(() => null)
449
- if (blob && (blob.email || blob.label)) {
450
- label = blob.email || blob.label || label
451
- }
452
- } catch (loadErr) {
453
- if (logger && logger.debug) logger.debug(`[dsh-subscriptions reconcile] loadBlob ${ref} error: ` + (loadErr && loadErr.message || loadErr))
454
- }
455
- return { provider, index, label }
456
- }
457
- return null
458
- })
459
- )
460
- const additions = additionResults.filter(Boolean)
461
-
462
- if (!additions.length) return
463
- if (logger && logger.info) {
464
- logger.info(`[dsh-subscriptions reconcile] restoring orphaned vault slots: ${additions.map((a) => `${a.provider}#${a.index}`).join(', ')}`)
465
- }
466
- const currentSlots = Array.isArray(live().slots) ? live().slots : []
467
- const newSlots = [...currentSlots, ...additions]
468
- const parsed = Config({ ...live(), slots: stripLegacySlots(newSlots) })
469
- await api.replace(parsed)
470
- syncCustomVendors()
471
- await syncAdapter()
472
- refreshModels().catch((err) => {
473
- if (logger && logger.debug) logger.debug('[dsh-subscriptions reconcile] refreshModels error: ' + (err && err.message || err))
474
- })
475
- } catch (e) {
476
- if (logger && logger.warn) {
477
- logger.warn('[dsh-subscriptions reconcile] failed: ' + (e && e.message || e))
478
- }
479
- }
480
- }
481
-
482
- // #46: after a login lands, eagerly refresh the live model catalog so the
483
- // model picker shows the new provider within ~1 min instead of waiting.
484
- let lastModelRefresh = 0
485
- async function refreshModels() {
486
- if (Date.now() - lastModelRefresh < 60 * 1000) return
487
- lastModelRefresh = Date.now()
488
- const providers = await store.loggedInProviders()
489
- for (const provider of providers) {
490
- try {
491
- const slot = normalizeSlots(live().slots).find((x) => x.provider === provider)
492
- if (!slot) continue
493
- const blob = await store.loadBlob(slot.ref).catch(() => null)
494
- if (!blob) continue
495
- const fresh = await store.ensureFresh(provider, blob, slot.ref)
496
- const cfg = vendorConfig(provider, live())
497
- await getVendor(provider).listModels(fresh, cfg, fetch).catch(() => {})
498
- } catch (err) { /* best-effort */ }
499
- }
500
- }
316
+ const pending = new Map()
501
317
 
502
318
  function sweepPending(now) {
503
- for (const [state, row] of pending) {
504
- if (now - row.createdAt > PENDING_TTL_MS) pending.delete(state)
319
+ for (const [stateKey, row] of pending) {
320
+ if (now - row.createdAt > PENDING_TTL_MS) pending.delete(stateKey)
505
321
  }
506
322
  }
507
323
 
@@ -534,226 +350,15 @@ export function apply(ctx, config) {
534
350
  return { ref, label: pmL(blob.label || blob.email) || displayName(provider) }
535
351
  }
536
352
 
537
- async function enrichAntigravityAccount(slot, info) {
538
- if (!info.configured || slot.provider !== 'antigravity') return info
539
- let blob
540
- try { blob = await store.loadBlob(slot.ref) } catch { return info }
541
- if (blob.validationUrl) {
542
- return {
543
- ...info,
544
- validationUrl: blob.validationUrl,
545
- validationMessage: blob.validationMessage || info.validationMessage || '',
546
- paidTierName: blob.paidTierName || info.paidTierName || '',
547
- }
548
- }
549
- try {
550
- const fresh = await store.ensureFresh(slot.provider, blob, slot.ref)
551
- const probe = await inspectGoogleAccount(fetch, fresh.accessToken, {
552
- metadata: antigravityMetadata(fresh.projectId || ''),
553
- extraHeaders: antigravityIdentityHeaders(fresh.projectId || ''),
554
- projectId: fresh.projectId,
555
- })
556
- const next = { ...fresh }
557
- if (probe.projectId && probe.projectId !== fresh.projectId) next.projectId = probe.projectId
558
- if (probe.paidTierId) next.paidTierId = probe.paidTierId
559
- if (probe.paidTierName) next.paidTierName = probe.paidTierName
560
- if (probe.validation?.validationUrl) {
561
- next.validationUrl = probe.validation.validationUrl
562
- next.validationMessage = probe.validation.message || ''
563
- await store.saveBlob(slot.ref, next)
564
- return {
565
- ...info,
566
- validationUrl: next.validationUrl,
567
- validationMessage: next.validationMessage,
568
- paidTierName: next.paidTierName || '',
569
- }
570
- }
571
- if (probe.notice?.message) {
572
- next.accountNotice = probe.notice.message
573
- await store.saveBlob(slot.ref, next)
574
- return { ...info, accountNotice: next.accountNotice, paidTierName: next.paidTierName || '' }
575
- }
576
- if (probe.projectId || probe.paidTierId) await store.saveBlob(slot.ref, next)
577
- return { ...info, paidTierName: next.paidTierName || info.paidTierName || '' }
578
- } catch { /* keep settings responsive */ }
579
- return info
580
- }
581
-
582
- function stripLegacySlots(slots) {
583
- return (slots || []).filter((slot) => isProvider(slot.provider))
584
- }
585
-
586
- // #98: privacy masking. pmE for raw emails, pmL for display labels (only email-looking ones masked).
353
+ // Privacy masking
587
354
  const privacyOn = () => !!live().privacyMask
588
355
  const pmE = (s) => privacyOn() ? maskEmail(s) : String(s || '')
589
356
  const pmL = (s) => privacyOn() ? maskLabel(s) : String(s || '')
590
357
 
591
- // #99: anonymized diagnostics report. No tokens, emails, refs, proxy URLs.
592
- function scrubReport(v) {
593
- if (typeof v === 'string') return maskText(v)
594
- if (Array.isArray(v)) return v.map(scrubReport)
595
- if (v && typeof v === 'object') {
596
- const o = {}
597
- for (const k of Object.keys(v)) o[k] = scrubReport(v[k])
598
- return o
599
- }
600
- return v
601
- }
602
-
603
- async function diagnosticsReport() {
604
- const cfg = live()
605
- const slots = normalizeSlots(cfg.slots)
606
- const mk = () => ({ loggedIn: false, slots: 0, configured: 0, cooldown: 0, proxy: 0, maxUsagePercent: null })
607
- const providers = {}
608
- for (const p of PROVIDERS) providers[p] = mk()
609
- const logged = await store.loggedInProviders()
610
- for (const slot of slots) {
611
- const pv = providers[slot.provider] || (providers[slot.provider] = mk())
612
- pv.slots++
613
- if (slot.proxyUrl) pv.proxy++
614
- try {
615
- const info = await store.describeRef(slot.ref)
616
- if (info.configured) pv.configured++
617
- if (info.cooldownUntil && info.cooldownUntil > Date.now()) pv.cooldown++
618
- if (info.usagePercent != null) pv.maxUsagePercent = Math.max(pv.maxUsagePercent || 0, Math.round(info.usagePercent))
619
- } catch (err) { /* best-effort */ }
620
- }
621
- for (const p of Object.keys(providers)) providers[p].loggedIn = logged.includes(p)
622
- const rows = history.all()
623
- const byStatus = {}
624
- for (const r of rows) {
625
- const k = (r.provider || '?') + ':' + (r.status || '?')
626
- byStatus[k] = (byStatus[k] || 0) + 1
627
- }
628
- const lastErrors = rows.filter((r) => r.status && r.status >= 400).slice(0, 10)
629
- .map((r) => ({ ts: r.ts, provider: r.provider, status: r.status, kind: r.kind || 'request', ms: r.ms || null }))
630
- return scrubReport({
631
- generatedAt: new Date().toISOString(),
632
- plugin: NS + (pkgVersion ? ' v' + pkgVersion : ''),
633
- runtime: { node: process.version, platform: process.platform, arch: process.arch, uptimeSec: Math.round(process.uptime()) },
634
- providers,
635
- requests: { total: rows.length, byStatus, lastErrors },
636
- settings: {
637
- cooldownMs: cfg.cooldownMs,
638
- switchAtRemaining: cfg.switchAtRemaining,
639
- probeIntervalMin: cfg.probeIntervalMin,
640
- useWebCallback: !!cfg.useWebCallback,
641
- autoLoopback: !!cfg.autoLoopback,
642
- privacyMask: !!cfg.privacyMask,
643
- customVendors: Array.isArray(cfg.customVendors) ? cfg.customVendors.length : 0,
644
- proxySlots: slots.filter((s) => s.proxyUrl).length,
645
- },
646
- })
647
- }
648
-
649
- async function accountsView() {
650
- const out = []
651
- for (const slot of normalizeSlots(live().slots)) {
652
- const info = await enrichAntigravityAccount(slot, await store.describeRef(slot.ref))
653
- out.push({
654
- provider: slot.provider,
655
- index: slot.index,
656
- ref: slot.ref,
657
- label: pmL(slot.label || info.label),
658
- configured: info.configured,
659
- writable: info.writable,
660
- cooldownUntil: info.cooldownUntil,
661
- // #303: expose the cooldown scope and quarantine so the card can
662
- // explain why only some models are blocked.
663
- cooldownFamilies: info.cooldownFamilies || null,
664
- quarantineUntil: info.quarantineUntil || 0,
665
- quarantineReason: info.quarantineReason || null,
666
- usagePercent: info.usagePercent,
667
- quota: info.quota || null,
668
- usage: info.usage || null,
669
- requests: store.getRequestCount(slot.ref) || null,
670
- refreshError: info.refreshError || '',
671
- validationUrl: info.validationUrl || '',
672
- validationMessage: info.validationMessage || '',
673
- accountNotice: info.accountNotice || '',
674
- paidTierName: info.paidTierName || '',
675
- })
676
- }
677
- return out
678
- }
679
-
680
- async function configResponse() {
681
- return {
682
- ok: true,
683
- config: publicConfig(live()),
684
- accounts: await accountsView(),
685
- providers: PROVIDERS.map((id) => ({ id, name: displayName(id) })),
686
- }
687
- }
688
-
689
- ctx.effect(() => {
690
- syncAdapter().catch((err) => {
691
- if (logger && logger.debug) logger.debug('[dsh-subscriptions] syncAdapter initial error: ' + (err && err.message || err))
692
- })
693
- syncOllama().catch((err) => {
694
- if (logger && logger.debug) logger.debug('[dsh-subscriptions] syncOllama initial error: ' + (err && err.message || err))
695
- })
696
- reconcileSlots().catch((err) => {
697
- if (logger && logger.debug) logger.debug('[dsh-subscriptions] reconcileSlots effect error: ' + (err && err.message || err))
698
- })
699
-
700
- let retries = 0
701
- let retryTimer
702
- const scheduleRetry = () => {
703
- if (adapterDisposed || retries >= 30) return
704
- retryTimer = setTimeout(retrySync, 1000)
705
- if (retryTimer && typeof retryTimer.unref === 'function') retryTimer.unref()
706
- }
707
- const retrySync = async () => {
708
- if (adapterDisposed) return
709
- try {
710
- const loggedIn = await store.loggedInProviders()
711
- if (!loggedIn.length) return
712
- const registered = ctx.llm && typeof ctx.llm.listProviders === 'function'
713
- ? ctx.llm.listProviders().map((p) => p.id)
714
- : []
715
- const regSet = new Set(registered)
716
- const pending = loggedIn.filter((p) => !regSet.has(p))
717
- if (pending.length === 0) return
718
- retries += 1
719
- await syncAdapter()
720
- if (retries < 30) {
721
- scheduleRetry()
722
- }
723
- } catch (err) {
724
- if (logger && logger.debug) logger.debug('[dsh-subscriptions] retrySync error: ' + (err && err.message || err))
725
- if (retries < 30) {
726
- scheduleRetry()
727
- }
728
- }
729
- }
730
- scheduleRetry()
358
+ const diagnosticsReport = () => createDiagnosticsReport({ live, store, history, NS, pkgVersion })
359
+ const accountsView = () => createAccountsView({ live, store, fetch, pmL })
731
360
 
732
- // #75: eager usage refresh at startup so the windows (5h/7d) land in the blob immediately
733
- // and the active subscription chip shows 5h/7d/... right away instead of waiting for probeInterval.
734
- const eager = async () => {
735
- try {
736
- for (const slot of normalizeSlots(live().slots)) {
737
- store.refreshUsage(slot.provider).catch((err) => {
738
- if (logger && logger.debug) logger.debug('[dsh-subscriptions] eager usage error: ' + (err && err.message || err))
739
- })
740
- }
741
- } catch (err) {
742
- if (logger && logger.debug) logger.debug('[dsh-subscriptions] eager refresh error: ' + (err && err.message || err))
743
- }
744
- }
745
- eager()
746
- return () => {
747
- adapterDisposed = true
748
- if (retryTimer) clearTimeout(retryTimer)
749
- if (handle) {
750
- disposeAdapterHandle(handle)
751
- handle = undefined
752
- registeredByUs.clear()
753
- }
754
- }
755
- }, 'dsh-subscriptions: llm adapter')
756
- // ponytail: background refresh ahead of expiry, single timer, per-account lock + retry backoff
361
+ // Ponytail: background refresh ahead of expiry, single timer
757
362
  ctx.effect(() => {
758
363
  const tick = async () => {
759
364
  const cfg = live()
@@ -774,9 +379,9 @@ export function apply(ctx, config) {
774
379
  if (blob.expiresAt - now > ahead) continue
775
380
  if (typeof store.shouldSkipRefresh === 'function' && store.shouldSkipRefresh(ref, now, retryMs)) continue
776
381
  await store.ensureFresh(slot.provider, blob, ref).catch((e) => {
777
- if (logger && logger.warn) logger.warn("[dsh-subscriptions] background refresh failed for " + ref + ": " + String(e && e.message || e))
382
+ if (logger && logger.warn) logger.warn('[dsh-subscriptions] background refresh failed for ' + ref + ': ' + String(e && e.message || e))
778
383
  })
779
- } catch (err) { /* best-effort */ }
384
+ } catch { /* best-effort */ }
780
385
  }
781
386
  }
782
387
  tick().catch(() => {})
@@ -786,15 +391,12 @@ export function apply(ctx, config) {
786
391
  return () => clear(id)
787
392
  }, 'dsh-subscriptions: refresh ahead')
788
393
 
789
- // Background health-check: every N minutes runs a cheap check across all
790
- // connected accounts. Dead ones are flagged via describeRef, no cooldown
791
- // is set (same as /check).
394
+ // Background health-check probe loop
792
395
  ctx.effect(() => {
793
396
  const tick = async () => {
794
397
  const cfg = live()
795
398
  const mins = Number(cfg.probeIntervalMin)
796
399
  if (!Number.isFinite(mins) || mins <= 0) return
797
- const now = Date.now()
798
400
  for (const slot of normalizeSlots(cfg.slots)) {
799
401
  const ref = slot.ref
800
402
  try {
@@ -811,9 +413,9 @@ export function apply(ctx, config) {
811
413
  const cfg2 = vendorConfig(slot.provider, live())
812
414
  const probeFetch = async (u, i) => ((typeof fetchForRef === 'function' && fetchForRef(ref)) || fetch)(u, i)
813
415
  await vendor.check(fresh, cfg2, probeFetch).catch((e) => {
814
- if (logger && logger.warn) logger.warn("[dsh-subscriptions] probe " + ref + ": " + String(e && e.message || e).slice(0, 200))
416
+ if (logger && logger.warn) logger.warn('[dsh-subscriptions] probe ' + ref + ': ' + String(e && e.message || e).slice(0, 200))
815
417
  })
816
- } catch (err) { /* best-effort */ }
418
+ } catch { /* best-effort */ }
817
419
  }
818
420
  }
819
421
  let lastProbeAt = 0
@@ -830,8 +432,6 @@ export function apply(ctx, config) {
830
432
  return () => clearInterval(timer)
831
433
  }, 'dsh-subscriptions: probe loop')
832
434
 
833
-
834
-
835
435
  registerRoutes(ctx, {
836
436
  NS,
837
437
  live,