@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 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`。
@@ -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
- export function resolveFallbackVendor(currentProvider, availableProviders, customChains = {}, visited = new Set()) {
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 chain = (customChains && customChains[currentProvider]) || DEFAULT_CASCADE_CHAINS[currentProvider] || []
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()) return t('coolingDown')
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('check')),
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
- // Only some model families may be cooled down - say which ones.
3161
- statusText = t('subsCooldown') + (Array.isArray(a.cooldownFamilies) && a.cooldownFamilies.length ? ' · ' + a.cooldownFamilies.join('/') : '')
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') }
@@ -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('')
@@ -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 diagnosticsReport = () => createDiagnosticsReport({ live, store, history, NS, pkgVersion })
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
- const tick = async () => {
425
- const cfg = live()
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,
@@ -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
- await vendor.check(blob, vendorConfig(provider, live()), probeFetch)
364
- const latencyMs = Date.now() - t0;
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)
@@ -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.69",
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",