@goodandready/dsh-subscriptions 0.6.17 → 0.6.18
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 +9 -5
- package/lib/client.js +29 -9
- package/lib/index.js +10 -4
- package/lib/routes/accounts.js +13 -14
- package/lib/vault.js +22 -4
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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' } }, '
|
|
1236
|
-
React.createElement('th', { style: { padding: '6px' } }, '
|
|
1237
|
-
React.createElement('th', { style: { padding: '6px' } }, '
|
|
1238
|
-
React.createElement('th', { style: { padding: '6px' } }, '
|
|
1239
|
-
React.createElement('th', { style: { padding: '6px' } }, '
|
|
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 ? '⛔
|
|
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
|
-
|
|
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)
|
package/lib/routes/accounts.js
CHANGED
|
@@ -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
|
-
|
|
402
|
-
let
|
|
403
|
-
if (result.
|
|
404
|
-
|
|
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
|
-
|
|
423
|
-
|
|
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()
|
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.
|
|
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)
|
|
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.
|
|
3
|
+
"version": "0.6.18",
|
|
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",
|