@goodandready/dsh-subscriptions 0.6.22 → 0.6.24

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.
@@ -0,0 +1,84 @@
1
+ import { normalizeSlots } from './accounts.js'
2
+ import {
3
+ inspectGoogleAccount,
4
+ antigravityMetadata,
5
+ antigravityIdentityHeaders,
6
+ } from './code-assist.js'
7
+
8
+ export async function enrichAntigravityAccount(slot, info, store, fetch) {
9
+ if (!info.configured || slot.provider !== 'antigravity') return info
10
+ let blob
11
+ try { blob = await store.loadBlob(slot.ref) } catch { return info }
12
+ if (blob.validationUrl) {
13
+ return {
14
+ ...info,
15
+ validationUrl: blob.validationUrl,
16
+ validationMessage: blob.validationMessage || info.validationMessage || '',
17
+ paidTierName: blob.paidTierName || info.paidTierName || '',
18
+ }
19
+ }
20
+ try {
21
+ const fresh = await store.ensureFresh(slot.provider, blob, slot.ref)
22
+ const probe = await inspectGoogleAccount(fetch, fresh.accessToken, {
23
+ metadata: antigravityMetadata(fresh.projectId || ''),
24
+ extraHeaders: antigravityIdentityHeaders(fresh.projectId || ''),
25
+ projectId: fresh.projectId,
26
+ })
27
+ const next = { ...fresh }
28
+ if (probe.projectId && probe.projectId !== fresh.projectId) next.projectId = probe.projectId
29
+ if (probe.paidTierId) next.paidTierId = probe.paidTierId
30
+ if (probe.paidTierName) next.paidTierName = probe.paidTierName
31
+ if (probe.validation?.validationUrl) {
32
+ next.validationUrl = probe.validation.validationUrl
33
+ next.validationMessage = probe.validation.message || ''
34
+ await store.saveBlob(slot.ref, next)
35
+ return {
36
+ ...info,
37
+ validationUrl: next.validationUrl,
38
+ validationMessage: next.validationMessage,
39
+ paidTierName: next.paidTierName || '',
40
+ }
41
+ }
42
+ if (probe.notice?.message) {
43
+ next.accountNotice = probe.notice.message
44
+ await store.saveBlob(slot.ref, next)
45
+ return { ...info, accountNotice: next.accountNotice, paidTierName: next.paidTierName || '' }
46
+ }
47
+ if (probe.projectId || probe.paidTierId) await store.saveBlob(slot.ref, next)
48
+ return { ...info, paidTierName: next.paidTierName || info.paidTierName || '' }
49
+ } catch { /* keep settings responsive */ }
50
+ return info
51
+ }
52
+
53
+ export async function createAccountsView({ live, store, fetch, pmL }) {
54
+ const out = []
55
+ for (const slot of normalizeSlots(live().slots)) {
56
+ const info = await enrichAntigravityAccount(slot, await store.describeRef(slot.ref), store, fetch)
57
+ out.push({
58
+ provider: slot.provider,
59
+ index: slot.index,
60
+ ref: slot.ref,
61
+ label: pmL(slot.label || info.label),
62
+ configured: info.configured,
63
+ writable: info.writable,
64
+ cooldownUntil: info.cooldownUntil,
65
+ // Issue 303: expose the cooldown scope and quarantine so the card can
66
+ // explain why only some models are blocked.
67
+ cooldownFamilies: info.cooldownFamilies || null,
68
+ quarantineUntil: info.quarantineUntil || 0,
69
+ quarantineReason: info.quarantineReason || null,
70
+ healthScore: info.healthScore,
71
+ healthBadge: info.healthBadge,
72
+ usagePercent: info.usagePercent,
73
+ quota: info.quota || null,
74
+ usage: info.usage || null,
75
+ requests: store.getRequestCount(slot.ref) || null,
76
+ refreshError: info.refreshError || '',
77
+ validationUrl: info.validationUrl || '',
78
+ validationMessage: info.validationMessage || '',
79
+ accountNotice: info.accountNotice || '',
80
+ paidTierName: info.paidTierName || '',
81
+ })
82
+ }
83
+ return out
84
+ }
package/lib/accounts.js CHANGED
@@ -2,7 +2,7 @@ import { fetchWithTimeout } from "./http.js"
2
2
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
3
3
  import { oauthRef, isProvider } from './refs.js'
