@goodandready/dsh-subscriptions 0.6.15 → 0.6.17

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/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 } from './health.js'
5
+ import { recordSwitch, recordExhaust, recordBroken, computeHealthScore } from './health.js'
6
6
  import { executeWithRetry } from './backoff.js'
7
7
  import { getVendor } from './vendors/index.js'
8
8
 
@@ -177,6 +177,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
177
177
  cooldownUntil: info.cooldownUntil,
178
178
  cooldownFamilies: info.cooldownFamilies || null,
179
179
  quota: info.quota || null,
180
+ healthScore: computeHealthScore(info.health),
180
181
  quarantineUntil: inQ ? q.until : 0,
181
182
  quarantineReason: inQ ? q.reason : null,
182
183
  label: slot.label || info.label,
package/lib/adapter.js CHANGED
@@ -99,7 +99,10 @@ export class SubscriptionAdapter extends LlmAdapter {
99
99
  let yieldedAny = false
100
100
  try {
101
101
  if (typeof deps.refreshUsage === 'function') {
102
- await deps.refreshUsage(provider)
102
+ try {
103
+ const p = deps.refreshUsage(provider)
104
+ if (p && typeof p.catch === 'function') p.catch(() => {})
105
+ } catch { /* fire-and-forget background usage refresh */ }
103
106
  }
104
107
  for await (const event of streamWithRotation({
105
108
  accounts: await deps.listAccounts(provider),
@@ -108,7 +111,7 @@ export class SubscriptionAdapter extends LlmAdapter {
108
111
  switchAtRemaining: typeof deps.switchAtRemaining === 'function' ? deps.switchAtRemaining() : (deps.switchAtRemaining ?? 0),
109
112
  options,
110
113
  onCooldown: (account) => {
111
- deps.rememberCooldown(account.ref, account.cooldownUntil, account.cooldownFamilies || null)
114
+ if (typeof deps.rememberCooldown === 'function') deps.rememberCooldown(account.ref, account.cooldownUntil, account.cooldownFamilies || null)
112
115
  if (typeof deps.recordSwitch === 'function') deps.recordSwitch(account.ref)
113
116
  },
114
117
  streamOnce: async function* (account, opts) {
@@ -190,6 +193,15 @@ export class SubscriptionAdapter extends LlmAdapter {
190
193
  }
191
194
  return
192
195
  } catch (err) {
196
+ // #349: opt-in cascading cross-vendor fallback when all accounts are exhausted
197
+ if (!yieldedAny && typeof deps.cascadingFallback === 'function') {
198
+ try {
199
+ yield* deps.cascadingFallback({ options, provider, err })
200
+ return
201
+ } catch (cascadeErr) {
202
+ /* proceed to ollama fallback or error rethrow */
203
+ }
204
+ }
193
205
  // #91: when the whole pool is exhausted and nothing has been streamed
194
206
  // yet, continue seamlessly on local Ollama instead of failing the chat.
195
207
  if (!yieldedAny && typeof deps.ollamaFallback === 'function') {
package/lib/alerts.js ADDED
@@ -0,0 +1,147 @@
1
+ // #351: Proactive Quota Alerts & Webhook Dispatcher
2
+ // Manages quota threshold warnings, session expiry notifications,
3
+ // and webhook dispatch (e.g. Telegram/Discord or external monitoring).
4
+
5
+ const MAX_ALERTS = 100
6
+ const DEDUP_WINDOW_MS = 10 * 60 * 1000 // 10 minutes cooldown per (ref + type)
7
+
8
+ const alertBuffer = []
9
+ const lastAlertTime = new Map()
10
+
11
+ export function clearAlertsForTesting() {
12
+ alertBuffer.length = 0
13
+ lastAlertTime.clear()
14
+ }
15
+
16
+ /**
17
+ * Records an alert into the in-memory buffer with deduplication.
18
+ * @returns {object|null} the created alert, or null if deduped
19
+ */
20
+ export function recordAlert({ type, ref, provider, message, severity = 'warn', details = null, nowMs = Date.now() }) {
21
+ const dedupKey = `${type}:${ref || provider}`
22
+ const prev = lastAlertTime.get(dedupKey) || 0
23
+ if (nowMs - prev < DEDUP_WINDOW_MS) {
24
+ return null // Deduplicate to avoid alert floods
25
+ }
26
+ lastAlertTime.set(dedupKey, nowMs)
27
+
28
+ const alert = {
29
+ id: `alt_${nowMs.toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
30
+ type,
31
+ ref: ref || null,
32
+ provider: provider || 'unknown',
33
+ message: String(message || ''),
34
+ severity,
35
+ details,
36
+ timestamp: nowMs,
37
+ }
38
+
39
+ alertBuffer.push(alert)
40
+ if (alertBuffer.length > MAX_ALERTS) {
41
+ alertBuffer.shift()
42
+ }
43
+ return alert
44
+ }
45
+
46
+ /**
47
+ * Returns recent alerts from the in-memory buffer.
48
+ */
49
+ export function getRecentAlerts(limit = 50) {
50
+ return alertBuffer.slice(-Math.max(1, Math.min(limit, MAX_ALERTS))).reverse()
51
+ }
52
+
53
+ /**
54
+ * Dispatches an alert asynchronously to an external webhookUrl if provided.
55
+ */
56
+ export async function dispatchWebhookAlert(alert, webhookUrl, fetchImpl = fetch) {
57
+ if (!alert || !webhookUrl || typeof webhookUrl !== 'string' || !webhookUrl.startsWith('http')) {
58
+ return false
59
+ }
60
+
61
+ const payload = {
62
+ event: 'subscription_alert',
63
+ alert,
64
+ source: 'dsh-subscriptions',
65
+ sentAt: new Date().toISOString(),
66
+ }
67
+
68
+ try {
69
+ const res = await fetchImpl(webhookUrl, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'content-type': 'application/json',
73
+ 'user-agent': 'dsh-subscriptions/0.6.17',
74
+ },
75
+ body: JSON.stringify(payload),
76
+ })
77
+ return res.ok
78
+ } catch (err) {
79
+ // Non-blocking fire-and-forget
80
+ return false
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Evaluates used percent against configured thresholds and fires alerts if triggered.
86
+ */
87
+ export function checkQuotaThresholds({
88
+ ref,
89
+ provider,
90
+ usedPercent,
91
+ thresholds = [80, 90, 95],
92
+ webhookUrl = '',
93
+ fetchImpl = fetch,
94
+ nowMs = Date.now(),
95
+ }) {
96
+ const pct = Number(usedPercent) || 0
97
+ if (pct <= 0) return null
98
+
99
+ // Find the highest threshold that was exceeded
100
+ const sorted = [...thresholds].sort((a, b) => b - a)
101
+ const matchedThreshold = sorted.find((th) => pct >= th)
102
+ if (!matchedThreshold) return null
103
+
104
+ const severity = pct >= 95 ? 'error' : 'warn'
105
+ const message = `[Quota Alert] ${provider || 'Account'} (${ref || 'slot'}) has reached ${Math.round(pct)}% used (threshold: ${matchedThreshold}%)`
106
+
107
+ const alert = recordAlert({
108
+ type: 'quota_threshold',
109
+ ref,
110
+ provider,
111
+ message,
112
+ severity,
113
+ details: { usedPercent: pct, threshold: matchedThreshold },
114
+ nowMs,
115
+ })
116
+
117
+ if (alert && webhookUrl) {
118
+ dispatchWebhookAlert(alert, webhookUrl, fetchImpl).catch(() => {})
119
+ }
120
+ return alert
121
+ }
122
+
123
+ /**
124
+ * Fires an alert when a session token expires or is revoked.
125
+ */
126
+ export function notifySessionExpired({
127
+ ref,
128
+ provider,
129
+ message,
130
+ webhookUrl = '',
131
+ fetchImpl = fetch,
132
+ nowMs = Date.now(),
133
+ }) {
134
+ const alert = recordAlert({
135
+ type: 'token_expired',
136
+ ref,
137
+ provider,
138
+ message: message || `Session for ${provider || 'account'} (${ref || 'slot'}) has expired and requires re-authentication.`,
139
+ severity: 'error',
140
+ nowMs,
141
+ })
142
+
143
+ if (alert && webhookUrl) {
144
+ dispatchWebhookAlert(alert, webhookUrl, fetchImpl).catch(() => {})
145
+ }
146
+ return alert
147
+ }
package/lib/cascade.js ADDED
@@ -0,0 +1,51 @@
1
+ // #349: Cascading Cross-Vendor Fallback Engine
2
+ // When a primary vendor's accounts are all exhausted (or return 429/503),
3
+ // route seamlessly to a compatible alternative vendor.
4
+
5
+ export const DEFAULT_CASCADE_CHAINS = {
6
+ claude: ['copilot', 'cursor', 'antigravity'],
7
+ codex: ['copilot', 'cursor'],
8
+ grok: ['copilot', 'antigravity'],
9
+ copilot: ['cursor', 'codex'],
10
+ cursor: ['copilot', 'claude'],
11
+ antigravity: ['copilot', 'claude'],
12
+ }
13
+
14
+ /**
15
+ * Resolves the next candidate fallback provider from the cascade chain.
16
+ * @param {string} currentProvider - currently failing provider
17
+ * @param {Set<string>|Array<string>} availableProviders - providers that currently have logged-in accounts
18
+ * @param {Record<string, string[]>} [customChains] - user-configured custom chains
19
+ * @param {Set<string>} [visited] - set of already attempted providers in this request cascade
20
+ * @returns {string|null} - the next candidate provider or null if none available
21
+ */
22
+ export function resolveFallbackVendor(currentProvider, availableProviders, customChains = {}, visited = new Set()) {
23
+ const availSet = new Set(availableProviders || [])
24
+ const chain = (customChains && customChains[currentProvider]) || DEFAULT_CASCADE_CHAINS[currentProvider] || []
25
+
26
+ for (const candidate of chain) {
27
+ if (candidate !== currentProvider && !visited.has(candidate) && availSet.has(candidate)) {
28
+ return candidate
29
+ }
30
+ }
31
+ return null
32
+ }
33
+
34
+ /**
35
+ * Maps a model identifier across vendors if needed.
36
+ * @param {string} sourceProvider
37
+ * @param {string} sourceModel
38
+ * @param {string} targetProvider
39
+ * @returns {string} target model name
40
+ */
41
+ export function mapFallbackModel(sourceProvider, sourceModel, targetProvider) {
42
+ const model = String(sourceModel || '').toLowerCase()
43
+ // If fallback to copilot/cursor from claude, prefer claude-compatible tag if requested
44
+ if ((targetProvider === 'copilot' || targetProvider === 'cursor') && model.includes('claude')) {
45
+ return 'claude-3-5-sonnet'
46
+ }
47
+ if (targetProvider === 'codex' && model.includes('claude')) {
48
+ return 'gpt-4o'
49
+ }
50
+ return sourceModel
51
+ }
package/lib/client.js CHANGED
@@ -1226,6 +1226,38 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1226
1226
  React.createElement('div', { className: 'dsub-stat-lbl' }, t('statsLastActivity')),
1227
1227
  ),
1228
1228
  ) : null,
1229
+ accounts && accounts.length ? React.createElement('details', { className: 'dsub-group', open: true, style: { marginBottom: '14px' } },
1230
+ React.createElement('summary', { className: 'dsub-groupHead' }, '📊 ' + (t('healthMatrix') || 'Health Matrix & Quota Runway')),
1231
+ React.createElement('div', { style: { padding: '8px 0', overflowX: 'auto' } },
1232
+ React.createElement('table', { style: { width: '100%', fontSize: '12px', borderCollapse: 'collapse' } },
1233
+ React.createElement('thead', null,
1234
+ React.createElement('tr', { style: { borderBottom: '1px solid var(--dsw-alias-border-primary, rgba(255,255,255,0.1))', textAlign: 'left', opacity: 0.7 } },
1235
+ React.createElement('th', { style: { padding: '6px' } }, 'Account'),
1236
+ React.createElement('th', { style: { padding: '6px' } }, 'Health'),
1237
+ React.createElement('th', { style: { padding: '6px' } }, 'Latency'),
1238
+ React.createElement('th', { style: { padding: '6px' } }, 'Status'),
1239
+ React.createElement('th', { style: { padding: '6px' } }, 'Quota / Runway'),
1240
+ ),
1241
+ ),
1242
+ React.createElement('tbody', null,
1243
+ accounts.map((acc, idx) => {
1244
+ const sc = acc.healthScore != null ? acc.healthScore : 100
1245
+ const healthColor = sc >= 80 ? '#10b981' : (sc >= 50 ? '#f59e0b' : '#ef4444')
1246
+ const isQ = acc.status === 'quarantine' || (acc.quarantineUntil && Number(acc.quarantineUntil) > Date.now())
1247
+ const statusLabel = isQ ? '⛔ Quarantine' : (acc.status === 'probing' ? '🟡 Warmup' : (acc.configured ? '🟢 Active' : '⚪ Idle'))
1248
+ const pct = acc.usagePercent != null ? Math.round(acc.usagePercent) : null
1249
+ return React.createElement('tr', { key: (acc.ref || acc.provider + acc.index || idx), style: { borderBottom: '1px solid var(--dsw-alias-border-secondary, rgba(255,255,255,0.05))' } },
1250
+ React.createElement('td', { style: { padding: '6px', fontWeight: 600 } }, (acc.provider ? acc.provider.toUpperCase() : 'LLM') + ' #' + (acc.index || 1) + (acc.label ? ' (' + acc.label + ')' : '')),
1251
+ React.createElement('td', { style: { padding: '6px', color: healthColor } }, sc + '%'),
1252
+ React.createElement('td', { style: { padding: '6px' } }, acc.latencyMs ? acc.latencyMs + ' ms' : '—'),
1253
+ React.createElement('td', { style: { padding: '6px' } }, statusLabel),
1254
+ React.createElement('td', { style: { padding: '6px' } }, pct != null ? (pct + '% used') : '—'),
1255
+ )
1256
+ }),
1257
+ ),
1258
+ ),
1259
+ ),
1260
+ ) : null,
1229
1261
  React.createElement('div', { className: 'dsub-block' },
1230
1262
  React.createElement('div', { className: 'dsub-h' }, t('title')),
1231
1263
  React.createElement('div', { className: 'dsub-sub' },
@@ -67,6 +67,16 @@ export const Config = z.object({
67
67
  antigravityClientSecret: z.string().default('')
68
68
  .description('Google Cloud OAuth Client Secret for Antigravity.'),
69
69
  antigravityRedirectUri: z.string().default(''),
70
+ cascadingFallback: z.boolean().default(false)
71
+ .description('#349 Opt-in cross-vendor fallback when all accounts of a provider are exhausted or rate limited.'),
72
+ cascadingChain: z.any().default({})
73
+ .description('#349 Custom cross-vendor fallback chains (e.g. { claude: ["copilot", "cursor"] }).'),
74
+ webhookUrl: z.string().default('')
75
+ .description('#351 External webhook URL to receive quota and session alerts (JSON POST).'),
76
+ alertThresholds: z.array(z.number()).default([80, 90, 95])
77
+ .description('#351 Quota usage percentage thresholds for firing alerts.'),
78
+ autoPacing: z.boolean().default(true)
79
+ .description('#352 Predictive burn-rate load balancing between accounts.'),
70
80
  customVendors: z.array(z.any()).default([])
71
81
  .description('Declarative OpenAI-Responses-compatible providers. See README.'),
72
82
  })
package/lib/forecast.js CHANGED
@@ -69,3 +69,45 @@ export function estimateForecast(state, key, remainingPercent, resetsAt, now) {
69
69
  sampleCount: samples.length,
70
70
  }
71
71
  }
72
+
73
+ // #352: Burn-rate calculation & Pacing risk estimator
74
+ export function calculateBurnRatePerHour(samples, now = Date.now()) {
75
+ if (!Array.isArray(samples) || samples.length < 2) return 0
76
+ const recent = samples.filter((s) => s.at >= now - 6 * HOUR_MS)
77
+ if (recent.length < 2) return 0
78
+ const first = recent[0]
79
+ const last = recent[recent.length - 1]
80
+ const hours = (last.at - first.at) / HOUR_MS
81
+ if (hours <= 0.05) return 0
82
+ const consumed = Math.max(0, first.pct - last.pct)
83
+ return consumed / hours
84
+ }
85
+
86
+ export function computePacingRisk(remainingPercent, pacePerHour, resetsAt, now = Date.now()) {
87
+ const rem = Number(remainingPercent) || 0
88
+ const pace = Number(pacePerHour) || 0
89
+ if (pace <= 0.02 || rem <= 0) {
90
+ return { pacePerHour: 0, hoursUntilExhaustion: Infinity, survivesReset: true, pacingRisk: 0 }
91
+ }
92
+
93
+ const hoursUntilExhaustion = rem / pace
94
+ const reset = Number(resetsAt) || 0
95
+ if (reset > now) {
96
+ const hoursUntilReset = (reset - now) / HOUR_MS
97
+ if (hoursUntilExhaustion < hoursUntilReset) {
98
+ // Risk: will exhaust before reset
99
+ const deficitHours = hoursUntilReset - hoursUntilExhaustion
100
+ const pacingRisk = Math.min(100, Math.round(deficitHours * 25))
101
+ return { pacePerHour: pace, hoursUntilExhaustion, survivesReset: false, pacingRisk }
102
+ } else {
103
+ return { pacePerHour: pace, hoursUntilExhaustion, survivesReset: true, pacingRisk: 0 }
104
+ }
105
+ }
106
+
107
+ return {
108
+ pacePerHour: pace,
109
+ hoursUntilExhaustion,
110
+ survivesReset: null,
111
+ pacingRisk: hoursUntilExhaustion < 1 ? 50 : 0,
112
+ }
113
+ }
package/lib/http.js CHANGED
@@ -42,7 +42,35 @@ export function isLoopback(value) {
42
42
  * @returns {boolean}
43
43
  * Reject cross-site writes. Do not require loopback: Web UI is used over LAN and reverse proxies. */
44
44
  export function isTrustedSettingsRequest(request) {
45
- return request.headers['sec-fetch-site'] !== 'cross-site'
45
+ const headers = request?.headers || {}
46
+ const secFetchSite = headers['sec-fetch-site']
47
+ if (secFetchSite) {
48
+ return secFetchSite !== 'cross-site'
49
+ }
50
+ const host = headers.host || headers['x-forwarded-host']
51
+ const origin = headers.origin
52
+ if (origin) {
53
+ try {
54
+ const u = new URL(origin)
55
+ if (host && u.host.toLowerCase() !== host.toLowerCase()) {
56
+ return false
57
+ }
58
+ } catch {
59
+ return false
60
+ }
61
+ }
62
+ const referer = headers.referer
63
+ if (!origin && referer) {
64
+ try {
65
+ const u = new URL(referer)
66
+ if (host && u.host.toLowerCase() !== host.toLowerCase()) {
67
+ return false
68
+ }
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+ return true
46
74
  }
47
75
 
48
76
  export function queryOf(req) {
package/lib/index.js CHANGED
@@ -26,13 +26,25 @@ import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.j
26
26
  import { createResetCreditService } from './reset-credits.js'
27
27
  import { maskEmail, maskLabel, maskText } from './mask.js'
28
28
  import { proxyFetch, pickFetch } from './proxy.js'
29
- import { HistoryStore } from './history.js'
29
+ import { HistoryStore, DEFAULT_HISTORY_DIR } from './history.js'
30
+ export { observeForecast, estimateForecast } from './forecast.js'
31
+ export { formatRelativeReset } from './relative-time.js'
30
32
  import {
31
33
  inspectGoogleAccount,
32
34
  antigravityMetadata,
33
35
  antigravityIdentityHeaders,
34
36
  } from './code-assist.js'
35
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
+ export { resolveFallbackVendor, mapFallbackModel, DEFAULT_CASCADE_CHAINS } from './cascade.js'
44
+ export { exportVault, importVault } from './vault.js'
45
+ export { recordAlert, getRecentAlerts, checkQuotaThresholds, notifySessionExpired } from './alerts.js'
46
+ export { calculateBurnRatePerHour, computePacingRisk } from './forecast.js'
47
+ export { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure } from './quarantine.js'
36
48
  export const name = '@goodandready/dsh-subscriptions'
37
49
  export const inject = ['llm', 'credentials', 'webServer', 'settings']
38
50
 
@@ -116,7 +128,7 @@ export function apply(ctx, config) {
116
128
  bestEffort('emit limit-notice', () => ctx.emit && ctx.emit('subscriptions.limit-notice', { provider, ref, window: win.id, usedPercent: win.usedPercent, threshold }), undefined, logger)
117
129
  },
118
130
  })
119
- const history = new HistoryStore()
131
+ const history = new HistoryStore(DEFAULT_HISTORY_DIR, 7 * 24 * 60 * 60 * 1000, 1000)
120
132
  const recordHistory = (entry) => history.add(entry)
121
133
 
122
134
  // #85: host-only reset credit service for Codex accounts.
@@ -186,6 +198,7 @@ export function apply(ctx, config) {
186
198
  fetchImpl: fetch,
187
199
  fetchForRef,
188
200
  ollamaFallback: ollamaFallbackStream,
201
+ cascadingFallback: cascadeFallbackStream,
189
202
  hideDeprecatedModels: () => !!live().hideDeprecatedModels,
190
203
  })
191
204
 
@@ -230,6 +243,31 @@ export function apply(ctx, config) {
230
243
  },
231
244
  }), 'dsh-subscriptions: image generation service')
232
245
  const pending = new Map()
246
+ async function* cascadeFallbackStream({ options, provider, err }) {
247
+ const cfg = live()
248
+ if (!cfg.cascadingFallback) throw err
249
+ const logged = await store.loggedInProviders()
250
+ const available = Object.keys(logged || {}).filter((p) => logged[p] && p !== provider)
251
+ const nextVendor = resolveFallbackVendor(provider, available, cfg.cascadingChain)
252
+ if (!nextVendor) throw err
253
+ const targetModel = mapFallbackModel(provider, options && options.model, nextVendor)
254
+ if (logger && logger.warn) {
255
+ logger.warn('[dsh-subscriptions] ' + provider + ': all accounts exhausted, cascading fallback to ' + nextVendor + '/' + targetModel)
256
+ }
257
+ try {
258
+ recordHistory({
259
+ provider: nextVendor,
260
+ ref: String(nextVendor).toUpperCase() + '_CASCADE_FALLBACK',
261
+ model: targetModel,
262
+ path: '/v1/chat/completions',
263
+ method: 'POST',
264
+ status: 200,
265
+ kind: 'cascade',
266
+ })
267
+ } catch (e) { /* ignore cascade history recording error */ }
268
+ yield* adapter.stream({ ...options, provider: nextVendor, model: targetModel })
269
+ }
270
+
233
271
  const adapter = new SubscriptionAdapter({
234
272
  listAccounts: (provider) => store.listAccounts(provider),
235
273
  loadBlob: (ref) => store.loadBlob(ref),
@@ -324,18 +362,24 @@ export function apply(ctx, config) {
324
362
  const existing = normalizeSlots(live().slots)
325
363
  const existingRefs = new Set(existing.map((s) => s.ref))
326
364
  const candidateProviders = PROVIDERS.filter((p) => isProvider(p))
327
- const additions = []
328
-
365
+ const candidates = []
329
366
  for (const provider of candidateProviders) {
330
367
  for (let index = 1; index <= 10; index++) {
331
368
  const ref = oauthRef(provider, index)
332
- if (existingRefs.has(ref)) continue
369
+ if (!existingRefs.has(ref)) {
370
+ candidates.push({ provider, index, ref })
371
+ }
372
+ }
373
+ }
374
+
375
+ const additionResults = await Promise.all(
376
+ candidates.map(async ({ provider, index, ref }) => {
333
377
  let d
334
378
  try {
335
379
  d = await creds.describe(credentialRef(ref))
336
380
  } catch (err) {
337
381
  if (logger && logger.debug) logger.debug(`[dsh-subscriptions reconcile] describe ${ref} error: ` + (err && err.message || err))
338
- continue
382
+ return null
339
383
  }
340
384
  if (d && d.configured) {
341
385
  let label = d.label || ''
@@ -347,10 +391,12 @@ export function apply(ctx, config) {
347
391
  } catch (loadErr) {
348
392
  if (logger && logger.debug) logger.debug(`[dsh-subscriptions reconcile] loadBlob ${ref} error: ` + (loadErr && loadErr.message || loadErr))
349
393
  }
350
- additions.push({ provider, index, label })
394
+ return { provider, index, label }
351
395
  }
352
- }
353
- }
396
+ return null
397
+ })
398
+ )
399
+ const additions = additionResults.filter(Boolean)
354
400
 
355
401
  if (!additions.length) return
356
402
  if (logger && logger.info) {
@@ -700,7 +746,7 @@ export function apply(ctx, config) {
700
746
  const vendor = getVendor(slot.provider)
701
747
  if (typeof vendor.check !== 'function') continue
702
748
  const cfg2 = vendorConfig(slot.provider, live())
703
- const probeFetch = async (u, i) => fetch(u, i)
749
+ const probeFetch = async (u, i) => ((typeof fetchForRef === 'function' && fetchForRef(ref)) || fetch)(u, i)
704
750
  await vendor.check(fresh, cfg2, probeFetch).catch((e) => {
705
751
  if (logger && logger.warn) logger.warn("[dsh-subscriptions] probe " + ref + ": " + String(e && e.message || e).slice(0, 200))
706
752
  })
package/lib/quarantine.js CHANGED
@@ -1,7 +1,12 @@
1
+ // #350: Self-healing & Warm-up probe before returning from quarantine.
1
2
  export const REASON_RATE_LIMIT = 'RATE_LIMIT'
2
3
  export const REASON_HARD_LIMIT = 'HARD_LIMIT'
3
4
  export const REASON_REVOKED = 'TOKEN_REVOKED'
4
5
 
6
+ export const STATUS_ACTIVE = 'active'
7
+ export const STATUS_QUARANTINE = 'quarantine'
8
+ export const STATUS_PROBING = 'probing'
9
+
5
10
  export function quarantineDuration(reason) {
6
11
  switch (reason) {
7
12
  case REASON_HARD_LIMIT:
@@ -20,12 +25,64 @@ export function putInQuarantine(account, reason, nowMs = Date.now()) {
20
25
  ...account,
21
26
  quarantineReason: reason,
22
27
  quarantineUntil: nowMs + duration,
23
- status: 'quarantine'
28
+ status: STATUS_QUARANTINE,
24
29
  }
25
30
  }
26
31
 
27
32
  export function isQuarantined(account, nowMs = Date.now()) {
28
- if (!account || !account.quarantineUntil) return false
33
+ if (!account) return false
34
+ if (account.status === STATUS_PROBING) return true
35
+ if (!account.quarantineUntil) return false
29
36
  return Number(account.quarantineUntil) > Number(nowMs)
30
37
  }
31
38
 
39
+ /**
40
+ * Checks whether an account has expired its quarantine period and needs a warm-up probe.
41
+ */
42
+ export function needsWarmupProbe(account, nowMs = Date.now()) {
43
+ if (!account) return false
44
+ const until = Number(account.quarantineUntil) || 0
45
+ if (until === 0) return false
46
+ return until <= Number(nowMs) && (account.status === STATUS_QUARANTINE || account.status === STATUS_PROBING)
47
+ }
48
+
49
+ /**
50
+ * Transitions account to probing state for warm-up.
51
+ */
52
+ export function enterProbing(account) {
53
+ return {
54
+ ...account,
55
+ status: STATUS_PROBING,
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Restores an account back to active service after a successful warm-up probe.
61
+ */
62
+ export function resolveWarmupSuccess(account, nowMs = Date.now()) {
63
+ return {
64
+ ...account,
65
+ status: STATUS_ACTIVE,
66
+ quarantineUntil: 0,
67
+ quarantineReason: null,
68
+ quarantineAttempts: 0,
69
+ probePassedAt: nowMs,
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Extends quarantine with exponential backoff if warmup probe failed.
75
+ */
76
+ export function resolveWarmupFailure(account, reason, nowMs = Date.now()) {
77
+ const baseDuration = quarantineDuration(reason || account?.quarantineReason || REASON_RATE_LIMIT)
78
+ const attempts = Math.min((Number(account?.quarantineAttempts) || 1) * 2, 8)
79
+ const extendedDuration = Math.min(baseDuration * attempts, 48 * 60 * 60 * 1000) // max 48h
80
+ return {
81
+ ...account,
82
+ status: STATUS_QUARANTINE,
83
+ quarantineReason: reason || account?.quarantineReason || REASON_RATE_LIMIT,
84
+ quarantineUntil: nowMs + extendedDuration,
85
+ quarantineAttempts: attempts,
86
+ probeFailedAt: nowMs,
87
+ }
88
+ }
@@ -64,6 +64,14 @@ export function parseConsumeResult(raw) {
64
64
  export function createResetCreditService({ loadBlob, fetchImpl, now = Date.now, randomId = randomUUID }) {
65
65
  const challenges = new Map()
66
66
 
67
+ function pruneChallenges(nowMs = now()) {
68
+ for (const [id, ch] of challenges.entries()) {
69
+ if (nowMs > ch.expiresAt) {
70
+ challenges.delete(id)
71
+ }
72
+ }
73
+ }
74
+
67
75
  async function readDetails(ref) {
68
76
  const blob = await loadBlob(ref)
69
77
  const access = blob && blob.accessToken
@@ -101,6 +109,7 @@ export function createResetCreditService({ loadBlob, fetchImpl, now = Date.now,
101
109
  async prepare(ref) {
102
110
  const { accountId, details } = await readDetails(ref)
103
111
  const preparedAt = now()
112
+ pruneChallenges(preparedAt)
104
113
  const readyAt = preparedAt + CONFIRM_DELAY_MS
105
114
  const expiresAt = Math.min(preparedAt + CHALLENGE_TTL_MS, details.creditExpiresAt === undefined ? Number.MAX_SAFE_INTEGER : details.creditExpiresAt)
106
115
  if (expiresAt <= readyAt) throw new Error('the available reset expires too soon')
package/lib/rotate.js CHANGED
@@ -112,6 +112,11 @@ export function pickAccount(accounts, nowMs, opts) {
112
112
  for (const tier of tiers) {
113
113
  if (tier.length) {
114
114
  tier.sort((a, b) => {
115
+ // 0. Auto-Pacing: lower pacing risk preferred (#352)
116
+ const aRisk = Number(a.pacingRisk) || 0
117
+ const bRisk = Number(b.pacingRisk) || 0
118
+ if (Math.abs(aRisk - bRisk) >= 20) return aRisk - bRisk
119
+
115
120
  // 1. Least Remaining Window (#167)
116
121
  if (a.quota && b.quota && a.quota.resetAt && b.quota.resetAt) {
117
122
  const aReset = Number(a.quota.resetAt)
@@ -1,3 +1,4 @@
1
+ import { exportVault, importVault } from '../vault.js'
1
2
  import { normalizeSlots } from '../accounts.js'
2
3
  import { Config, publicConfig } from '../config-schema.js'
3
4
  import { decryptWithPassphrase, encryptWithPassphrase } from '../crypto.js'
@@ -339,4 +340,102 @@ export function registerAccountsRoutes(ctx, state) {
339
340
  }
340
341
  }),
341
342
  }), 'dsh-subscriptions: /logout')
343
+
344
+ // #353: Encrypted Vault export
345
+ ctx.effect(() => ctx.webServer.register({
346
+ kind: 'exact',
347
+ path: '/dsh-subscriptions/vault/export',
348
+ handler: safeJsonHandler(async (req, res) => {
349
+ if (req.method !== 'POST') {
350
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
351
+ return
352
+ }
353
+ if (!isTrustedSettingsRequest(req)) {
354
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
355
+ return
356
+ }
357
+ let payload
358
+ try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
359
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
360
+ return
361
+ }
362
+ const passphrase = payload.passphrase
363
+ try {
364
+ const slots = live().slots || []
365
+ const blobs = {}
366
+ for (const slot of normalizeSlots(slots)) {
367
+ try {
368
+ const blob = await store.loadBlob(slot.ref)
369
+ if (blob) blobs[slot.ref] = blob
370
+ } catch { /* ignore */ }
371
+ }
372
+ const result = exportVault({ slots, blobs }, passphrase)
373
+ writeJson(res, 200, result)
374
+ } catch (e) {
375
+ writeJson(res, 400, { ok: false, error: { code: 'vault_export', message: String(e && e.message || e) } })
376
+ }
377
+ }),
378
+ }), 'dsh-subscriptions: /vault/export')
379
+
380
+ // #353: Encrypted Vault import
381
+ ctx.effect(() => ctx.webServer.register({
382
+ kind: 'exact',
383
+ path: '/dsh-subscriptions/vault/import',
384
+ handler: safeJsonHandler(async (req, res) => {
385
+ if (req.method !== 'POST') {
386
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
387
+ return
388
+ }
389
+ if (!isTrustedSettingsRequest(req)) {
390
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
391
+ return
392
+ }
393
+ let payload
394
+ try { payload = JSON.parse((await readBody(req, 2 * 1024 * 1024)).toString('utf8') || '{}') } catch {
395
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
396
+ return
397
+ }
398
+ const { passphrase, vault } = payload
399
+ try {
400
+ const result = importVault(vault, passphrase)
401
+ // Save imported blobs
402
+ let blobCount = 0
403
+ if (result.blobs && typeof result.blobs === 'object') {
404
+ for (const [ref, blob] of Object.entries(result.blobs)) {
405
+ try {
406
+ await store.saveBlob(ref, blob)
407
+ blobCount++
408
+ } catch { /* ignore */ }
409
+ }
410
+ }
411
+ // Merge imported slots into configuration
412
+ if (Array.isArray(result.slots) && result.slots.length && getSettingsApi()) {
413
+ const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
414
+ for (const s of result.slots) {
415
+ const idx = curSlots.findIndex((c) => c.provider === s.provider && Number(c.index) === Number(s.index))
416
+ if (idx >= 0) {
417
+ curSlots[idx] = { ...curSlots[idx], ...s }
418
+ } else {
419
+ curSlots.push(s)
420
+ }
421
+ }
422
+ const parsed = Config({ ...live(), slots: stripLegacySlots(curSlots) })
423
+ await getSettingsApi().replace(parsed)
424
+ syncCustomVendors()
425
+ }
426
+ await syncAdapter()
427
+ refreshModels().catch(() => {})
428
+ writeJson(res, 200, {
429
+ ok: true,
430
+ slotCount: result.slots.length,
431
+ blobCount,
432
+ exportedAt: result.exportedAt,
433
+ accounts: await accountsView(),
434
+ })
435
+ } catch (e) {
436
+ writeJson(res, 400, { ok: false, error: { code: 'vault_import', message: String(e && e.message || e) } })
437
+ }
438
+ }),
439
+ }), 'dsh-subscriptions: /vault/import')
440
+
342
441
  }
@@ -1,3 +1,4 @@
1
+ import { getRecentAlerts } from '../alerts.js'
1
2
  import { normalizeSlots, vendorConfig } from '../accounts.js'
2
3
  import { Config, publicConfig } from '../config-schema.js'
3
4
  import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
@@ -18,6 +19,7 @@ export function registerStatusRoutes(ctx, state) {
18
19
  diagnosticsReport,
19
20
  pmL = (s) => s,
20
21
  pmE = (s) => s,
22
+ fetchForRef,
21
23
  } = state
22
24
 
23
25
  async function configResponse() {
@@ -213,7 +215,8 @@ export function registerStatusRoutes(ctx, state) {
213
215
  const probeFetch = async (url, init = {}) => {
214
216
  const timeoutSignal = AbortSignal.timeout(15_000)
215
217
  const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
216
- const res2 = await fetch(url, { ...init, signal })
218
+ const doFetch = (typeof fetchForRef === "function" && fetchForRef(ref)) || fetch
219
+ const res2 = await doFetch(url, { ...init, signal })
217
220
  try {
218
221
  const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
219
222
  if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
@@ -327,7 +330,8 @@ export function registerStatusRoutes(ctx, state) {
327
330
  const probeFetch = async (url, init = {}) => {
328
331
  const timeoutSignal = AbortSignal.timeout(15_000)
329
332
  const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
330
- const res2 = await fetch(url, { ...init, signal })
333
+ const doFetch = (typeof fetchForRef === "function" && fetchForRef(ref)) || fetch
334
+ const res2 = await doFetch(url, { ...init, signal })
331
335
  try {
332
336
  const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
333
337
  if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
@@ -379,4 +383,19 @@ export function registerStatusRoutes(ctx, state) {
379
383
  })
380
384
  }),
381
385
  }), 'dsh-subscriptions: /smoke')
386
+
387
+ // #351: Quota and session alerts API
388
+ ctx.effect(() => ctx.webServer.register({
389
+ kind: 'exact',
390
+ path: '/dsh-subscriptions/alerts',
391
+ handler: safeJsonHandler(async (req, res) => {
392
+ if (req.method !== 'GET') {
393
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
394
+ return
395
+ }
396
+ const limit = Math.min(Number(queryOf(req).get('limit') || '50'), 100)
397
+ writeJson(res, 200, { ok: true, alerts: getRecentAlerts(limit) })
398
+ }),
399
+ }), 'dsh-subscriptions: /alerts')
400
+
382
401
  }
@@ -8,8 +8,19 @@ const DEFAULT_TTL_MS = 30 * 60 * 1000 // 30 mins
8
8
  * @param {number} [ttlMs]
9
9
  * @returns {void}
10
10
  */
11
+ export function pruneSessionPins(now = Date.now()) {
12
+ for (const [id, entry] of sessionPins.entries()) {
13
+ if (now > entry.expiresAt) {
14
+ sessionPins.delete(id)
15
+ }
16
+ }
17
+ }
18
+
11
19
  export function pinSession(sessionId, accountRef, ttlMs = DEFAULT_TTL_MS) {
12
20
  if (!sessionId || !accountRef) return
21
+ if (sessionPins.size >= 50) {
22
+ pruneSessionPins()
23
+ }
13
24
  sessionPins.set(sessionId, {
14
25
  accountRef,
15
26
  expiresAt: Date.now() + ttlMs
@@ -9,6 +9,7 @@ export async function* streamWithRotation({
9
9
  streamOnce,
10
10
  options,
11
11
  onCooldown,
12
+ cascadeFallback, // #349: optional cross-vendor cascading fallback generator
12
13
  offlineFallback, // #174: optional fallback generator if all accounts exhausted
13
14
  }) {
14
15
  const pool = (accounts || []).map((account) => ({ ...account }))
@@ -25,6 +26,11 @@ export async function* streamWithRotation({
25
26
  })
26
27
 
27
28
  if (!account || tried.has(account.ref || account.id)) {
29
+ if (cascadeFallback) {
30
+ // #349 Cascading Cross-Vendor Fallback
31
+ yield* cascadeFallback(options, lastError)
32
+ return
33
+ }
28
34
  if (offlineFallback) {
29
35
  // #174 Local Mock Server Offline Fallback
30
36
  yield* offlineFallback(options, lastError)
package/lib/vault.js ADDED
@@ -0,0 +1,74 @@
1
+ // #353: Encrypted Vault Export/Import for Subscriptions
2
+ // Secure migration and backup of slots and credentials using AES-256-GCM.
3
+
4
+ import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
5
+
6
+ export const VAULT_FORMAT = 'dsh-subscriptions-vault-v1'
7
+
8
+ /**
9
+ * Encrypts slots configuration and credential blobs into a portable vault string.
10
+ */
11
+ export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscriptions' }, passphrase) {
12
+ const pass = String(passphrase || '').trim()
13
+ if (!pass || pass.length < 4) {
14
+ throw new Error('passphrase must be at least 4 characters')
15
+ }
16
+
17
+ const payload = {
18
+ format: VAULT_FORMAT,
19
+ version: '0.6.17',
20
+ exportedAt: new Date().toISOString(),
21
+ exportedBy,
22
+ slots: Array.isArray(slots) ? slots : [],
23
+ blobs: blobs && typeof blobs === 'object' ? blobs : {},
24
+ }
25
+
26
+ const encrypted = encryptWithPassphrase(JSON.stringify(payload), pass)
27
+ return {
28
+ ok: true,
29
+ vault: encrypted,
30
+ slotCount: payload.slots.length,
31
+ blobCount: Object.keys(payload.blobs).length,
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Decrypts and validates an encrypted vault string.
37
+ */
38
+ export function importVault(encryptedPayload, passphrase) {
39
+ const pass = String(passphrase || '').trim()
40
+ if (!pass) {
41
+ throw new Error('passphrase is required')
42
+ }
43
+
44
+ const raw = String(encryptedPayload || '').trim()
45
+ if (!raw.startsWith('DSHE1:')) {
46
+ throw new Error('invalid vault format: missing DSHE1 header')
47
+ }
48
+
49
+ let jsonStr
50
+ try {
51
+ jsonStr = decryptWithPassphrase(raw, pass)
52
+ } catch (err) {
53
+ throw new Error('failed to decrypt vault: invalid passphrase or corrupted payload')
54
+ }
55
+
56
+ let data
57
+ try {
58
+ data = JSON.parse(jsonStr)
59
+ } catch (err) {
60
+ throw new Error('malformed vault content: invalid JSON')
61
+ }
62
+
63
+ if (!data || data.format !== VAULT_FORMAT || !Array.isArray(data.slots) || typeof data.blobs !== 'object') {
64
+ throw new Error('invalid vault schema: unsupported structure')
65
+ }
66
+
67
+ return {
68
+ ok: true,
69
+ slots: data.slots,
70
+ blobs: data.blobs,
71
+ version: data.version,
72
+ exportedAt: data.exportedAt,
73
+ }
74
+ }
@@ -111,6 +111,85 @@ export async function pollDeviceToken(deviceCode, fetchImpl) {
111
111
  return readJson(res)
112
112
  }
113
113
 
114
+
115
+ export async function deviceStart(cfg, fetchImpl) {
116
+ const json = await requestDeviceCode(fetchImpl)
117
+ if (!json || !json.user_code || !json.device_code) {
118
+ throw httpError(502, "GitHub device code response is incomplete", "DEVICE")
119
+ }
120
+ return {
121
+ userCode: json.user_code,
122
+ deviceAuthId: json.device_code,
123
+ intervalMs: Math.max(parseInt(json.interval, 10) || 5, 1) * 1000,
124
+ authUrl: json.verification_uri || "https://github.com/login/device",
125
+ }
126
+ }
127
+
128
+ export async function devicePoll(cfg, session, fetchImpl) {
129
+ const impl = fetchImpl || fetch
130
+ let json
131
+ try {
132
+ json = await pollDeviceToken(session.deviceAuthId, impl)
133
+ } catch (err) {
134
+ throw httpError(502, String(err && err.message || err), "DEVICE")
135
+ }
136
+ if (json.error === "authorization_pending" || json.error === "slow_down") {
137
+ return { status: "pending" }
138
+ }
139
+ if (json.error) {
140
+ throw httpError(400, json.error_description || json.error, "DEVICE")
141
+ }
142
+ if (!json.access_token) {
143
+ return { status: "pending" }
144
+ }
145
+ const githubToken = json.access_token
146
+ const exchange = await exchangeCopilotToken(githubToken, impl)
147
+ const copilotToken = exchange && exchange.token
148
+ const expiresAt = exchange && exchange.expires_at ? exchange.expires_at * 1000 : Date.now() + 30 * 60 * 1000
149
+
150
+ let label = "copilot"
151
+ let email = ""
152
+ try {
153
+ const userRes = await impl(COPILOT_USER_URL, {
154
+ headers: {
155
+ Authorization: `Bearer ${githubToken}`,
156
+ "User-Agent": "GitHubCopilotChat/0.24.0",
157
+ },
158
+ })
159
+ if (userRes.ok) {
160
+ const user = await readJson(userRes)
161
+ if (user && user.login) {
162
+ label = user.login
163
+ if (user.email) email = user.email
164
+ }
165
+ }
166
+ } catch { /* best-effort */ }
167
+
168
+ return {
169
+ status: "authorized",
170
+ blob: {
171
+ accessToken: copilotToken || githubToken,
172
+ refreshToken: githubToken,
173
+ githubToken,
174
+ expiresAt,
175
+ label,
176
+ email,
177
+ },
178
+ }
179
+ }
180
+
181
+ export async function refresh(cfg, blob, fetchImpl) {
182
+ const impl = fetchImpl || fetch
183
+ const githubToken = blob && (blob.refreshToken || blob.githubToken)
184
+ if (!githubToken) return blob
185
+ const exchange = await exchangeCopilotToken(githubToken, impl)
186
+ return {
187
+ ...blob,
188
+ accessToken: exchange.token || blob.accessToken,
189
+ expiresAt: exchange.expires_at ? exchange.expires_at * 1000 : Date.now() + 30 * 60 * 1000,
190
+ }
191
+ }
192
+
114
193
  export async function exchangeCopilotToken(githubToken, fetchImpl) {
115
194
  const impl = fetchImpl || fetch
116
195
  const res = await impl(COPILOT_TOKEN_URL, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.15",
3
+ "version": "0.6.17",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",