@goodandready/dsh-subscriptions 0.6.17 → 0.6.19

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/adapter.js CHANGED
@@ -7,6 +7,7 @@ import { streamWithRotation } from './stream-rotate.js'
7
7
  import { pickFetch } from './proxy.js'
8
8
  import { quotaSnapshot, parseBody } from './ratelimit.js'
9
9
  import { toTokenUsage } from './wire.js'
10
+ import { isSwitchableError } from './rotate.js'
10
11
 
11
12
  // #94: heuristic for test/preview/beta/legacy model ids.
12
13
  function isDeprecatedId(id) {
@@ -93,7 +94,7 @@ export class SubscriptionAdapter extends LlmAdapter {
93
94
  }
94
95
  }
95
96
 
96
- async *stream(options) {
97
+ async *stream(options, visited = new Set()) {
97
98
  const provider = options.provider
98
99
  const deps = this.deps
99
100
  let yieldedAny = false
@@ -193,13 +194,16 @@ export class SubscriptionAdapter extends LlmAdapter {
193
194
  }
194
195
  return
195
196
  } catch (err) {
196
- // #349: opt-in cascading cross-vendor fallback when all accounts are exhausted
197
- if (!yieldedAny && typeof deps.cascadingFallback === 'function') {
197
+ // #349 & #361: opt-in cascading cross-vendor fallback with abort and cycle checks
198
+ if (!yieldedAny && !options.signal?.aborted && isSwitchableError(err) && typeof deps.cascadingFallback === 'function') {
198
199
  try {
199
- yield* deps.cascadingFallback({ options, provider, err })
200
+ for await (const event of deps.cascadingFallback({ options, provider, err, visited })) {
201
+ yieldedAny = true
202
+ yield event
203
+ }
200
204
  return
201
205
  } catch (cascadeErr) {
202
- /* proceed to ollama fallback or error rethrow */
206
+ err = cascadeErr
203
207
  }
204
208
  }
205
209
  // #91: when the whole pool is exhausted and nothing has been streamed
package/lib/client.js CHANGED
@@ -493,7 +493,17 @@ const cssId = 'dsh-subscriptions/settings.module.css'
493
493
  "updateNow": "Update",
494
494
  "updating": "Updating…",
495
495
  "updateSuccessRestart": "Update complete. Restarting service…",
496
- "updateFailed": "Update failed"
496
+ "updateFailed": "Update failed",
497
+ "healthMatrix": "Health Matrix & Quota Runway",
498
+ "healthAccount": "Account",
499
+ "healthScore": "Health",
500
+ "healthLatency": "Latency",
501
+ "healthStatus": "Status",
502
+ "healthQuota": "Quota / Runway",
503
+ "healthQuarantine": "Quarantine",
504
+ "healthWarmup": "Warmup",
505
+ "healthActive": "Active",
506
+ "healthIdle": "Idle"
497
507
  };
498
508
 
499
509
  const zh = {
@@ -663,7 +673,17 @@ const cssId = 'dsh-subscriptions/settings.module.css'
663
673
  "updateNow": "立即更新",
664
674
  "updating": "正在更新…",
665
675
  "updateSuccessRestart": "更新成功,服务重启中…",
666
- "updateFailed": "更新失败"
676
+ "updateFailed": "更新失败",
677
+ "healthMatrix": "健康矩阵与配额预测",
678
+ "healthAccount": "账户",
679
+ "healthScore": "健康度",
680
+ "healthLatency": "延迟",
681
+ "healthStatus": "状态",
682
+ "healthQuota": "额度 / 余量",
683
+ "healthQuarantine": "隔离期",
684
+ "healthWarmup": "预热中",
685
+ "healthActive": "活跃",
686
+ "healthIdle": "空闲"
667
687
  };
668
688
  // #51: live countdown to the quota window reset (account.quota.resetAt).
669
689
  function cleanErrorMessage(raw) {
@@ -1232,11 +1252,11 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1232
1252
  React.createElement('table', { style: { width: '100%', fontSize: '12px', borderCollapse: 'collapse' } },
1233
1253
  React.createElement('thead', null,
1234
1254
  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'),
1255
+ React.createElement('th', { style: { padding: '6px' } }, t('healthAccount')),
1256
+ React.createElement('th', { style: { padding: '6px' } }, t('healthScore')),
1257
+ React.createElement('th', { style: { padding: '6px' } }, t('healthLatency')),
1258
+ React.createElement('th', { style: { padding: '6px' } }, t('healthStatus')),
1259
+ React.createElement('th', { style: { padding: '6px' } }, t('healthQuota')),
1240
1260
  ),
1241
1261
  ),
1242
1262
  React.createElement('tbody', null,
@@ -1244,7 +1264,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1244
1264
  const sc = acc.healthScore != null ? acc.healthScore : 100
1245
1265
  const healthColor = sc >= 80 ? '#10b981' : (sc >= 50 ? '#f59e0b' : '#ef4444')
1246
1266
  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'))
1267
+ const statusLabel = isQ ? ('⛔ ' + t('healthQuarantine')) : (acc.status === 'probing' ? ('🟡 ' + t('healthWarmup')) : (acc.configured ? ('🟢 ' + t('healthActive')) : ('⚪ ' + t('healthIdle'))))
1248
1268
  const pct = acc.usagePercent != null ? Math.round(acc.usagePercent) : null
1249
1269
  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
1270
  React.createElement('td', { style: { padding: '6px', fontWeight: 600 } }, (acc.provider ? acc.provider.toUpperCase() : 'LLM') + ' #' + (acc.index || 1) + (acc.label ? ' (' + acc.label + ')' : '')),
@@ -1879,7 +1899,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1879
1899
  name: 'plugins.item',
1880
1900
  id: ROW_ID,
1881
1901
  order: 60,
1882
- label: () => 'Subscriptions',
1902
+ label: () => t('title') || 'Subscriptions',
1883
1903
  locale: NS,
1884
1904
  inject: () => ({ ctx: ctx }),
1885
1905
  },
package/lib/index.js CHANGED
@@ -243,17 +243,20 @@ export function apply(ctx, config) {
243
243
  },
244
244
  }), 'dsh-subscriptions: image generation service')