4
4
  import { parseBlob, serializeBlob } from './blob.js'
5
- import { recordSwitch, recordExhaust, recordBroken, computeHealthScore } from './health.js'
5
+ import { recordSwitch, recordExhaust, recordBroken, computeHealthScore, healthBadge } from './health.js'
6
6
  import { executeWithRetry } from './backoff.js'
7
7
  import { getVendor } from './vendors/index.js'
8
8
 
@@ -153,6 +153,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
153
153
  quota: quotas.get(ref) || null,
154
154
  usage: windows.get(ref) || base.usage || null,
155
155
  health: health.get(ref) || null,
156
+ healthScore: computeHealthScore(health.get(ref) || null),
157
+ healthBadge: healthBadge(computeHealthScore(health.get(ref) || null)),
156
158
  usagePercent: usage.has(ref) ? usage.get(ref) : null,
157
159
  refreshError: refreshFailures.get(ref)?.error || '',
158
160
  validationUrl: base.validationUrl || '',
@@ -179,6 +181,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
179
181
  cooldownFamilies: info.cooldownFamilies || null,
180
182
  quota: info.quota || null,
181
183
  healthScore: computeHealthScore(info.health),
184
+ healthBadge: healthBadge(computeHealthScore(info.health)),
182
185
  quarantineUntil: inQ ? q.until : 0,
183
186
  quarantineReason: inQ ? q.reason : null,
184
187
  label: slot.label || info.label,
@@ -271,7 +274,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
271
274
  for (const th of THRESHOLDS) {
272
275
  if (pct >= th && prev < th) {
273
276
  notifyThresholds.set(key, th)
274
- try { onLimitNotice && onLimitNotice(slot.provider, slot.ref, win, th) } catch (err) { /* ignore notification failure */ }
277
+ try { onLimitNotice && onLimitNotice(slot.provider, slot.ref, win, th) } catch { /* ignore notification failure */ }
275
278
  break
276
279
  }
277
280
  }
