@goodandready/dsh-subscriptions 0.6.69 → 0.6.70
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/README.md +7 -0
- package/README.ru.md +7 -0
- package/README.zh.md +7 -0
- package/lib/adapter-manager.js +1 -1
- package/lib/cascade.js +27 -2
- package/lib/client.js +143 -4
- package/lib/config-schema.js +2 -0
- package/lib/diagnostics.js +6 -1
- package/lib/error-buffer.js +49 -0
- package/lib/index.js +36 -27
- package/lib/routes/status.js +43 -6
- package/lib/stream-rotate.js +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -207,6 +207,13 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
207
207
|
* **In-App Toast Alerts (`enableToasts`)**: Lightweight floating toast alerts in the DSH chat interface notifying you when accounts enter quarantine, recover from cooldowns, or exceed quota safety thresholds.
|
|
208
208
|
* **Automated Webhook Integration**: Instant event notifications dispatched via `notifyQuarantineEntered` and `notifyQuarantineReleased`.
|
|
209
209
|
|
|
210
|
+
### 24. 🛡️ Resilience, Diagnostics & Fallback Enhancements (v0.6.70)
|
|
211
|
+
* **Cross-Vendor Cascading Fallback & Provider Priority Ordering (#536)**: Configure fallback ordering across subscription providers with interactive reordering in WebUI.
|
|
212
|
+
* **1-Click Connection Smoke Test & Latency Ping (#536)**: Test individual account connections on the spot with real-time latency badges.
|
|
213
|
+
* **Live Cooldown Countdown & Availability Toasts (#536)**: Exact countdown timer for cooled-down accounts and automated toast alerts upon recovery.
|
|
214
|
+
* **Proactive OAuth Token Heartbeat (#536)**: Pre-emptive background token refresh before expiry to prevent first-query latency spikes.
|
|
215
|
+
* **Recent Errors Ring Buffer Inspector (#536)**: In-memory sanitized log of the last 20 runtime failures in WebUI Diagnostics.
|
|
216
|
+
|
|
210
217
|
### 23. 🧠 Antigravity Tiered Models, Tool Sentinel & Stream Resilience (v0.6.69)
|
|
211
218
|
* **Gemini 3 Tool Replay Sentinel (#534)**: Injects `thoughtSignature: 'skip_thought_signature_validator'` on reconstructed tool calls, enabling reliable multi-turn tool calling without Gemini 3 validation failures.
|
|
212
219
|
* **Tiered Flash Models & Thinking Levels (#534)**: Exposes Gemini 3.8 and 3.7 Flash High, Medium, and Low thinking levels with upstream `*-flash-tiered` mapping.
|
package/README.ru.md
CHANGED
|
@@ -183,6 +183,13 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
183
183
|
* **Всплывающие уведомления (Toast Alerts)**: Легковесные всплывающие тосты в веб-интерфейсе DSH при событиях карантина (с отображением причины), восстановлении доступа и критическом расходе квоты. Включение управляется параметром `enableToasts` (по умолчанию `true`).
|
|
184
184
|
* **Интеграция с вебхуками**: Автоматическая отправка уведомлений через `notifyQuarantineEntered` и `notifyQuarantineReleased`.
|
|
185
185
|
|
|
186
|
+
### 20. 🛡️ Отказоустойчивость, диагностика и порядок фолбэка (v0.6.70)
|
|
187
|
+
* **Межвендорный каскадный фолбэк с настройкой очередности (#536)**: Настройка порядка переключения провайдеров при исчерпании квот с интерактивным изменением приоритета (▲/▼) в WebUI.
|
|
188
|
+
* **1-Click проверка подключения и пинг задержки (#536)**: Проверка конкретного аккаунта в один клик с отображением задержки (`✓ 240ms`).
|
|
189
|
+
* **Живой таймер кулдауна и уведомления о восстановлении (#536)**: Точный обратный отсчет кулдауна и всплывающие in-app тосты при возвращении аккаунта в строй.
|
|
190
|
+
* **Превентивный OAuth Heartbeat (#536)**: Упреждающее фоновое обновление токенов до истечения срока действия.
|
|
191
|
+
* **Журнал последних ошибок (Ring Buffer) (#536)**: Встроенный инспектор последних 20 ошибок в WebUI Diagnostics с кнопкой очистки.
|
|
192
|
+
|
|
186
193
|
### 19. 🧠 Antigravity Tiered-модели, сентинел тулов и устойчивость стримов (v0.6.69)
|
|
187
194
|
* **Сентинел реплея тулов Gemini 3 (#534)**: Автоматическая вставка `thoughtSignature: 'skip_thought_signature_validator'` для реплея истории вызовов тулов без сбоев валидации Google API.
|
|
188
195
|
* **Tiered-модели и уровни мышления (#534)**: Выбор уровней High/Medium/Low для Gemini 3.8 и 3.7 Flash с маппингом в upstream `*-flash-tiered` и `thinkingLevel`.
|
package/README.zh.md
CHANGED
|
@@ -132,6 +132,13 @@ graph LR
|
|
|
132
132
|
queueTimeoutMs: 30000 # v0.6.62: 队列最大等待超时时间(毫秒)
|
|
133
133
|
* **自动化 Webhook 告警集成**:通过 `notifyQuarantineEntered` 与 `notifyQuarantineReleased` 自动向外部通知系统派发隔离生命周期事件。
|
|
134
134
|
|
|
135
|
+
### 🛡️ 弹性容灾、故障诊断与跨服务商回退 (v0.6.70)
|
|
136
|
+
* **跨服务商级联故障转移与优先级排序 (#536)**: 支持自定义跨服务商回退顺序,可在 WebUI 中通过 ▲/▼ 交互调整优先级。
|
|
137
|
+
* **一键链路测试与延迟检测 (#536)**: 在账户列表或状态弹窗中一键发起探活并显示往返延迟。
|
|
138
|
+
* **冷却倒计时与就绪通知 (#536)**: 实时展示受限账户恢复倒计时,解除冷却时自动弹出就绪通知。
|
|
139
|
+
* **主动 OAuth 令牌心跳 (#536)**: 提前预刷即将过期的访问令牌,避免用户请求时产生额外等待。
|
|
140
|
+
* **最近错误环形日志审查 (#536)**: 内存保存最近 20 条已脱敏的运行错误,支持在 Diagnostics 中查看与清空。
|
|
141
|
+
|
|
135
142
|
### Antigravity 分档思考模型、工具回放哨兵与流式自愈 (v0.6.69)
|
|
136
143
|
* **Gemini 3 工具回放哨兵 (#534)**:在重建的工具调用中注入 `thoughtSignature: 'skip_thought_signature_validator'`,彻底解决多轮工具调用历史重放时 Gemini 3 的校验失败问题。
|
|
137
144
|
* **分档 Flash 模型与思考档位 (#534)**:将 `gemini-3.8-flash-tiered` 与 `3.7` 自动解析展开为 High/Medium/Low 档位选择,并在调用时自动组装 `thinkingConfig.thinkingLevel`。
|
package/lib/adapter-manager.js
CHANGED
|
@@ -75,7 +75,7 @@ export function createAdapterManager({
|
|
|
75
75
|
const loggedList = Array.isArray(logged) ? logged : Object.keys(logged || {}).filter((p) => logged[p])
|
|
76
76
|
const available = loggedList.filter((p) => p !== provider)
|
|
77
77
|
visited.add(provider)
|
|
78
|
-
const nextVendor = resolveFallbackVendor(provider, available, cfg.cascadingChain, visited)
|
|
78
|
+
const nextVendor = resolveFallbackVendor(provider, available, cfg.cascadingChain, visited, cfg.cascadingFallbackOrder)
|
|
79
79
|
if (!nextVendor) throw err
|
|
80
80
|
visited.add(nextVendor)
|
|
81
81
|
const targetModel = mapFallbackModel(provider, options && options.model, nextVendor)
|
package/lib/cascade.js
CHANGED
|
@@ -29,10 +29,35 @@ export const DEFAULT_CASCADE_CHAINS = {
|
|
|
29
29
|
* @param {Set<string>} [visited] - set of already attempted providers in this request cascade
|
|
30
30
|
* @returns {string|null} - the next candidate provider or null if none available
|
|
31
31
|
*/
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Resolves the next candidate fallback provider from the cascade chain.
|
|
34
|
+
* @param {string} currentProvider - currently failing provider
|
|
35
|
+
* @param {Set<string>|Array<string>} availableProviders - providers that currently have logged-in accounts
|
|
36
|
+
* @param {Record<string, string[]>} [customChains] - user-configured custom chains
|
|
37
|
+
* @param {Set<string>} [visited] - set of already attempted providers in this request cascade
|
|
38
|
+
* @param {string[]} [globalOrder] - configured global fallback priority sequence
|
|
39
|
+
* @returns {string|null} - the next candidate provider or null if none available
|
|
40
|
+
*/
|
|
41
|
+
export function resolveFallbackVendor(currentProvider, availableProviders, customChains = {}, visited = new Set(), globalOrder = []) {
|
|
33
42
|
const availSet = new Set(availableProviders || [])
|
|
34
|
-
const
|
|
43
|
+
const custom = customChains && customChains[currentProvider]
|
|
44
|
+
if (Array.isArray(custom) && custom.length > 0) {
|
|
45
|
+
for (const candidate of custom) {
|
|
46
|
+
if (candidate !== currentProvider && !visited.has(candidate) && availSet.has(candidate)) {
|
|
47
|
+
return candidate
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (Array.isArray(globalOrder) && globalOrder.length > 0) {
|
|
53
|
+
for (const candidate of globalOrder) {
|
|
54
|
+
if (candidate !== currentProvider && !visited.has(candidate) && availSet.has(candidate)) {
|
|
55
|
+
return candidate
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
35
59
|
|
|
60
|
+
const chain = DEFAULT_CASCADE_CHAINS[currentProvider] || []
|
|
36
61
|
for (const candidate of chain) {
|
|
37
62
|
if (candidate !== currentProvider && !visited.has(candidate) && availSet.has(candidate)) {
|
|
38
63
|
return candidate
|
package/lib/client.js
CHANGED
|
@@ -430,6 +430,17 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
430
430
|
"smokeTesting": "Pinging…",
|
|
431
431
|
"smokeOk": "Smoke test passed",
|
|
432
432
|
"smokeFail": "Smoke test failed",
|
|
433
|
+
"testConnection": "Test",
|
|
434
|
+
"testConnectionSuccess": "Connection verified",
|
|
435
|
+
"cascadingFallback": "Cross-vendor fallback",
|
|
436
|
+
"cascadingFallbackHint": "Automatically route queries to alternative providers when all accounts of a primary provider are exhausted.",
|
|
437
|
+
"cascadingFallbackOrder": "Fallback Provider Order",
|
|
438
|
+
"cascadingFallbackOrderHint": "Sequence of alternative providers to try when primary accounts are exhausted.",
|
|
439
|
+
"recentErrors": "Recent Runtime Errors",
|
|
440
|
+
"recentErrorsDesc": "Last 20 runtime failures across model streams and providers (sanitized).",
|
|
441
|
+
"recentErrorsClear": "Clear Log",
|
|
442
|
+
"recentErrorsEmpty": "No recent errors recorded",
|
|
443
|
+
"toastCooldownRecovered": "is now available (cooldown expired)",
|
|
433
444
|
"modelsCatalogTitle": "Curated Subscription Models",
|
|
434
445
|
"modelsCatalogDesc": "Available model catalog across active subscriptions in DeepSeek Harness.",
|
|
435
446
|
"rotationDecisionsTitle": "Rotation & Routing Decisions",
|
|
@@ -702,6 +713,17 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
702
713
|
"smokeTesting": "正在探活…",
|
|
703
714
|
"smokeOk": "冒烟测试成功",
|
|
704
715
|
"smokeFail": "冒烟测试失败",
|
|
716
|
+
"testConnection": "测试",
|
|
717
|
+
"testConnectionSuccess": "连接已验证",
|
|
718
|
+
"cascadingFallback": "跨服务商自动故障转移",
|
|
719
|
+
"cascadingFallbackHint": "当首选服务商所有账户配额耗尽或限流时,自动无缝转接至备选服务商。",
|
|
720
|
+
"cascadingFallbackOrder": "故障转移供应商顺序",
|
|
721
|
+
"cascadingFallbackOrderHint": "首选服务商耗尽时按此顺序依次尝试备选服务商。",
|
|
722
|
+
"recentErrors": "最近运行错误记录",
|
|
723
|
+
"recentErrorsDesc": "最近 20 条流式传输与 API 调用失败记录(已脱敏)。",
|
|
724
|
+
"recentErrorsClear": "清空记录",
|
|
725
|
+
"recentErrorsEmpty": "暂无错误记录",
|
|
726
|
+
"toastCooldownRecovered": "已解除冷却恢复可用",
|
|
705
727
|
"modelsCatalogTitle": "精选订阅模型",
|
|
706
728
|
"modelsCatalogDesc": "当前 DeepSeek Harness 活跃订阅下可用模型一览。",
|
|
707
729
|
"rotationDecisionsTitle": "轮询调度与路由决策",
|
|
@@ -987,7 +1009,13 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
987
1009
|
if (account.quarantineReason && account.quarantineUntil && account.quarantineUntil > Date.now()) {
|
|
988
1010
|
return account.quarantineReason === 'VERIFY' ? t('verifyAccount') : String(account.quarantineReason)
|
|
989
1011
|
}
|
|
990
|
-
if (account.cooldownUntil && account.cooldownUntil > Date.now())
|
|
1012
|
+
if (account.cooldownUntil && account.cooldownUntil > Date.now()) {
|
|
1013
|
+
const leftSec = Math.max(0, Math.ceil((account.cooldownUntil - Date.now()) / 1000))
|
|
1014
|
+
const m = Math.floor(leftSec / 60)
|
|
1015
|
+
const s = leftSec % 60
|
|
1016
|
+
const pad = (n) => String(n).padStart(2, "0")
|
|
1017
|
+
return t("coolingDown") + " (" + pad(m) + ":" + pad(s) + ")"
|
|
1018
|
+
}
|
|
991
1019
|
if (account.usagePercent != null && account.usagePercent >= 100) return t('usageFull')
|
|
992
1020
|
return t('connected')
|
|
993
1021
|
}
|
|
@@ -1114,12 +1142,14 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1114
1142
|
fetch('/dsh-subscriptions/diagnostics', { cache: 'no-store' })
|
|
1115
1143
|
.then((r) => r.json())
|
|
1116
1144
|
.then((d) => {
|
|
1145
|
+
if (d && d.ok && d.report) setDiagReport(d.report)
|
|
1117
1146
|
const txt = d && d.ok ? JSON.stringify(d.report, null, 2) : ('error: ' + ((d && d.error && d.error.message) || 'unknown'))
|
|
1118
1147
|
setDiag(txt)
|
|
1119
1148
|
try { navigator.clipboard.writeText(txt) } catch { /* clipboard write disallowed */ }
|
|
1120
1149
|
})
|
|
1121
1150
|
.catch((e) => setDiag('error: ' + String(e && e.message || e)))
|
|
1122
1151
|
}
|
|
1152
|
+
const [diagReport, setDiagReport] = React.useState(null)
|
|
1123
1153
|
const [checkRes, setCheckRes] = React.useState({})
|
|
1124
1154
|
const [hostPing, setHostPing] = React.useState({ ok: true, latencyMs: null })
|
|
1125
1155
|
const [telemetry, setTelemetry] = React.useState(null)
|
|
@@ -1259,6 +1289,10 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1259
1289
|
.then((r) => r.json())
|
|
1260
1290
|
.then((d) => { if (d && d.ok && d.queue) setQueueStats(d.queue) })
|
|
1261
1291
|
.catch(() => {})
|
|
1292
|
+
fetch('/dsh-subscriptions/diagnostics', { cache: 'no-store' })
|
|
1293
|
+
.then((r) => r.json())
|
|
1294
|
+
.then((d) => { if (d && d.ok && d.report) setDiagReport(d.report) })
|
|
1295
|
+
.catch(() => {})
|
|
1262
1296
|
}
|
|
1263
1297
|
|
|
1264
1298
|
const runSmokeTest = async (provider, index) => {
|
|
@@ -2081,6 +2115,48 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2081
2115
|
React.createElement('span', null, t('notifyLimits')),
|
|
2082
2116
|
),
|
|
2083
2117
|
React.createElement('div', { className: 'dsub-sub' }, t('notifyLimitsHint')),
|
|
2118
|
+
React.createElement('label', { className: 'dsub-row', style: { marginTop: 10 } },
|
|
2119
|
+
React.createElement('input', {
|
|
2120
|
+
type: 'checkbox',
|
|
2121
|
+
checked: Boolean(draft.cascadingFallback),
|
|
2122
|
+
onChange: (e) => setDraft((d) => Object.assign({}, d, { cascadingFallback: e.target.checked })),
|
|
2123
|
+
}),
|
|
2124
|
+
React.createElement('span', null, t('cascadingFallback')),
|
|
2125
|
+
),
|
|
2126
|
+
React.createElement('div', { className: 'dsub-sub' }, t('cascadingFallbackHint')),
|
|
2127
|
+
Boolean(draft.cascadingFallback) ? React.createElement('div', { style: { marginTop: 8, padding: '8px 12px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 8 } },
|
|
2128
|
+
React.createElement('div', { style: { fontSize: 12, fontWeight: 600, marginBottom: 4 } }, t('cascadingFallbackOrder')),
|
|
2129
|
+
React.createElement('div', { className: 'dsub-sub', style: { marginBottom: 8 } }, t('cascadingFallbackOrderHint')),
|
|
2130
|
+
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
2131
|
+
(Array.isArray(draft.cascadingFallbackOrder) && draft.cascadingFallbackOrder.length ? draft.cascadingFallbackOrder : (providers.length ? providers : ['antigravity', 'claude', 'codex', 'grok', 'copilot', 'cursor'])).map((p, idx, arr) => {
|
|
2132
|
+
return React.createElement('div', { key: p, style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 8px', background: 'var(--dsw-alias-bg-layer-1)', borderRadius: 6, fontSize: 12 } },
|
|
2133
|
+
React.createElement('span', null, (idx + 1) + '. ' + p.toUpperCase()),
|
|
2134
|
+
React.createElement('div', { style: { display: 'flex', gap: 4 } },
|
|
2135
|
+
idx > 0 ? React.createElement('button', {
|
|
2136
|
+
type: 'button', className: 'dsub-mini', style: { padding: '1px 6px', fontSize: 10 },
|
|
2137
|
+
onClick: () => {
|
|
2138
|
+
const next = arr.slice()
|
|
2139
|
+
const tmp = next[idx - 1]
|
|
2140
|
+
next[idx - 1] = next[idx]
|
|
2141
|
+
next[idx] = tmp
|
|
2142
|
+
setDraft((d) => Object.assign({}, d, { cascadingFallbackOrder: next }))
|
|
2143
|
+
}
|
|
2144
|
+
}, '▲') : null,
|
|
2145
|
+
idx < arr.length - 1 ? React.createElement('button', {
|
|
2146
|
+
type: 'button', className: 'dsub-mini', style: { padding: '1px 6px', fontSize: 10 },
|
|
2147
|
+
onClick: () => {
|
|
2148
|
+
const next = arr.slice()
|
|
2149
|
+
const tmp = next[idx + 1]
|
|
2150
|
+
next[idx + 1] = next[idx]
|
|
2151
|
+
next[idx] = tmp
|
|
2152
|
+
setDraft((d) => Object.assign({}, d, { cascadingFallbackOrder: next }))
|
|
2153
|
+
}
|
|
2154
|
+
}, '▼') : null,
|
|
2155
|
+
)
|
|
2156
|
+
)
|
|
2157
|
+
})
|
|
2158
|
+
)
|
|
2159
|
+
) : null,
|
|
2084
2160
|
),
|
|
2085
2161
|
),
|
|
2086
2162
|
),
|
|
@@ -2105,6 +2181,39 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2105
2181
|
}, t('diagCopy')),
|
|
2106
2182
|
React.createElement('pre', { className: 'dsub-diag' }, diag),
|
|
2107
2183
|
) : null,
|
|
2184
|
+
React.createElement('details', { className: 'dsub-details', style: { marginTop: 12 } },
|
|
2185
|
+
React.createElement('summary', { className: 'dsub-summary' }, '⚠️ ' + t('recentErrors')),
|
|
2186
|
+
React.createElement('div', { className: 'dsub-block' },
|
|
2187
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 } },
|
|
2188
|
+
React.createElement('span', { className: 'dsub-sub' }, t('recentErrorsDesc')),
|
|
2189
|
+
React.createElement('button', {
|
|
2190
|
+
type: 'button', className: 'dsub-mini',
|
|
2191
|
+
onClick: async () => {
|
|
2192
|
+
await fetch('/dsh-subscriptions/diagnostics/errors/clear', { method: 'POST' }).catch(() => {})
|
|
2193
|
+
genDiag()
|
|
2194
|
+
}
|
|
2195
|
+
}, t('recentErrorsClear'))
|
|
2196
|
+
),
|
|
2197
|
+
(diagReport && Array.isArray(diagReport.recentErrors) && diagReport.recentErrors.length ? diagReport.recentErrors : []).length === 0
|
|
2198
|
+
? React.createElement('div', { className: 'dsub-dim', style: { padding: 8 } }, t('recentErrorsEmpty'))
|
|
2199
|
+
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
|
|
2200
|
+
(diagReport.recentErrors || []).map((err, i) => React.createElement('div', {
|
|
2201
|
+
key: i,
|
|
2202
|
+
style: { padding: '6px 10px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 6, fontSize: 11.5, borderLeft: '3px solid var(--dsw-alias-state-error-primary)' }
|
|
2203
|
+
},
|
|
2204
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', marginBottom: 2 } },
|
|
2205
|
+
React.createElement('span', { style: { fontWeight: 600 } }, (err.provider || 'unknown').toUpperCase() + (err.model ? ' · ' + err.model : '')),
|
|
2206
|
+
React.createElement('span', { className: 'dsub-dim' }, (err.ts || '').slice(11, 19))
|
|
2207
|
+
),
|
|
2208
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
2209
|
+
err.code ? React.createElement('span', { className: 'dsub-pillTag', style: { background: 'color-mix(in srgb,var(--dsw-alias-state-error-primary) 15%,transparent)', color: 'var(--dsw-alias-state-error-primary)' } }, err.code) : null,
|
|
2210
|
+
err.status ? React.createElement('span', { className: 'dsub-dim' }, 'HTTP ' + err.status) : null,
|
|
2211
|
+
React.createElement('span', { style: { opacity: 0.9, wordBreak: 'break-word' } }, err.message)
|
|
2212
|
+
)
|
|
2213
|
+
))
|
|
2214
|
+
)
|
|
2215
|
+
)
|
|
2216
|
+
),
|
|
2108
2217
|
wizardModal ? React.createElement('div', { className: 'dsub-modalWrap', onClick: (e) => { if (e.target === e.currentTarget) setWizardModal(null) } },
|
|
2109
2218
|
React.createElement('div', { className: 'dsub-modal' },
|
|
2110
2219
|
React.createElement('div', { className: 'dsub-modalHead' },
|
|
@@ -2269,8 +2378,16 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2269
2378
|
React.createElement('button', {
|
|
2270
2379
|
type: 'button', className: 'dsub-mini',
|
|
2271
2380
|
disabled: checking[key],
|
|
2381
|
+
title: t('smokeDesc'),
|
|
2272
2382
|
onClick: () => doCheck(row.slot.provider, row.slot.index).catch((e) => setErr(cleanErrorMessage(e.message || e))),
|
|
2273
|
-
}, checking[key] ? t('checking') : t('
|
|
2383
|
+
}, checking[key] ? t('checking') : ('⚡ ' + t('testConnection'))),
|
|
2384
|
+
(function(){
|
|
2385
|
+
var r = checkRes[key];
|
|
2386
|
+
if (!r) return null;
|
|
2387
|
+
var cls = r.ok ? 'dsub-badge-ok' : 'dsub-badge-bad';
|
|
2388
|
+
var txt = r.ok ? ('✓ ' + (r.latencyMs != null ? r.latencyMs + 'ms' : 'OK')) : ('✗ ' + ((r.error && (r.error.code || r.error.message)) || 'FAIL'));
|
|
2389
|
+
return React.createElement('span', { className: 'dsub-badge ' + cls, style: { marginLeft: 4 }, title: r.ok ? t('testConnectionSuccess') : ((r.error && r.error.message) || 'fail') }, txt);
|
|
2390
|
+
})(),
|
|
2274
2391
|
React.createElement('button', {
|
|
2275
2392
|
type: 'button', className: 'dsub-mini',
|
|
2276
2393
|
title: t('wizardTitle'),
|
|
@@ -3141,6 +3258,24 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
3141
3258
|
)
|
|
3142
3259
|
}
|
|
3143
3260
|
|
|
3261
|
+
const prevCooldownsRef = React.useRef(new Map())
|
|
3262
|
+
React.useEffect(() => {
|
|
3263
|
+
for (const a of (state.accounts || [])) {
|
|
3264
|
+
const isCooled = Boolean(a.cooldownUntil && a.cooldownUntil > now)
|
|
3265
|
+
const wasCooled = prevCooldownsRef.current.get(a.ref)
|
|
3266
|
+
if (wasCooled && !isCooled) {
|
|
3267
|
+
const toastId = 't_cool_' + a.ref + '_' + Date.now()
|
|
3268
|
+
setToasts((prev) => [{
|
|
3269
|
+
id: toastId,
|
|
3270
|
+
message: (a.label || a.ref) + ' ' + t('toastCooldownRecovered'),
|
|
3271
|
+
cls: 'dsub-toast-ok',
|
|
3272
|
+
}, ...prev].slice(0, 3))
|
|
3273
|
+
setTimeout(() => dismissToast(toastId), 5000)
|
|
3274
|
+
}
|
|
3275
|
+
prevCooldownsRef.current.set(a.ref, isCooled)
|
|
3276
|
+
}
|
|
3277
|
+
}, [state.accounts, now])
|
|
3278
|
+
|
|
3144
3279
|
// Accounts list
|
|
3145
3280
|
const rows = state.accounts.map((a, i) => {
|
|
3146
3281
|
const isLogged = state.logged.includes(a.provider)
|
|
@@ -3157,8 +3292,12 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
3157
3292
|
statusText = a.quarantineReason === 'VERIFY' ? t('verifyAccount') : String(a.quarantineReason)
|
|
3158
3293
|
} else if (cooled) {
|
|
3159
3294
|
statusClass = 'dsub-statusWarn'
|
|
3160
|
-
|
|
3161
|
-
|
|
3295
|
+
const leftSec = Math.max(0, Math.ceil((a.cooldownUntil - now) / 1000))
|
|
3296
|
+
const m = Math.floor(leftSec / 60)
|
|
3297
|
+
const s = leftSec % 60
|
|
3298
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
3299
|
+
const fams = Array.isArray(a.cooldownFamilies) && a.cooldownFamilies.length ? ' · ' + a.cooldownFamilies.join('/') : ''
|
|
3300
|
+
statusText = t('subsCooldown') + ' (' + pad(m) + ':' + pad(s) + ')' + fams
|
|
3162
3301
|
} else if (isLogged) {
|
|
3163
3302
|
if (pct >= 90) { statusClass = 'dsub-statusBad'; statusText = '90%+ ' + t('subsUsed') }
|
|
3164
3303
|
else if (pct >= 50) { statusClass = 'dsub-statusWarn'; statusText = pct + '% ' + t('subsUsed') }
|
package/lib/config-schema.js
CHANGED
|
@@ -108,6 +108,8 @@ export const Config = z.object({
|
|
|
108
108
|
.description('Custom model catalog for Grok.'),
|
|
109
109
|
cascadingFallback: z.boolean().volatile().default(false)
|
|
110
110
|
.description('#349 Opt-in cross-vendor fallback when all accounts of a provider are exhausted or rate limited.'),
|
|
111
|
+
cascadingFallbackOrder: z.array(z.string()).volatile().default(PROVIDERS)
|
|
112
|
+
.description('Configurable global priority sequence for cross-vendor fallback.'),
|
|
111
113
|
cascadingChain: z.any().volatile().default({})
|
|
112
114
|
.description('#349 Custom cross-vendor fallback chains (e.g. { claude: ["copilot", "cursor"] }).'),
|
|
113
115
|
webhookUrl: z.string().volatile().default('')
|
package/lib/diagnostics.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getSessionPinStats } from './session-pin.js'
|
|
|
6
6
|
import { maskText } from './mask.js'
|
|
7
7
|
import { normalizeSlots } from './accounts.js'
|
|
8
8
|
import { PROVIDERS } from './refs.js'
|
|
9
|
+
import { defaultErrorBuffer } from './error-buffer.js'
|
|
9
10
|
|
|
10
11
|
function scrubReport(v) {
|
|
11
12
|
if (typeof v === 'string') return maskText(v)
|
|
@@ -18,7 +19,7 @@ function scrubReport(v) {
|
|
|
18
19
|
return v
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export async function createDiagnosticsReport({ live, store, history, NS, pkgVersion }) {
|
|
22
|
+
export async function createDiagnosticsReport({ live, store, history, NS, pkgVersion, heartbeatStats }) {
|
|
22
23
|
const cfg = live()
|
|
23
24
|
const slots = normalizeSlots(cfg.slots)
|
|
24
25
|
const mk = () => ({ loggedIn: false, slots: 0, configured: 0, cooldown: 0, proxy: 0, maxUsagePercent: null })
|
|
@@ -51,6 +52,8 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
|
|
|
51
52
|
runtime: { node: process.version, platform: process.platform, arch: process.arch, uptimeSec: Math.round(process.uptime()) },
|
|
52
53
|
providers,
|
|
53
54
|
requests: { total: rows.length, byStatus, lastErrors },
|
|
55
|
+
recentErrors: defaultErrorBuffer.getEntries(),
|
|
56
|
+
heartbeat: heartbeatStats || { lastRunAt: null, proactiveRefreshCount: 0 },
|
|
54
57
|
cache: calculateCacheMetrics(rows),
|
|
55
58
|
queue: defaultConcurrencyQueue.stats(),
|
|
56
59
|
pins: getSessionPinStats(),
|
|
@@ -78,6 +81,8 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
|
|
|
78
81
|
privacyMask: !!cfg.privacyMask,
|
|
79
82
|
customVendors: Array.isArray(cfg.customVendors) ? cfg.customVendors.length : 0,
|
|
80
83
|
proxySlots: slots.filter((s) => s.proxyUrl).length,
|
|
84
|
+
cascadingFallback: !!cfg.cascadingFallback,
|
|
85
|
+
cascadingFallbackOrder: cfg.cascadingFallbackOrder || PROVIDERS,
|
|
81
86
|
},
|
|
82
87
|
})
|
|
83
88
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { cleanErrorMessage, maskText } from './mask.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ring buffer holding the most recent runtime errors across vendors and streams.
|
|
5
|
+
* Sanitizes all text to prevent credential or PII leaks in diagnostics.
|
|
6
|
+
*/
|
|
7
|
+
export class ErrorRingBuffer {
|
|
8
|
+
/**
|
|
9
|
+
* @param {number} [limit=20]
|
|
10
|
+
*/
|
|
11
|
+
constructor(limit = 20) {
|
|
12
|
+
this.limit = limit
|
|
13
|
+
/** @type {Array<{ id: string, ts: string, provider: string, ref: string, model: string, status: number|null, code: string, message: string }>} */
|
|
14
|
+
this.entries = []
|
|
15
|
+
this._nextId = 1
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{ provider?: string, ref?: string, model?: string, status?: number|null, code?: string, message?: string }} [param0]
|
|
20
|
+
*/
|
|
21
|
+
record({ provider = 'unknown', ref = '', model = '', status = null, code = '', message = '' } = {}) {
|
|
22
|
+
const rawMsg = cleanErrorMessage(maskText(String(message || '')))
|
|
23
|
+
const entry = {
|
|
24
|
+
id: String(this._nextId++),
|
|
25
|
+
ts: new Date().toISOString(),
|
|
26
|
+
provider: String(provider || ''),
|
|
27
|
+
ref: String(ref || ''),
|
|
28
|
+
model: String(model || ''),
|
|
29
|
+
status: typeof status === 'number' ? status : null,
|
|
30
|
+
code: String(code || ''),
|
|
31
|
+
message: rawMsg.slice(0, 250),
|
|
32
|
+
}
|
|
33
|
+
this.entries.unshift(entry)
|
|
34
|
+
if (this.entries.length > this.limit) {
|
|
35
|
+
this.entries.length = this.limit
|
|
36
|
+
}
|
|
37
|
+
return entry
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getEntries() {
|
|
41
|
+
return this.entries.slice()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
clear() {
|
|
45
|
+
this.entries = []
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const defaultErrorBuffer = new ErrorRingBuffer(20)
|
package/lib/index.js
CHANGED
|
@@ -416,37 +416,45 @@ export function apply(ctx, rawConfig) {
|
|
|
416
416
|
const pmE = (s) => privacyOn() ? maskEmail(s) : String(s || '')
|
|
417
417
|
const pmL = (s) => privacyOn() ? maskLabel(s) : String(s || '')
|
|
418
418
|
|
|
419
|
-
const
|
|
419
|
+
const heartbeatStats = { lastRunAt: 0, proactiveRefreshCount: 0 }
|
|
420
|
+
const runProactiveHeartbeat = async () => {
|
|
421
|
+
heartbeatStats.lastRunAt = Date.now()
|
|
422
|
+
const cfg = live()
|
|
423
|
+
const ahead = Number(cfg.refreshAheadMs) || 5 * 60 * 1000
|
|
424
|
+
const retryMs = Number(cfg.refreshRetryMs) || 10 * 60 * 1000
|
|
425
|
+
const now = Date.now()
|
|
426
|
+
let refreshed = 0
|
|
427
|
+
for (const slot of normalizeSlots(cfg.slots)) {
|
|
428
|
+
const ref = slot.ref
|
|
429
|
+
try {
|
|
430
|
+
const info = await store.describeRef(ref)
|
|
431
|
+
if (!info.configured) continue
|
|
432
|
+
if (info.cooldownUntil && info.cooldownUntil > now) continue
|
|
433
|
+
const raw = await store.resolveRaw(ref)
|
|
434
|
+
if (!raw) continue
|
|
435
|
+
const blob = await store.loadBlob(ref).catch(() => null)
|
|
436
|
+
if (!blob || !blob.refreshToken) continue
|
|
437
|
+
if (!blob.expiresAt) continue
|
|
438
|
+
if (blob.expiresAt - now > ahead) continue
|
|
439
|
+
if (typeof store.shouldSkipRefresh === "function" && store.shouldSkipRefresh(ref, now, retryMs)) continue
|
|
440
|
+
await store.ensureFresh(slot.provider, blob, ref).then(() => {
|
|
441
|
+
refreshed++
|
|
442
|
+
heartbeatStats.proactiveRefreshCount++
|
|
443
|
+
}).catch((e) => {
|
|
444
|
+
if (logger && logger.warn) logger.warn("[dsh-subscriptions] background refresh failed for " + ref + ": " + String(e && e.message || e))
|
|
445
|
+
})
|
|
446
|
+
} catch { /* best-effort */ }
|
|
447
|
+
}
|
|
448
|
+
return refreshed
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const diagnosticsReport = () => createDiagnosticsReport({ live, store, history, NS, pkgVersion, heartbeatStats })
|
|
420
452
|
const accountsView = () => createAccountsView({ live, store, fetch, pmL })
|
|
421
453
|
|
|
422
454
|
// Ponytail: background refresh ahead of expiry, single timer
|
|
423
455
|
ctx.effect(() => {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const ahead = Number(cfg.refreshAheadMs) || 5 * 60 * 1000
|
|
427
|
-
const retryMs = Number(cfg.refreshRetryMs) || 10 * 60 * 1000
|
|
428
|
-
const now = Date.now()
|
|
429
|
-
for (const slot of normalizeSlots(cfg.slots)) {
|
|
430
|
-
const ref = slot.ref
|
|
431
|
-
try {
|
|
432
|
-
const info = await store.describeRef(ref)
|
|
433
|
-
if (!info.configured) continue
|
|
434
|
-
if (info.cooldownUntil && info.cooldownUntil > now) continue
|
|
435
|
-
const raw = await store.resolveRaw(ref)
|
|
436
|
-
if (!raw) continue
|
|
437
|
-
const blob = await store.loadBlob(ref).catch(() => null)
|
|
438
|
-
if (!blob || !blob.refreshToken) continue
|
|
439
|
-
if (!blob.expiresAt) continue
|
|
440
|
-
if (blob.expiresAt - now > ahead) continue
|
|
441
|
-
if (typeof store.shouldSkipRefresh === 'function' && store.shouldSkipRefresh(ref, now, retryMs)) continue
|
|
442
|
-
await store.ensureFresh(slot.provider, blob, ref).catch((e) => {
|
|
443
|
-
if (logger && logger.warn) logger.warn('[dsh-subscriptions] background refresh failed for ' + ref + ': ' + String(e && e.message || e))
|
|
444
|
-
})
|
|
445
|
-
} catch { /* best-effort */ }
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
tick().catch(() => {})
|
|
449
|
-
const id = setInterval(() => { tick().catch(() => {}) }, 60 * 1000)
|
|
456
|
+
runProactiveHeartbeat().catch(() => {})
|
|
457
|
+
const id = setInterval(() => { runProactiveHeartbeat().catch(() => {}) }, 60 * 1000)
|
|
450
458
|
return () => clearInterval(id)
|
|
451
459
|
}, 'dsh-subscriptions: refresh ahead')
|
|
452
460
|
|
|
@@ -509,6 +517,7 @@ export function apply(ctx, rawConfig) {
|
|
|
509
517
|
refForSlot,
|
|
510
518
|
resetCredits,
|
|
511
519
|
diagnosticsReport,
|
|
520
|
+
triggerHeartbeat: runProactiveHeartbeat,
|
|
512
521
|
pending,
|
|
513
522
|
completeOAuth,
|
|
514
523
|
fetchForRef,
|
package/lib/routes/status.js
CHANGED
|
@@ -19,6 +19,7 @@ import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson
|
|
|
19
19
|
import { quotaSnapshot } from '../ratelimit.js'
|
|
20
20
|
import { PROVIDERS, displayName, droppedCredentialRefs, isProvider, oauthRef } from '../refs.js'
|
|
21
21
|
import { getVendor } from '../vendors/index.js'
|
|
22
|
+
import { defaultErrorBuffer } from '../error-buffer.js'
|
|
22
23
|
|
|
23
24
|
export function registerStatusRoutes(ctx, state) {
|
|
24
25
|
const {
|
|
@@ -298,6 +299,40 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
298
299
|
}),
|
|
299
300
|
}), 'dsh-subscriptions: /diagnostics')
|
|
300
301
|
|
|
302
|
+
ctx.effect(() => ctx.webServer.register({
|
|
303
|
+
kind: 'exact',
|
|
304
|
+
path: '/dsh-subscriptions/diagnostics/errors/clear',
|
|
305
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
306
|
+
if (req.method !== 'POST') {
|
|
307
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
311
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
defaultErrorBuffer.clear()
|
|
315
|
+
writeJson(res, 200, { ok: true, cleared: true })
|
|
316
|
+
}),
|
|
317
|
+
}), 'dsh-subscriptions: /diagnostics/errors/clear')
|
|
318
|
+
|
|
319
|
+
ctx.effect(() => ctx.webServer.register({
|
|
320
|
+
kind: 'exact',
|
|
321
|
+
path: '/dsh-subscriptions/heartbeat/run',
|
|
322
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
323
|
+
if (req.method !== 'POST') {
|
|
324
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
328
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
const refreshedCount = typeof state.triggerHeartbeat === 'function' ? await state.triggerHeartbeat() : 0
|
|
332
|
+
writeJson(res, 200, { ok: true, refreshed: refreshedCount })
|
|
333
|
+
}),
|
|
334
|
+
}), 'dsh-subscriptions: /heartbeat/run')
|
|
335
|
+
|
|
301
336
|
|
|
302
337
|
// ponytail: cheap per-vendor probe; never sets cooldown, never returns tokens
|
|
303
338
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -345,10 +380,6 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
345
380
|
return
|
|
346
381
|
}
|
|
347
382
|
const vendor = getVendor(provider)
|
|
348
|
-
if (typeof vendor.check !== 'function') {
|
|
349
|
-
writeJson(res, 200, { ok: true, provider, index: index, ref, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: info.quota || null })
|
|
350
|
-
return
|
|
351
|
-
}
|
|
352
383
|
let capturedQuota = null
|
|
353
384
|
const probeFetch = async (url, init = {}) => {
|
|
354
385
|
const doFetch = (typeof fetchForRef === "function" && fetchForRef(ref)) || fetch
|
|
@@ -360,8 +391,14 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
360
391
|
return res2
|
|
361
392
|
}
|
|
362
393
|
try {
|
|
363
|
-
|
|
364
|
-
|
|
394
|
+
if (typeof vendor.check === 'function') {
|
|
395
|
+
await vendor.check(blob, vendorConfig(provider, live()), probeFetch)
|
|
396
|
+
} else if (typeof vendor.listModels === 'function') {
|
|
397
|
+
await vendor.listModels(blob, vendorConfig(provider, live()), probeFetch)
|
|
398
|
+
} else if (typeof vendor.usage === 'function') {
|
|
399
|
+
await vendor.usage(blob, vendorConfig(provider, live()), probeFetch)
|
|
400
|
+
}
|
|
401
|
+
const latencyMs = Date.now() - t0
|
|
365
402
|
writeJson(res, 200, { ok: true, provider, index: index, ref, latencyMs, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: capturedQuota || info.quota || null, usagePercent: info.usagePercent ?? null })
|
|
366
403
|
} catch (e) {
|
|
367
404
|
const isTimeout = isTimeoutError(e)
|
package/lib/stream-rotate.js
CHANGED
|
@@ -4,6 +4,7 @@ import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, m
|
|
|
4
4
|
import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
|
|
5
5
|
import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
|
|
6
6
|
import { withRunawayGuard } from './runaway-guard.js'
|
|
7
|
+
import { defaultErrorBuffer } from './error-buffer.js'
|
|
7
8
|
|
|
8
9
|
export async function* streamWithRotation({
|
|
9
10
|
accounts,
|
|
@@ -163,6 +164,14 @@ export async function* streamWithRotation({
|
|
|
163
164
|
return
|
|
164
165
|
} catch (err) {
|
|
165
166
|
lastError = err
|
|
167
|
+
defaultErrorBuffer.record({
|
|
168
|
+
provider: options && options.provider,
|
|
169
|
+
ref: account.ref || account.id,
|
|
170
|
+
model: options && options.model,
|
|
171
|
+
status: err && (err.status || err.statusCode),
|
|
172
|
+
code: err && (err.code || err.name),
|
|
173
|
+
message: err && (err.message || String(err)),
|
|
174
|
+
})
|
|
166
175
|
// If chunks were already yielded to the caller, never rotate mid-stream
|
|
167
176
|
// as that would repeat or scramble generated tokens.
|
|
168
177
|
if (firstChunkDelivered) throw err
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-subscriptions",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.70",
|
|
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",
|