245
245
  const pending = new Map()
246
- async function* cascadeFallbackStream({ options, provider, err }) {
246
+ async function* cascadeFallbackStream({ options, provider, err, visited = new Set() }) {
247
247
  const cfg = live()
248
248
  if (!cfg.cascadingFallback) throw err
249
249
  const logged = await store.loggedInProviders()
250
250
  const available = Object.keys(logged || {}).filter((p) => logged[p] && p !== provider)
251
- const nextVendor = resolveFallbackVendor(provider, available, cfg.cascadingChain)
251
+ visited.add(provider)
252
+ const nextVendor = resolveFallbackVendor(provider, available, cfg.cascadingChain, visited)
252
253
  if (!nextVendor) throw err
254
+ visited.add(nextVendor)
253
255
  const targetModel = mapFallbackModel(provider, options && options.model, nextVendor)
254
256
  if (logger && logger.warn) {
255
257
  logger.warn('[dsh-subscriptions] ' + provider + ': all accounts exhausted, cascading fallback to ' + nextVendor + '/' + targetModel)
256
258
  }
259
+ yield* adapter.stream({ ...options, provider: nextVendor, model: targetModel }, visited)
257
260
  try {
258
261
  recordHistory({
259
262
  provider: nextVendor,
@@ -265,7 +268,6 @@ export function apply(ctx, config) {
265
268
  kind: 'cascade',
266
269
  })
267
270
  } catch (e) { /* ignore cascade history recording error */ }
268
- yield* adapter.stream({ ...options, provider: nextVendor, model: targetModel })
269
271
  }
270
272
 
271
273
  const adapter = new SubscriptionAdapter({
@@ -287,6 +289,7 @@ export function apply(ctx, config) {
287
289
  })
288
290
 
289
291
  let handle
292
+ let adapterDisposed = false
290
293
  let registeredByUs = new Set()
291
294
  function disposeAdapterHandle(h) {
292
295
  if (!h) return
@@ -299,6 +302,7 @@ export function apply(ctx, config) {
299
302
  }
300
303
 
301
304
  async function syncAdapter() {
305
+ if (adapterDisposed) return
302
306
  const desired = await store.loggedInProviders()
303
307
  const globalProviders = ctx.llm && typeof ctx.llm.listProviders === 'function'
304
308
  ? ctx.llm.listProviders().map((p) => p.id)
@@ -639,11 +643,12 @@ export function apply(ctx, config) {
639
643
  let retries = 0
640
644
  let retryTimer
641
645
  const scheduleRetry = () => {
642
- if (retries >= 30) return
646
+ if (adapterDisposed || retries >= 30) return
643
647
  retryTimer = setTimeout(retrySync, 1000)
644
648
  if (retryTimer && typeof retryTimer.unref === 'function') retryTimer.unref()
645
649
  }
646
650
  const retrySync = async () => {
651
+ if (adapterDisposed) return
647
652
  try {
648
653
  const loggedIn = await store.loggedInProviders()
649
654
  if (!loggedIn.length) return
@@ -682,6 +687,7 @@ export function apply(ctx, config) {
682
687
  }
683
688
  eager()
684
689
  return () => {
690
+ adapterDisposed = true
685
691
  if (retryTimer) clearTimeout(retryTimer)
686
692
  if (handle) {
687
693
  disposeAdapterHandle(handle)
@@ -398,18 +398,10 @@ export function registerAccountsRoutes(ctx, state) {
398
398
  const { passphrase, vault } = payload
399
399
  try {
400
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()) {
401
+ const settings = getSettingsApi()
402
+ let parsed
403
+ if (result.slots.length) {
404
+ if (!settings) throw new Error('settings are unavailable for vault import')
413
405
  const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
414
406
  for (const s of result.slots) {
415
407
  const idx = curSlots.findIndex((c) => c.provider === s.provider && Number(c.index) === Number(s.index))
@@ -419,8 +411,15 @@ export function registerAccountsRoutes(ctx, state) {
419
411
  curSlots.push(s)
420
412
  }
421
413
  }
422
- const parsed = Config({ ...live(), slots: stripLegacySlots(curSlots) })
423
- await getSettingsApi().replace(parsed)
414
+ parsed = Config({ ...live(), slots: stripLegacySlots(curSlots) })
415
+ }
416
+ let blobCount = 0
417
+ for (const [ref, blob] of Object.entries(result.blobs)) {
418
+ await store.saveBlob(ref, blob)
419
+ blobCount++
420
+ }
421
+ if (parsed) {
422
+ await settings.replace(parsed)
424
423
  syncCustomVendors()
425
424
  }
426
425
  await syncAdapter()
@@ -285,6 +285,10 @@ export function registerStatusRoutes(ctx, state) {
285
285
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
286
286
  return
287
287
  }
288
+ if (!isTrustedSettingsRequest(req)) {
289
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
290
+ return
291
+ }
288
292
  let payload = {}
289
293
  try { payload = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch (err) { /* ignore status inspection error */ }
290
294
 
package/lib/vault.js CHANGED
@@ -1,7 +1,8 @@
1
- // #353: Encrypted Vault Export/Import for Subscriptions
1
+ // #353 & #360: Encrypted Vault Export/Import for Subscriptions
2
2
  // Secure migration and backup of slots and credentials using AES-256-GCM.
3
3
 
4
4
  import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
5
+ import { isProvider, oauthRef } from './refs.js'
5
6
 
6
7
  export const VAULT_FORMAT = 'dsh-subscriptions-vault-v1'
7
8
 
@@ -16,7 +17,7 @@ export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscrip
16
17
 
17
18
  const payload = {
18
19
  format: VAULT_FORMAT,
19
- version: '0.6.17',
20
+ version: '0.6.18',
20
21
  exportedAt: new Date().toISOString(),
21
22
  exportedBy,
22
23
  slots: Array.isArray(slots) ? slots : [],
@@ -33,7 +34,7 @@ export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscrip
33
34
  }
34
35
 
35
36
  /**
36
- * Decrypts and validates an encrypted vault string.
37
+ * Decrypts and validates an encrypted vault string with strict schema and injection protection.
37
38
  */
38
39
  export function importVault(encryptedPayload, passphrase) {
39
40
  const pass = String(passphrase || '').trim()
@@ -60,10 +61,27 @@ export function importVault(encryptedPayload, passphrase) {
60
61
  throw new Error('malformed vault content: invalid JSON')
61
62
  }
62
63
 
63
- if (!data || data.format !== VAULT_FORMAT || !Array.isArray(data.slots) || typeof data.blobs !== 'object') {
64
+ if (!data || data.format !== VAULT_FORMAT || !Array.isArray(data.slots)
65
+ || !data.blobs || typeof data.blobs !== 'object' || Array.isArray(data.blobs)) {
64
66
  throw new Error('invalid vault schema: unsupported structure')
65
67
  }
66
68
 
69
+ const refs = new Set()
70
+ for (const slot of data.slots) {
71
+ if (!slot || !isProvider(slot.provider) || !Number.isSafeInteger(slot.index) || slot.index < 1) {
72
+ throw new Error('invalid vault slot')
73
+ }
74
+ const ref = oauthRef(slot.provider, slot.index)
75
+ if (refs.has(ref)) throw new Error('duplicate vault slot')
76
+ refs.add(ref)
77
+ }
78
+
79
+ for (const [ref, blob] of Object.entries(data.blobs)) {
80
+ if (!refs.has(ref) || !blob || typeof blob !== 'object' || Array.isArray(blob)) {
81
+ throw new Error('invalid vault credential: expected a declared slot and an object')
82
+ }
83
+ }
84
+
67
85
  return {
68
86
  ok: true,
69
87
  slots: data.slots,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.17",
3
+ "version": "0.6.19",
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",