@@ -0,0 +1,300 @@
1
+ import { SubscriptionAdapter } from './adapter.js'
2
+ import { resolveFallbackVendor, mapFallbackModel } from './cascade.js'
3
+ import { normalizeSlots, vendorConfig } from './accounts.js'
4
+ import { PROVIDERS, isProvider, oauthRef } from './refs.js'
5
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
6
+ import { getVendor } from './vendors/index.js'
7
+ import { Config } from './config-schema.js'
8
+
9
+ export function createAdapterManager({
10
+ ctx,
11
+ live,
12
+ store,
13
+ logger,
14
+ fetch,
15
+ fetchForRef,
16
+ recordHistory,
17
+ getSettingsApi,
18
+ syncCustomVendors,
19
+ syncOllama,
20
+ stripLegacySlots,
21
+ }) {
22
+ let handle
23
+ let adapterDisposed = false
24
+ let registeredByUs = new Set()
25
+
26
+ function disposeAdapterHandle(h) {
27
+ if (!h) return
28
+ try {
29
+ if (typeof h === 'function') h()
30
+ else if (typeof h.dispose === 'function') h.dispose()
31
+ } catch (err) {
32
+ if (logger && logger.debug) logger.debug('[dsh-subscriptions] dispose handle error: ' + (err && err.message || err))
33
+ }
34
+ }
35
+
36
+ const adapter = new SubscriptionAdapter({
37
+ listAccounts: (provider) => store.listAccounts(provider),
38
+ loadBlob: (ref) => store.loadBlob(ref),
39
+ ensureFresh: (provider, blob, ref) => store.ensureFresh(provider, blob, ref),
40
+ vendorConfig: (provider) => vendorConfig(provider, live()),
41
+ cooldownMs: () => live().cooldownMs,
42
+ switchAtRemaining: () => live().switchAtRemaining,
43
+ rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
44
+ rememberQuarantine: (ref, reason, until) => store.rememberQuarantine(ref, reason, until),
45
+ rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
46
+ getQuota: (ref) => store.getQuota(ref),
47
+ refreshUsage: (provider) => store.refreshUsage(provider),
48
+ saveBlob: (ref, blob) => store.saveBlob(ref, blob),
49
+ recordHistory,
50
+ fetchImpl: fetch,
51
+ fetchForRef,
52
+ })
53
+
54
+ async function* cascadeFallbackStream({ options, provider, err, visited = new Set() }) {
55
+ const cfg = live()
56
+ if (!cfg.cascadingFallback) throw err
57
+ const logged = await store.loggedInProviders()
58
+ const available = Object.keys(logged || {}).filter((p) => logged[p] && p !== provider)
59
+ visited.add(provider)
60
+ const nextVendor = resolveFallbackVendor(provider, available, cfg.cascadingChain, visited)
61
+ if (!nextVendor) throw err
62
+ visited.add(nextVendor)
63
+ const targetModel = mapFallbackModel(provider, options && options.model, nextVendor)
64
+ if (logger && logger.warn) {
65
+ logger.warn('[dsh-subscriptions] ' + provider + ': all accounts exhausted, cascading fallback to ' + nextVendor + '/' + targetModel)
66
+ }
67
+ yield* adapter.stream({ ...options, provider: nextVendor, model: targetModel }, visited)
68
+ try {
69
+ recordHistory({
70
+ provider: nextVendor,
71
+ ref: String(nextVendor).toUpperCase() + '_CASCADE_FALLBACK',
72
+ model: targetModel,
73
+ path: '/v1/chat/completions',
74
+ method: 'POST',
75
+ status: 200,
76
+ kind: 'cascade',
77
+ })
78
+ } catch { /* ignore cascade history recording error */ }
79
+ }
80
+
81
+ async function syncAdapter() {
82
+ if (adapterDisposed) return
83
+ const desired = await store.loggedInProviders()
84
+ const globalProviders = ctx.llm && typeof ctx.llm.listProviders === 'function'
85
+ ? ctx.llm.listProviders().map((p) => p.id)
86
+ : []
87
+ const globalSet = new Set(globalProviders)
88
+
89
+ const claimable = desired.filter((p) => !globalSet.has(p) || registeredByUs.has(p))
90
+ const skipped = desired.filter((p) => globalSet.has(p) && !registeredByUs.has(p))
91
+
92
+ if (skipped.length && logger && logger.info) {
93
+ logger.info(`[dsh-subscriptions] syncAdapter skipping already-bound providers: ${skipped.join(', ')}`)
94
+ }
95
+
96
+ if (!handle && claimable.length) {
97
+ handle = ctx.llm.registerAdapter(claimable, adapter)
98
+ registeredByUs = new Set(claimable)
99
+ return
100
+ }
101
+ if (!handle) {
102
+ registeredByUs.clear()
103
+ return
104
+ }
105
+ if (!claimable.length) {
106
+ disposeAdapterHandle(handle)
107
+ handle = undefined
108
+ registeredByUs.clear()
109
+ return
110
+ }
111
+ if (typeof handle.replace === 'function') {
112
+ try {
113
+ handle.replace(claimable)
114
+ registeredByUs = new Set(claimable)
115
+ return
116
+ } catch (err) {
117
+ if (logger && logger.warn) logger.warn('[dsh-subscriptions] handle.replace failed: ' + (err && err.message || err))
118
+ disposeAdapterHandle(handle)
119
+ handle = undefined
120
+ registeredByUs.clear()
121
+ }
122
+ } else {
123
+ disposeAdapterHandle(handle)
124
+ handle = undefined
125
+ registeredByUs.clear()
126
+ }
127
+
128
+ const currentGlobal = new Set((ctx.llm && typeof ctx.llm.listProviders === 'function' ? ctx.llm.listProviders() : []).map((p) => p.id))
129
+ const reClaimable = desired.filter((p) => !currentGlobal.has(p))
130
+ if (reClaimable.length) {
131
+ handle = ctx.llm.registerAdapter(reClaimable, adapter)
132
+ registeredByUs = new Set(reClaimable)
133
+ }
134
+ }
135
+
136
+ async function reconcileSlots() {
137
+ const api = getSettingsApi()
138
+ if (!api || typeof api.replace !== 'function') return
139
+ const creds = ctx.credentials
140
+ if (!creds || typeof creds.describe !== 'function') return
141
+
142
+ try {
143
+ const existing = normalizeSlots(live().slots)
144
+ const existingRefs = new Set(existing.map((s) => s.ref))
145
+ const candidateProviders = PROVIDERS.filter((p) => isProvider(p))
146
+ const candidates = []
147
+ for (const provider of candidateProviders) {
148
+ for (let index = 1; index <= 10; index++) {
149
+ const ref = oauthRef(provider, index)
150
+ if (!existingRefs.has(ref)) {
151
+ candidates.push({ provider, index, ref })
152
+ }
153
+ }
154
+ }
155
+
156
+ const additionResults = await Promise.all(
157
+ candidates.map(async ({ provider, index, ref }) => {
158
+ let d
159
+ try {
160
+ d = await creds.describe(credentialRef(ref))
161
+ } catch (err) {
162
+ if (logger && logger.debug) logger.debug(`[dsh-subscriptions reconcile] describe ${ref} error: ` + (err && err.message || err))
163
+ return null
164
+ }
165
+ if (d && d.configured) {
166
+ let label = d.label || ''
167
+ try {
168
+ const blob = await store.loadBlob(ref).catch(() => null)
169
+ if (blob && (blob.email || blob.label)) {
170
+ label = blob.email || blob.label || label
171
+ }
172
+ } catch (loadErr) {
173
+ if (logger && logger.debug) logger.debug(`[dsh-subscriptions reconcile] loadBlob ${ref} error: ` + (loadErr && loadErr.message || loadErr))
174
+ }
175
+ return { provider, index, label }
176
+ }
177
+ return null
178
+ })
179
+ )
180
+ const additions = additionResults.filter(Boolean)
181
+
182
+ if (!additions.length) return
183
+ if (logger && logger.info) {
184
+ logger.info(`[dsh-subscriptions reconcile] restoring orphaned vault slots: ${additions.map((a) => `${a.provider}#${a.index}`).join(', ')}`)
185
+ }
186
+ const currentSlots = Array.isArray(live().slots) ? live().slots : []
187
+ const newSlots = [...currentSlots, ...additions]
188
+ const parsed = Config({ ...live(), slots: stripLegacySlots(newSlots) })
189
+ await api.replace(parsed)
190
+ syncCustomVendors()
191
+ await syncAdapter()
192
+ refreshModels().catch((err) => {
193
+ if (logger && logger.debug) logger.debug('[dsh-subscriptions reconcile] refreshModels error: ' + (err && err.message || err))
194
+ })
195
+ } catch (e) {
196
+ if (logger && logger.warn) {
197
+ logger.warn('[dsh-subscriptions reconcile] failed: ' + (e && e.message || e))
198
+ }
199
+ }
200
+ }
201
+
202
+ let lastModelRefresh = 0
203
+ async function refreshModels() {
204
+ if (Date.now() - lastModelRefresh < 60 * 1000) return
205
+ lastModelRefresh = Date.now()
206
+ const providers = await store.loggedInProviders()
207
+ for (const provider of providers) {
208
+ try {
209
+ const slot = normalizeSlots(live().slots).find((x) => x.provider === provider)
210
+ if (!slot) continue
211
+ const blob = await store.loadBlob(slot.ref).catch(() => null)
212
+ if (!blob) continue
213
+ const fresh = await store.ensureFresh(provider, blob, slot.ref)
214
+ const cfg = vendorConfig(provider, live())
215
+ await getVendor(provider).listModels(fresh, cfg, fetch).catch(() => {})
216
+ } catch { /* best-effort */ }
217
+ }
218
+ }
219
+
220
+ function registerAdapterLifecycle() {
221
+ return ctx.effect(() => {
222
+ syncAdapter().catch((err) => {
223
+ if (logger && logger.debug) logger.debug('[dsh-subscriptions] syncAdapter initial error: ' + (err && err.message || err))
224
+ })
225
+ if (syncOllama) {
226
+ syncOllama().catch((err) => {
227
+ if (logger && logger.debug) logger.debug('[dsh-subscriptions] syncOllama initial error: ' + (err && err.message || err))
228
+ })
229
+ }
230
+ reconcileSlots().catch((err) => {
231
+ if (logger && logger.debug) logger.debug('[dsh-subscriptions] reconcileSlots effect error: ' + (err && err.message || err))
232
+ })
233
+
234
+ let retries = 0
235
+ let retryTimer
236
+ const scheduleRetry = () => {
237
+ if (adapterDisposed || retries >= 30) return
238
+ retryTimer = setTimeout(retrySync, 1000)
239
+ if (retryTimer && typeof retryTimer.unref === 'function') retryTimer.unref()
240
+ }
241
+ const retrySync = async () => {
242
+ if (adapterDisposed) return
243
+ try {
244
+ const loggedIn = await store.loggedInProviders()
245
+ if (!loggedIn.length) return
246
+ const registered = ctx.llm && typeof ctx.llm.listProviders === 'function'
247
+ ? ctx.llm.listProviders().map((p) => p.id)
248
+ : []
249
+ const regSet = new Set(registered)
250
+ const pending = loggedIn.filter((p) => !regSet.has(p))
251
+ if (pending.length === 0) return
252
+ retries += 1
253
+ await syncAdapter()
254
+ if (retries < 30) {
255
+ scheduleRetry()
256
+ }
257
+ } catch (err) {
258
+ if (logger && logger.debug) logger.debug('[dsh-subscriptions] retrySync error: ' + (err && err.message || err))
259
+ if (retries < 30) {
260
+ scheduleRetry()
261
+ }
262
+ }
263
+ }
264
+ scheduleRetry()
265
+
266
+ // #75: eager usage refresh at startup so the windows (5h/7d) land in the blob immediately
267
+ const eager = async () => {
268
+ try {
269
+ for (const slot of normalizeSlots(live().slots)) {
270
+ store.refreshUsage(slot.provider).catch((err) => {
271
+ if (logger && logger.debug) logger.debug('[dsh-subscriptions] eager usage error: ' + (err && err.message || err))
272
+ })
273
+ }
274
+ } catch (err) {
275
+ if (logger && logger.debug) logger.debug('[dsh-subscriptions] eager refresh error: ' + (err && err.message || err))
276
+ }
277
+ }
278
+ eager()
279
+
280
+ return () => {
281
+ adapterDisposed = true
282
+ if (retryTimer) clearTimeout(retryTimer)
283
+ if (handle) {
284
+ disposeAdapterHandle(handle)
285
+ handle = undefined
286
+ registeredByUs.clear()
287
+ }
288
+ }
289
+ }, 'dsh-subscriptions: llm adapter')
290
+ }
291
+
292
+ return {
293
+ adapter,
294
+ syncAdapter,
295
+ reconcileSlots,
296
+ refreshModels,
297
+ cascadeFallbackStream,
298
+ registerAdapterLifecycle,
299
+ }
300
+ }
package/lib/adapter.js CHANGED
@@ -37,7 +37,7 @@ export class SubscriptionAdapter extends LlmAdapter {
37
37
  return { id: provider, name: displayName(provider) }
38
38
  }
39
39
 
40
- providerRetryPolicy(_provider) {
40
+ providerRetryPolicy() {
41
41
  return undefined
42
42
  }
43
43
 
@@ -67,11 +67,11 @@ export class SubscriptionAdapter extends LlmAdapter {
67
67
  return models
68
68
  }
69
69
 
70
- imageRequestPricing(_provider, _model) {
70
+ imageRequestPricing() {
71
71
  return undefined
72
72
  }
73
73
 
74
- async resolveModel(provider, model, _signal) {
74
+ async resolveModel(provider, model) {
75
75
  const rows = await this.listModels(provider)
76
76
  const found = rows.find((row) => row && row.id === model)
77
77
  if (found) {
@@ -127,11 +127,11 @@ export class SubscriptionAdapter extends LlmAdapter {
127
127
  try {
128
128
  const snap = quotaSnapshot(provider, res.headers, null, Date.now())
129
129
  if (snap && typeof deps.rememberQuota === "function") deps.rememberQuota(account.ref, snap)
130
- } catch (err) { /* ignore quota capture failure */ }
130
+ } catch { /* ignore quota capture failure */ }
131
131
  if (!res.ok && res.clone) {
132
132
  try {
133
133
  const txt = await res.clone().text()
134
- let j=null; try { j = JSON.parse(txt) } catch (err) { j = null }
134
+ let j=null; try { j = JSON.parse(txt) } catch { j = null }
135
135
  if (j) {
136
136
  const b = parseBody(provider, j, Date.now())
137
137
  if (b) {
@@ -139,7 +139,7 @@ export class SubscriptionAdapter extends LlmAdapter {
139
139
  if (snap2) deps.rememberQuota(account.ref, snap2)
140
140
  }
141
141
  }
142
- } catch (err) { /* ignore quota capture failure */ }
142
+ } catch { /* ignore quota capture failure */ }
143
143
  }
144
144
  return res
145
145
  }
@@ -169,7 +169,7 @@ export class SubscriptionAdapter extends LlmAdapter {
169
169
  ms: Date.now() - t0,
170
170
  kind: 'stream',
171
171
  })
172
- } catch (err) { /* ignore quota capture failure */ }
172
+ } catch { /* ignore quota capture failure */ }
173
173
  }
174
174
  } catch (err) {
175
175
  if (err && err.code === 'VALIDATION_REQUIRED' && err.validationUrl) {
package/lib/alerts.js CHANGED
@@ -8,7 +8,7 @@ const DEDUP_WINDOW_MS = 10 * 60 * 1000 // 10 minutes cooldown per (ref + type)
8
8
  const alertBuffer = []
9
9
  const lastAlertTime = new Map()
10
10
 
11
- export function clearAlertsForTesting() {
11
+ export function clearAlerts() {
12
12
  alertBuffer.length = 0
13
13
  lastAlertTime.clear()
14
14
  }
@@ -75,7 +75,7 @@ export async function dispatchWebhookAlert(alert, webhookUrl, fetchImpl = fetch)
75
75
  body: JSON.stringify(payload),
76
76
  })
77
77
  return res.ok
78
- } catch (err) {
78
+ } catch {
79
79
  // Non-blocking fire-and-forget
80
80
  return false
81
81
  }
package/lib/backoff.js CHANGED
@@ -3,7 +3,7 @@ export async function executeWithRetry(fn, {
3
3
  initialDelayMs = 200,
4
4
  maxDelayMs = 3000,
5
5
  factor = 2,
6
- isRetryable = (err) => true
6
+ isRetryable = () => true
7
7
  } = {}) {
8
8
  let attempt = 0
9
9
  let delay = initialDelayMs