@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 +2 -1
- package/lib/adapter.js +14 -2
- package/lib/alerts.js +147 -0
- package/lib/cascade.js +51 -0
- package/lib/client.js +32 -0
- package/lib/config-schema.js +10 -0
- package/lib/forecast.js +42 -0
- package/lib/http.js +29 -1
- package/lib/index.js +56 -10
- package/lib/quarantine.js +59 -2
- package/lib/reset-credits.js +9 -0
- package/lib/rotate.js +5 -0
- package/lib/routes/accounts.js +99 -0
- package/lib/routes/status.js +21 -2
- package/lib/session-pin.js +11 -0
- package/lib/stream-rotate.js +6 -0
- package/lib/vault.js +74 -0
- package/lib/vendors/copilot.js +79 -0
- package/package.json +1 -1
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
|
-
|
|
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' },
|
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/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
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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/reset-credits.js
CHANGED
|
@@ -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)
|
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,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
|
}
|
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'
|
|
@@ -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
|
|
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
|
|
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
|
}
|
package/lib/session-pin.js
CHANGED
|
@@ -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
|
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,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
|
+
}
|
package/lib/vendors/copilot.js
CHANGED
|
@@ -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.
|
|
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",
|