@goodandready/dsh-subscriptions 0.6.16 → 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 +14 -1
- package/lib/alerts.js +147 -0
- package/lib/cascade.js +51 -0
- package/lib/client.js +55 -3
- package/lib/config-schema.js +10 -0
- package/lib/forecast.js +42 -0
- package/lib/index.js +43 -1
- package/lib/quarantine.js +59 -2
- package/lib/rotate.js +5 -0
- package/lib/routes/accounts.js +98 -0
- package/lib/routes/status.js +16 -0
- package/lib/stream-rotate.js +6 -0
- package/lib/vault.js +92 -0
- 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,6 +194,18 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
193
194
|
}
|
|
194
195
|
return
|
|
195
196
|
} catch (err) {
|
|
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') {
|
|
199
|
+
try {
|
|
200
|
+
for await (const event of deps.cascadingFallback({ options, provider, err, visited })) {
|
|
201
|
+
yieldedAny = true
|
|
202
|
+
yield event
|
|
203
|
+
}
|
|
204
|
+
return
|
|
205
|
+
} catch (cascadeErr) {
|
|
206
|
+
err = cascadeErr
|
|
207
|
+
}
|
|
208
|
+
}
|
|
196
209
|
// #91: when the whole pool is exhausted and nothing has been streamed
|
|
197
210
|
// yet, continue seamlessly on local Ollama instead of failing the chat.
|
|
198
211
|
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
|
@@ -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) {
|
|
@@ -1226,6 +1246,38 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1226
1246
|
React.createElement('div', { className: 'dsub-stat-lbl' }, t('statsLastActivity')),
|
|
1227
1247
|
),
|
|
1228
1248
|
) : null,
|
|
1249
|
+
accounts && accounts.length ? React.createElement('details', { className: 'dsub-group', open: true, style: { marginBottom: '14px' } },
|
|
1250
|
+
React.createElement('summary', { className: 'dsub-groupHead' }, '📊 ' + (t('healthMatrix') || 'Health Matrix & Quota Runway')),
|
|
1251
|
+
React.createElement('div', { style: { padding: '8px 0', overflowX: 'auto' } },
|
|
1252
|
+
React.createElement('table', { style: { width: '100%', fontSize: '12px', borderCollapse: 'collapse' } },
|
|
1253
|
+
React.createElement('thead', null,
|
|
1254
|
+
React.createElement('tr', { style: { borderBottom: '1px solid var(--dsw-alias-border-primary, rgba(255,255,255,0.1))', textAlign: 'left', opacity: 0.7 } },
|
|
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')),
|
|
1260
|
+
),
|
|
1261
|
+
),
|
|
1262
|
+
React.createElement('tbody', null,
|
|
1263
|
+
accounts.map((acc, idx) => {
|
|
1264
|
+
const sc = acc.healthScore != null ? acc.healthScore : 100
|
|
1265
|
+
const healthColor = sc >= 80 ? '#10b981' : (sc >= 50 ? '#f59e0b' : '#ef4444')
|
|
1266
|
+
const isQ = acc.status === 'quarantine' || (acc.quarantineUntil && Number(acc.quarantineUntil) > Date.now())
|
|
1267
|
+
const statusLabel = isQ ? ('⛔ ' + t('healthQuarantine')) : (acc.status === 'probing' ? ('🟡 ' + t('healthWarmup')) : (acc.configured ? ('🟢 ' + t('healthActive')) : ('⚪ ' + t('healthIdle'))))
|
|
1268
|
+
const pct = acc.usagePercent != null ? Math.round(acc.usagePercent) : null
|
|
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))' } },
|
|
1270
|
+
React.createElement('td', { style: { padding: '6px', fontWeight: 600 } }, (acc.provider ? acc.provider.toUpperCase() : 'LLM') + ' #' + (acc.index || 1) + (acc.label ? ' (' + acc.label + ')' : '')),
|
|
1271
|
+
React.createElement('td', { style: { padding: '6px', color: healthColor } }, sc + '%'),
|
|
1272
|
+
React.createElement('td', { style: { padding: '6px' } }, acc.latencyMs ? acc.latencyMs + ' ms' : '—'),
|
|
1273
|
+
React.createElement('td', { style: { padding: '6px' } }, statusLabel),
|
|
1274
|
+
React.createElement('td', { style: { padding: '6px' } }, pct != null ? (pct + '% used') : '—'),
|
|
1275
|
+
)
|
|
1276
|
+
}),
|
|
1277
|
+
),
|
|
1278
|
+
),
|
|
1279
|
+
),
|
|
1280
|
+
) : null,
|
|
1229
1281
|
React.createElement('div', { className: 'dsub-block' },
|
|
1230
1282
|
React.createElement('div', { className: 'dsub-h' }, t('title')),
|
|
1231
1283
|
React.createElement('div', { className: 'dsub-sub' },
|
|
@@ -1847,7 +1899,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1847
1899
|
name: 'plugins.item',
|
|
1848
1900
|
id: ROW_ID,
|
|
1849
1901
|
order: 60,
|
|
1850
|
-
label: () => 'Subscriptions',
|
|
1902
|
+
label: () => t('title') || 'Subscriptions',
|
|
1851
1903
|
locale: NS,
|
|
1852
1904
|
inject: () => ({ ctx: ctx }),
|
|
1853
1905
|
},
|
package/lib/config-schema.js
CHANGED
|
@@ -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/index.js
CHANGED
|
@@ -35,6 +35,16 @@ import {
|
|
|
35
35
|
antigravityIdentityHeaders,
|
|
36
36
|
} from './code-assist.js'
|
|
37
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'
|
|
38
48
|
export const name = '@goodandready/dsh-subscriptions'
|
|
39
49
|
export const inject = ['llm', 'credentials', 'webServer', 'settings']
|
|
40
50
|
|
|
@@ -188,6 +198,7 @@ export function apply(ctx, config) {
|
|
|
188
198
|
fetchImpl: fetch,
|
|
189
199
|
fetchForRef,
|
|
190
200
|
ollamaFallback: ollamaFallbackStream,
|
|
201
|
+
cascadingFallback: cascadeFallbackStream,
|
|
191
202
|
hideDeprecatedModels: () => !!live().hideDeprecatedModels,
|
|
192
203
|
})
|
|
193
204
|
|
|
@@ -232,6 +243,33 @@ export function apply(ctx, config) {
|
|
|
232
243
|
},
|
|
233
244
|
}), 'dsh-subscriptions: image generation service')
|
|
234
245
|
const pending = new Map()
|
|
246
|
+
async function* cascadeFallbackStream({ options, provider, err, visited = new Set() }) {
|
|
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
|
+
visited.add(provider)
|
|
252
|
+
const nextVendor = resolveFallbackVendor(provider, available, cfg.cascadingChain, visited)
|
|
253
|
+
if (!nextVendor) throw err
|
|
254
|
+
visited.add(nextVendor)
|
|
255
|
+
const targetModel = mapFallbackModel(provider, options && options.model, nextVendor)
|
|
256
|
+
if (logger && logger.warn) {
|
|
257
|
+
logger.warn('[dsh-subscriptions] ' + provider + ': all accounts exhausted, cascading fallback to ' + nextVendor + '/' + targetModel)
|
|
258
|
+
}
|
|
259
|
+
yield* adapter.stream({ ...options, provider: nextVendor, model: targetModel }, visited)
|
|
260
|
+
try {
|
|
261
|
+
recordHistory({
|
|
262
|
+
provider: nextVendor,
|
|
263
|
+
ref: String(nextVendor).toUpperCase() + '_CASCADE_FALLBACK',
|
|
264
|
+
model: targetModel,
|
|
265
|
+
path: '/v1/chat/completions',
|
|
266
|
+
method: 'POST',
|
|
267
|
+
status: 200,
|
|
268
|
+
kind: 'cascade',
|
|
269
|
+
})
|
|
270
|
+
} catch (e) { /* ignore cascade history recording error */ }
|
|
271
|
+
}
|
|
272
|
+
|
|
235
273
|
const adapter = new SubscriptionAdapter({
|
|
236
274
|
listAccounts: (provider) => store.listAccounts(provider),
|
|
237
275
|
loadBlob: (ref) => store.loadBlob(ref),
|
|
@@ -251,6 +289,7 @@ export function apply(ctx, config) {
|
|
|
251
289
|
})
|
|
252
290
|
|
|
253
291
|
let handle
|
|
292
|
+
let adapterDisposed = false
|
|
254
293
|
let registeredByUs = new Set()
|
|
255
294
|
function disposeAdapterHandle(h) {
|
|
256
295
|
if (!h) return
|
|
@@ -263,6 +302,7 @@ export function apply(ctx, config) {
|
|
|
263
302
|
}
|
|
264
303
|
|
|
265
304
|
async function syncAdapter() {
|
|
305
|
+
if (adapterDisposed) return
|
|
266
306
|
const desired = await store.loggedInProviders()
|
|
267
307
|
const globalProviders = ctx.llm && typeof ctx.llm.listProviders === 'function'
|
|
268
308
|
? ctx.llm.listProviders().map((p) => p.id)
|
|
@@ -603,11 +643,12 @@ export function apply(ctx, config) {
|
|
|
603
643
|
let retries = 0
|
|
604
644
|
let retryTimer
|
|
605
645
|
const scheduleRetry = () => {
|
|
606
|
-
if (retries >= 30) return
|
|
646
|
+
if (adapterDisposed || retries >= 30) return
|
|
607
647
|
retryTimer = setTimeout(retrySync, 1000)
|
|
608
648
|
if (retryTimer && typeof retryTimer.unref === 'function') retryTimer.unref()
|
|
609
649
|
}
|
|
610
650
|
const retrySync = async () => {
|
|
651
|
+
if (adapterDisposed) return
|
|
611
652
|
try {
|
|
612
653
|
const loggedIn = await store.loggedInProviders()
|
|
613
654
|
if (!loggedIn.length) return
|
|
@@ -646,6 +687,7 @@ export function apply(ctx, config) {
|
|
|
646
687
|
}
|
|
647
688
|
eager()
|
|
648
689
|
return () => {
|
|
690
|
+
adapterDisposed = true
|
|
649
691
|
if (retryTimer) clearTimeout(retryTimer)
|
|
650
692
|
if (handle) {
|
|
651
693
|
disposeAdapterHandle(handle)
|
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:
|
|
28
|
+
status: STATUS_QUARANTINE,
|
|
24
29
|
}
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export function isQuarantined(account, nowMs = Date.now()) {
|
|
28
|
-
if (!account
|
|
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
|
+
}
|
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)
|
package/lib/routes/accounts.js
CHANGED
|
@@ -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,101 @@ 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
|
+
const settings = getSettingsApi()
|
|
402
|
+
let parsed
|
|
403
|
+
if (result.slots.length) {
|
|
404
|
+
if (!settings) throw new Error('settings are unavailable for vault import')
|
|
405
|
+
const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
|
|
406
|
+
for (const s of result.slots) {
|
|
407
|
+
const idx = curSlots.findIndex((c) => c.provider === s.provider && Number(c.index) === Number(s.index))
|
|
408
|
+
if (idx >= 0) {
|
|
409
|
+
curSlots[idx] = { ...curSlots[idx], ...s }
|
|
410
|
+
} else {
|
|
411
|
+
curSlots.push(s)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
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)
|
|
423
|
+
syncCustomVendors()
|
|
424
|
+
}
|
|
425
|
+
await syncAdapter()
|
|
426
|
+
refreshModels().catch(() => {})
|
|
427
|
+
writeJson(res, 200, {
|
|
428
|
+
ok: true,
|
|
429
|
+
slotCount: result.slots.length,
|
|
430
|
+
blobCount,
|
|
431
|
+
exportedAt: result.exportedAt,
|
|
432
|
+
accounts: await accountsView(),
|
|
433
|
+
})
|
|
434
|
+
} catch (e) {
|
|
435
|
+
writeJson(res, 400, { ok: false, error: { code: 'vault_import', message: String(e && e.message || e) } })
|
|
436
|
+
}
|
|
437
|
+
}),
|
|
438
|
+
}), 'dsh-subscriptions: /vault/import')
|
|
439
|
+
|
|
342
440
|
}
|
package/lib/routes/status.js
CHANGED
|
@@ -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'
|
|
@@ -382,4 +383,19 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
382
383
|
})
|
|
383
384
|
}),
|
|
384
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
|
+
|
|
385
401
|
}
|
package/lib/stream-rotate.js
CHANGED
|
@@ -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,92 @@
|
|
|
1
|
+
// #353 & #360: 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
|
+
import { isProvider, oauthRef } from './refs.js'
|
|
6
|
+
|
|
7
|
+
export const VAULT_FORMAT = 'dsh-subscriptions-vault-v1'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Encrypts slots configuration and credential blobs into a portable vault string.
|
|
11
|
+
*/
|
|
12
|
+
export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscriptions' }, passphrase) {
|
|
13
|
+
const pass = String(passphrase || '').trim()
|
|
14
|
+
if (!pass || pass.length < 4) {
|
|
15
|
+
throw new Error('passphrase must be at least 4 characters')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const payload = {
|
|
19
|
+
format: VAULT_FORMAT,
|
|
20
|
+
version: '0.6.18',
|
|
21
|
+
exportedAt: new Date().toISOString(),
|
|
22
|
+
exportedBy,
|
|
23
|
+
slots: Array.isArray(slots) ? slots : [],
|
|
24
|
+
blobs: blobs && typeof blobs === 'object' ? blobs : {},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const encrypted = encryptWithPassphrase(JSON.stringify(payload), pass)
|
|
28
|
+
return {
|
|
29
|
+
ok: true,
|
|
30
|
+
vault: encrypted,
|
|
31
|
+
slotCount: payload.slots.length,
|
|
32
|
+
blobCount: Object.keys(payload.blobs).length,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decrypts and validates an encrypted vault string with strict schema and injection protection.
|
|
38
|
+
*/
|
|
39
|
+
export function importVault(encryptedPayload, passphrase) {
|
|
40
|
+
const pass = String(passphrase || '').trim()
|
|
41
|
+
if (!pass) {
|
|
42
|
+
throw new Error('passphrase is required')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const raw = String(encryptedPayload || '').trim()
|
|
46
|
+
if (!raw.startsWith('DSHE1:')) {
|
|
47
|
+
throw new Error('invalid vault format: missing DSHE1 header')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let jsonStr
|
|
51
|
+
try {
|
|
52
|
+
jsonStr = decryptWithPassphrase(raw, pass)
|
|
53
|
+
} catch (err) {
|
|
54
|
+
throw new Error('failed to decrypt vault: invalid passphrase or corrupted payload')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let data
|
|
58
|
+
try {
|
|
59
|
+
data = JSON.parse(jsonStr)
|
|
60
|
+
} catch (err) {
|
|
61
|
+
throw new Error('malformed vault content: invalid JSON')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!data || data.format !== VAULT_FORMAT || !Array.isArray(data.slots)
|
|
65
|
+
|| !data.blobs || typeof data.blobs !== 'object' || Array.isArray(data.blobs)) {
|
|
66
|
+
throw new Error('invalid vault schema: unsupported structure')
|
|
67
|
+
}
|
|
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
|
+
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
slots: data.slots,
|
|
88
|
+
blobs: data.blobs,
|
|
89
|
+
version: data.version,
|
|
90
|
+
exportedAt: data.exportedAt,
|
|
91
|
+
}
|
|
92
|
+
}
|
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",
|