@goodandready/dsh-subscriptions 0.6.69 → 0.6.71

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.71)
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.71)
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.71)
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`。
@@ -45,6 +45,8 @@ export function createAdapterManager({
45
45
  drainBeforeReset: () => live().drainBeforeReset,
46
46
  drainSafetyPercent: () => live().drainSafetyPercent,
47
47
  switchAtRemaining: () => live().switchAtRemaining,
48
+ rotationStrategy: () => live().rotationStrategy,
49
+ enableSoftFailureSentinel: () => live().enableSoftFailureSentinel,
48
50
  rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
49
51
  rememberQuarantine: (ref, reason, until, attempts) => store.rememberQuarantine(ref, reason, until, attempts),
50
52
  getQuarantine: (ref) => store.getQuarantine(ref),
@@ -75,7 +77,7 @@ export function createAdapterManager({
75
77
  const loggedList = Array.isArray(logged) ? logged : Object.keys(logged || {}).filter((p) => logged[p])
76
78
  const available = loggedList.filter((p) => p !== provider)
77
79
  visited.add(provider)
78
- const nextVendor = resolveFallbackVendor(provider, available, cfg.cascadingChain, visited)
80
+ const nextVendor = resolveFallbackVendor(provider, available, cfg.cascadingChain, visited, cfg.cascadingFallbackOrder)
79
81
  if (!nextVendor) throw err
80
82
  visited.add(nextVendor)
81
83
  const targetModel = mapFallbackModel(provider, options && options.model, nextVendor)
package/lib/adapter.js CHANGED
@@ -142,6 +142,12 @@ export class SubscriptionAdapter extends LlmAdapter {
142
142
  const effectiveDrainSafetyPercent = options.drainSafetyPercent !== undefined
143
143
  ? options.drainSafetyPercent
144
144
  : (typeof deps.drainSafetyPercent === 'function' ? deps.drainSafetyPercent() : (deps.drainSafetyPercent ?? 5))
145
+ const effectiveRotationStrategy = options.rotationStrategy !== undefined
146
+ ? options.rotationStrategy
147
+ : (typeof deps.rotationStrategy === 'function' ? deps.rotationStrategy() : (deps.rotationStrategy ?? 'round-robin'))
148
+ const effectiveSoftFailureSentinel = options.enableSoftFailureSentinel !== undefined
149
+ ? options.enableSoftFailureSentinel
150
+ : (typeof deps.enableSoftFailureSentinel === 'function' ? deps.enableSoftFailureSentinel() : (deps.enableSoftFailureSentinel ?? true))
145
151
  const sessionId = options.sessionId || options.session_id || options.conversationId
146
152
  const effectiveReasoning = resolveEffectiveReasoning(sessionId, options)
147
153
  const decisionTrace = {}
@@ -153,6 +159,8 @@ export class SubscriptionAdapter extends LlmAdapter {
153
159
  autoPacing: effectiveAutoPacing,
154
160
  drainBeforeReset: effectiveDrainBeforeReset,
155
161
  drainSafetyPercent: effectiveDrainSafetyPercent,
162
+ rotationStrategy: effectiveRotationStrategy,
163
+ enableSoftFailureSentinel: effectiveSoftFailureSentinel,
156
164
  }
157
165
  for await (const event of streamWithRotation({
158
166
  accounts: await deps.listAccounts(provider),
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,24 @@ 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
+ "rotationStrategy": "Account Balancing Strategy",
440
+ "rotationStrategyHint": "Method used to distribute requests among available accounts of a provider.",
441
+ "rotationRoundRobin": "Round-Robin (Balanced rotation)",
442
+ "rotationLeastLoaded": "Least-Loaded (Fair-share quota)",
443
+ "rotationPriority": "Priority (Sequential by index/weight)",
444
+ "softFailureSentinel": "Soft-Failure & Refusal Sentinel",
445
+ "softFailureSentinelHint": "Detects HTTP 200 capacity apologies and auto-cascades to the next healthy account before streaming.",
446
+ "recentErrors": "Recent Runtime Errors",
447
+ "recentErrorsDesc": "Last 20 runtime failures across model streams and providers (sanitized).",
448
+ "recentErrorsClear": "Clear Log",
449
+ "recentErrorsEmpty": "No recent errors recorded",
450
+ "toastCooldownRecovered": "is now available (cooldown expired)",
433
451
  "modelsCatalogTitle": "Curated Subscription Models",
434
452
  "modelsCatalogDesc": "Available model catalog across active subscriptions in DeepSeek Harness.",
435
453
  "rotationDecisionsTitle": "Rotation & Routing Decisions",
@@ -702,6 +720,24 @@ const cssId = 'dsh-subscriptions/settings.module.css'
702
720
  "smokeTesting": "正在探活…",
703
721
  "smokeOk": "冒烟测试成功",
704
722
  "smokeFail": "冒烟测试失败",
723
+ "testConnection": "测试",
724
+ "testConnectionSuccess": "连接已验证",
725
+ "cascadingFallback": "跨服务商自动故障转移",
726
+ "cascadingFallbackHint": "当首选服务商所有账户配额耗尽或限流时,自动无缝转接至备选服务商。",
727
+ "cascadingFallbackOrder": "故障转移供应商顺序",
728
+ "cascadingFallbackOrderHint": "首选服务商耗尽时按此顺序依次尝试备选服务商。",
729
+ "rotationStrategy": "账户负载均衡策略",
730
+ "rotationStrategyHint": "在同一服务商的多个可用账户之间分发请求的方式。",
731
+ "rotationRoundRobin": "轮询均衡(标准交替)",
732
+ "rotationLeastLoaded": "最少负载优先(配额公平共享)",
733
+ "rotationPriority": "优先级顺序(按序号/权重顺序)",
734
+ "softFailureSentinel": "软性拒绝与容量哨兵",
735
+ "softFailureSentinelHint": "自动识别 HTTP 200 状态下的服务商容量道歉并无缝转接至下一个正常账户。",
736
+ "recentErrors": "最近运行错误记录",
737
+ "recentErrorsDesc": "最近 20 条流式传输与 API 调用失败记录(已脱敏)。",
738
+ "recentErrorsClear": "清空记录",
739
+ "recentErrorsEmpty": "暂无错误记录",
740
+ "toastCooldownRecovered": "已解除冷却恢复可用",
705
741
  "modelsCatalogTitle": "精选订阅模型",
706
742
  "modelsCatalogDesc": "当前 DeepSeek Harness 活跃订阅下可用模型一览。",
707
743
  "rotationDecisionsTitle": "轮询调度与路由决策",
@@ -987,7 +1023,13 @@ const cssId = 'dsh-subscriptions/settings.module.css'
987
1023
  if (account.quarantineReason && account.quarantineUntil && account.quarantineUntil > Date.now()) {
988
1024
  return account.quarantineReason === 'VERIFY' ? t('verifyAccount') : String(account.quarantineReason)
989
1025
  }
990
- if (account.cooldownUntil && account.cooldownUntil > Date.now()) return t('coolingDown')
1026
+ if (account.cooldownUntil && account.cooldownUntil > Date.now()) {
1027
+ const leftSec = Math.max(0, Math.ceil((account.cooldownUntil - Date.now()) / 1000))
1028
+ const m = Math.floor(leftSec / 60)
1029
+ const s = leftSec % 60
1030
+ const pad = (n) => String(n).padStart(2, "0")
1031
+ return t("coolingDown") + " (" + pad(m) + ":" + pad(s) + ")"
1032
+ }
991
1033
  if (account.usagePercent != null && account.usagePercent >= 100) return t('usageFull')
992
1034
  return t('connected')
993
1035
  }
@@ -1114,12 +1156,14 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1114
1156
  fetch('/dsh-subscriptions/diagnostics', { cache: 'no-store' })
1115
1157
  .then((r) => r.json())
1116
1158
  .then((d) => {
1159
+ if (d && d.ok && d.report) setDiagReport(d.report)
1117
1160
  const txt = d && d.ok ? JSON.stringify(d.report, null, 2) : ('error: ' + ((d && d.error && d.error.message) || 'unknown'))
1118
1161
  setDiag(txt)
1119
1162
  try { navigator.clipboard.writeText(txt) } catch { /* clipboard write disallowed */ }
1120
1163
  })
1121
1164
  .catch((e) => setDiag('error: ' + String(e && e.message || e)))
1122
1165
  }
1166
+ const [diagReport, setDiagReport] = React.useState(null)
1123
1167
  const [checkRes, setCheckRes] = React.useState({})
1124
1168
  const [hostPing, setHostPing] = React.useState({ ok: true, latencyMs: null })
1125
1169
  const [telemetry, setTelemetry] = React.useState(null)
@@ -1259,6 +1303,10 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1259
1303
  .then((r) => r.json())
1260
1304
  .then((d) => { if (d && d.ok && d.queue) setQueueStats(d.queue) })
1261
1305
  .catch(() => {})
1306
+ fetch('/dsh-subscriptions/diagnostics', { cache: 'no-store' })
1307
+ .then((r) => r.json())
1308
+ .then((d) => { if (d && d.ok && d.report) setDiagReport(d.report) })
1309
+ .catch(() => {})
1262
1310
  }
1263
1311
 
1264
1312
  const runSmokeTest = async (provider, index) => {
@@ -2081,6 +2129,75 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2081
2129
  React.createElement('span', null, t('notifyLimits')),
2082
2130
  ),
2083
2131
  React.createElement('div', { className: 'dsub-sub' }, t('notifyLimitsHint')),
2132
+ React.createElement('label', { className: 'dsub-row', style: { marginTop: 10 } },
2133
+ React.createElement('input', {
2134
+ type: 'checkbox',
2135
+ checked: Boolean(draft.cascadingFallback),
2136
+ onChange: (e) => setDraft((d) => Object.assign({}, d, { cascadingFallback: e.target.checked })),
2137
+ }),
2138
+ React.createElement('span', null, t('cascadingFallback')),
2139
+ ),
2140
+ React.createElement('div', { className: 'dsub-sub' }, t('cascadingFallbackHint')),
2141
+ Boolean(draft.cascadingFallback) ? React.createElement('div', { style: { marginTop: 8, padding: '8px 12px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 8 } },
2142
+ React.createElement('div', { style: { fontSize: 12, fontWeight: 600, marginBottom: 4 } }, t('cascadingFallbackOrder')),
2143
+ React.createElement('div', { className: 'dsub-sub', style: { marginBottom: 8 } }, t('cascadingFallbackOrderHint')),
2144
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
2145
+ (Array.isArray(draft.cascadingFallbackOrder) && draft.cascadingFallbackOrder.length ? draft.cascadingFallbackOrder : (providers.length ? providers : ['antigravity', 'claude', 'codex', 'grok', 'copilot', 'cursor'])).map((p, idx, arr) => {
2146
+ 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 } },
2147
+ React.createElement('span', null, (idx + 1) + '. ' + p.toUpperCase()),
2148
+ React.createElement('div', { style: { display: 'flex', gap: 4 } },
2149
+ idx > 0 ? React.createElement('button', {
2150
+ type: 'button', className: 'dsub-mini', style: { padding: '1px 6px', fontSize: 10 },
2151
+ onClick: () => {
2152
+ const next = arr.slice()
2153
+ const tmp = next[idx - 1]
2154
+ next[idx - 1] = next[idx]
2155
+ next[idx] = tmp
2156
+ setDraft((d) => Object.assign({}, d, { cascadingFallbackOrder: next }))
2157
+ }
2158
+ }, '▲') : null,
2159
+ idx < arr.length - 1 ? React.createElement('button', {
2160
+ type: 'button', className: 'dsub-mini', style: { padding: '1px 6px', fontSize: 10 },
2161
+ onClick: () => {
2162
+ const next = arr.slice()
2163
+ const tmp = next[idx + 1]
2164
+ next[idx + 1] = next[idx]
2165
+ next[idx] = tmp
2166
+ setDraft((d) => Object.assign({}, d, { cascadingFallbackOrder: next }))
2167
+ }
2168
+ }, '▼') : null,
2169
+ )
2170
+ )
2171
+ })
2172
+ )
2173
+ ) : null,
2174
+ ),
2175
+ React.createElement('div', { className: 'dsub-row', style: { marginTop: 12 } },
2176
+ React.createElement('label', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
2177
+ React.createElement('span', { style: { fontWeight: 600 } }, t('rotationStrategy')),
2178
+ React.createElement('span', { className: 'dsub-sub' }, t('rotationStrategyHint')),
2179
+ React.createElement('select', {
2180
+ className: 'dsub-select',
2181
+ value: draft.rotationStrategy || 'round-robin',
2182
+ onChange: (e) => setDraft((d) => Object.assign({}, d, { rotationStrategy: e.target.value })),
2183
+ style: { maxWidth: 320, marginTop: 4 }
2184
+ },
2185
+ React.createElement('option', { value: 'round-robin' }, t('rotationRoundRobin')),
2186
+ React.createElement('option', { value: 'least-loaded' }, t('rotationLeastLoaded')),
2187
+ React.createElement('option', { value: 'priority' }, t('rotationPriority')),
2188
+ ),
2189
+ ),
2190
+ ),
2191
+ React.createElement('div', { className: 'dsub-row', style: { marginTop: 12 } },
2192
+ React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' } },
2193
+ React.createElement('input', {
2194
+ type: 'checkbox',
2195
+ checked: draft.enableSoftFailureSentinel !== false,
2196
+ onChange: (e) => setDraft((d) => Object.assign({}, d, { enableSoftFailureSentinel: e.target.checked })),
2197
+ }),
2198
+ React.createElement('span', null, t('softFailureSentinel')),
2199
+ ),
2200
+ React.createElement('div', { className: 'dsub-sub' }, t('softFailureSentinelHint')),
2084
2201
  ),
2085
2202
  ),
2086
2203
  ),
@@ -2105,6 +2222,39 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2105
2222
  }, t('diagCopy')),
2106
2223
  React.createElement('pre', { className: 'dsub-diag' }, diag),
2107
2224
  ) : null,
2225
+ React.createElement('details', { className: 'dsub-details', style: { marginTop: 12 } },
2226
+ React.createElement('summary', { className: 'dsub-summary' }, '⚠️ ' + t('recentErrors')),
2227
+ React.createElement('div', { className: 'dsub-block' },
2228
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 } },
2229
+ React.createElement('span', { className: 'dsub-sub' }, t('recentErrorsDesc')),
2230
+ React.createElement('button', {
2231
+ type: 'button', className: 'dsub-mini',
2232
+ onClick: async () => {
2233
+ await fetch('/dsh-subscriptions/diagnostics/errors/clear', { method: 'POST' }).catch(() => {})
2234
+ genDiag()
2235
+ }
2236
+ }, t('recentErrorsClear'))
2237
+ ),
2238
+ (diagReport && Array.isArray(diagReport.recentErrors) && diagReport.recentErrors.length ? diagReport.recentErrors : []).length === 0
2239
+ ? React.createElement('div', { className: 'dsub-dim', style: { padding: 8 } }, t('recentErrorsEmpty'))
2240
+ : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
2241
+ (diagReport.recentErrors || []).map((err, i) => React.createElement('div', {
2242
+ key: i,
2243
+ 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)' }
2244
+ },
2245
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', marginBottom: 2 } },
2246
+ React.createElement('span', { style: { fontWeight: 600 } }, (err.provider || 'unknown').toUpperCase() + (err.model ? ' · ' + err.model : '')),
2247
+ React.createElement('span', { className: 'dsub-dim' }, (err.ts || '').slice(11, 19))
2248
+ ),
2249
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
2250
+ 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,
2251
+ err.status ? React.createElement('span', { className: 'dsub-dim' }, 'HTTP ' + err.status) : null,
2252
+ React.createElement('span', { style: { opacity: 0.9, wordBreak: 'break-word' } }, err.message)
2253
+ )
2254
+ ))
2255
+ )
2256
+ )
2257
+ ),
2108
2258
  wizardModal ? React.createElement('div', { className: 'dsub-modalWrap', onClick: (e) => { if (e.target === e.currentTarget) setWizardModal(null) } },
2109
2259
  React.createElement('div', { className: 'dsub-modal' },
2110
2260
  React.createElement('div', { className: 'dsub-modalHead' },
@@ -2269,8 +2419,16 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2269
2419
  React.createElement('button', {
2270
2420
  type: 'button', className: 'dsub-mini',
2271
2421
  disabled: checking[key],
2422
+ title: t('smokeDesc'),
2272
2423
  onClick: () => doCheck(row.slot.provider, row.slot.index).catch((e) => setErr(cleanErrorMessage(e.message || e))),
2273
- }, checking[key] ? t('checking') : t('check')),
2424
+ }, checking[key] ? t('checking') : ('⚡ ' + t('testConnection'))),
2425
+ (function(){
2426
+ var r = checkRes[key];
2427
+ if (!r) return null;
2428
+ var cls = r.ok ? 'dsub-badge-ok' : 'dsub-badge-bad';
2429
+ var txt = r.ok ? ('✓ ' + (r.latencyMs != null ? r.latencyMs + 'ms' : 'OK')) : ('✗ ' + ((r.error && (r.error.code || r.error.message)) || 'FAIL'));
2430
+ return React.createElement('span', { className: 'dsub-badge ' + cls, style: { marginLeft: 4 }, title: r.ok ? t('testConnectionSuccess') : ((r.error && r.error.message) || 'fail') }, txt);
2431
+ })(),
2274
2432
  React.createElement('button', {
2275
2433
  type: 'button', className: 'dsub-mini',
2276
2434
  title: t('wizardTitle'),
@@ -3141,6 +3299,24 @@ const cssId = 'dsh-subscriptions/settings.module.css'
3141
3299
  )
3142
3300
  }
3143
3301
 
3302
+ const prevCooldownsRef = React.useRef(new Map())
3303
+ React.useEffect(() => {
3304
+ for (const a of (state.accounts || [])) {
3305
+ const isCooled = Boolean(a.cooldownUntil && a.cooldownUntil > now)
3306
+ const wasCooled = prevCooldownsRef.current.get(a.ref)
3307
+ if (wasCooled && !isCooled) {
3308
+ const toastId = 't_cool_' + a.ref + '_' + Date.now()
3309
+ setToasts((prev) => [{
3310
+ id: toastId,
3311
+ message: (a.label || a.ref) + ' ' + t('toastCooldownRecovered'),
3312
+ cls: 'dsub-toast-ok',
3313
+ }, ...prev].slice(0, 3))
3314
+ setTimeout(() => dismissToast(toastId), 5000)
3315
+ }
3316
+ prevCooldownsRef.current.set(a.ref, isCooled)
3317
+ }
3318
+ }, [state.accounts, now])
3319
+
3144
3320
  // Accounts list
3145
3321
  const rows = state.accounts.map((a, i) => {
3146
3322
  const isLogged = state.logged.includes(a.provider)
@@ -3157,8 +3333,12 @@ const cssId = 'dsh-subscriptions/settings.module.css'
3157
3333
  statusText = a.quarantineReason === 'VERIFY' ? t('verifyAccount') : String(a.quarantineReason)
3158
3334
  } else if (cooled) {
3159
3335
  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('/') : '')
3336
+ const leftSec = Math.max(0, Math.ceil((a.cooldownUntil - now) / 1000))
3337
+ const m = Math.floor(leftSec / 60)
3338
+ const s = leftSec % 60
3339
+ const pad = (n) => String(n).padStart(2, '0')
3340
+ const fams = Array.isArray(a.cooldownFamilies) && a.cooldownFamilies.length ? ' · ' + a.cooldownFamilies.join('/') : ''
3341
+ statusText = t('subsCooldown') + ' (' + pad(m) + ':' + pad(s) + ')' + fams
3162
3342
  } else if (isLogged) {
3163
3343
  if (pct >= 90) { statusClass = 'dsub-statusBad'; statusText = '90%+ ' + t('subsUsed') }
3164
3344
  else if (pct >= 50) { statusClass = 'dsub-statusWarn'; statusText = pct + '% ' + t('subsUsed') }
@@ -108,6 +108,12 @@ 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.'),
113
+ rotationStrategy: z.string().volatile().default('round-robin')
114
+ .description('Load balancing strategy among available accounts: round-robin (balanced), least-loaded (fair-share quota), or priority (sequential).'),
115
+ enableSoftFailureSentinel: z.boolean().volatile().default(true)
116
+ .description('Intercept HTTP 200 soft-failure capacity apologies and automatically cascade to next healthy account.'),
111
117
  cascadingChain: z.any().volatile().default({})
112
118
  .description('#349 Custom cross-vendor fallback chains (e.g. { claude: ["copilot", "cursor"] }).'),
113
119
  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,10 @@ 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,
86
+ rotationStrategy: cfg.rotationStrategy || 'round-robin',
87
+ enableSoftFailureSentinel: cfg.enableSoftFailureSentinel !== false,
81
88
  },
82
89
  })
83
90
  }
@@ -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,
package/lib/quarantine.js CHANGED
@@ -3,6 +3,7 @@ export const REASON_RATE_LIMIT = 'RATE_LIMIT'
3
3
  export const REASON_HARD_LIMIT = 'HARD_LIMIT'
4
4
  export const REASON_REVOKED = 'TOKEN_REVOKED'
5
5
  export const REASON_MANUAL_PARK = 'MANUAL_PARK'
6
+ export const REASON_DEGRADED = 'DEGRADED_CAPACITY'
6
7
 
7
8
  export const STATUS_ACTIVE = 'active'
8
9
  export const STATUS_QUARANTINE = 'quarantine'
@@ -16,18 +17,21 @@ export function quarantineDuration(reason) {
16
17
  return 7 * 24 * 60 * 60 * 1000 // 7 days (requires re-auth)
17
18
  case REASON_MANUAL_PARK:
18
19
  return 60 * 60 * 1000 // 1 hour (manual park)
20
+ case REASON_DEGRADED:
21
+ return 5 * 60 * 1000 // 5 minutes degraded cooldown
19
22
  case REASON_RATE_LIMIT:
20
23
  default:
21
24
  return 60 * 60 * 1000 // 1 hour
22
25
  }
23
26
  }
24
27
 
25
- export function putInQuarantine(account, reason, nowMs = Date.now()) {
28
+ export function putInQuarantine(account, reason, nowMs = Date.now(), customUntil = null) {
26
29
  const duration = quarantineDuration(reason)
30
+ const until = (customUntil && Number(customUntil) > Number(nowMs)) ? Number(customUntil) : (Number(nowMs) + duration)
27
31
  return {
28
32
  ...account,
29
33
  quarantineReason: reason,
30
- quarantineUntil: nowMs + duration,
34
+ quarantineUntil: until,
31
35
  status: STATUS_QUARANTINE,
32
36
  }
33
37
  }
@@ -0,0 +1,87 @@
1
+ import { parseHeaders } from './ratelimit.js'
2
+
3
+ export const VENDOR_WINDOW_MS = {
4
+ claude: 5 * 3600 * 1000,
5
+ codex: 3 * 3600 * 1000,
6
+ grok: 2 * 3600 * 1000,
7
+ }
8
+
9
+ export function parseResetFromText(message, nowMs = Date.now()) {
10
+ if (!message || typeof message !== 'string') return null
11
+ const s = message.trim()
12
+
13
+ const secMatch = s.match(/(?:try again in|retry after|wait|resets? in)\s+(\d+)\s*(?:s|sec|seconds?)/i)
14
+ if (secMatch) {
15
+ const sec = Number(secMatch[1])
16
+ if (Number.isFinite(sec) && sec > 0) return nowMs + sec * 1000
17
+ }
18
+
19
+ const minMatch = s.match(/(?:try again in|retry after|wait|resets? in)\s+(\d+)\s*(?:m|min|minutes?)/i)
20
+ if (minMatch) {
21
+ const min = Number(minMatch[1])
22
+ if (Number.isFinite(min) && min > 0) return nowMs + min * 60 * 1000
23
+ }
24
+
25
+ const hrMatch = s.match(/(?:try again in|retry after|wait|resets? in)\s+(\d+)\s*(?:h|hr|hours?)/i)
26
+ if (hrMatch) {
27
+ const hr = Number(hrMatch[1])
28
+ if (Number.isFinite(hr) && hr > 0) return nowMs + hr * 3600 * 1000
29
+ }
30
+
31
+ return null
32
+ }
33
+
34
+ export function resolveAccurateReset(err, account, provider, nowMs = Date.now(), safetyBufferMs = 10000) {
35
+ const now = Number(nowMs) || Date.now()
36
+ let targetReset = null
37
+
38
+ if (err && err.resetAt) {
39
+ const val = Number(err.resetAt)
40
+ if (Number.isFinite(val) && val > now) {
41
+ targetReset = val
42
+ }
43
+ }
44
+
45
+ if (!targetReset && err) {
46
+ const headers = err.headers || (err.response && err.response.headers)
47
+ if (headers) {
48
+ const parsed = parseHeaders(headers, now)
49
+ if (parsed && parsed.resetAt && parsed.resetAt > now) {
50
+ targetReset = parsed.resetAt
51
+ }
52
+ }
53
+ }
54
+
55
+ if (!targetReset && err && err.retryAfter) {
56
+ const n = Number(err.retryAfter)
57
+ if (Number.isFinite(n) && n > 0) {
58
+ targetReset = n > 1e9 ? (n > 1e12 ? n : n * 1000) : now + n * 1000
59
+ }
60
+ }
61
+
62
+ if (!targetReset && err && err.message) {
63
+ const textReset = parseResetFromText(err.message, now)
64
+ if (textReset && textReset > now) {
65
+ targetReset = textReset
66
+ }
67
+ }
68
+
69
+ if (!targetReset && account && account.quota && account.quota.resetAt) {
70
+ const qReset = Number(account.quota.resetAt)
71
+ if (Number.isFinite(qReset) && qReset > now) {
72
+ targetReset = qReset
73
+ }
74
+ }
75
+
76
+ if (targetReset && targetReset > now && targetReset <= now + 7 * 86400 * 1000) {
77
+ return targetReset + (Number(safetyBufferMs) || 0)
78
+ }
79
+
80
+ const p = String(provider || (account && account.provider) || '').toLowerCase()
81
+ const windowDefault = VENDOR_WINDOW_MS[p]
82
+ if (windowDefault && err && (err.status === 429 || err.code === 'RATE_LIMIT' || err.code === 'QUOTA')) {
83
+ return now + windowDefault + (Number(safetyBufferMs) || 0)
84
+ }
85
+
86
+ return null
87
+ }
package/lib/rotate.js CHANGED
@@ -7,6 +7,7 @@ const SWITCH_CODES = new Set([
7
7
  'QUOTA',
8
8
  'QUOTA_EXCEEDED',
9
9
  'LICENSE_REQUIRED',
10
+ 'SOFT_FAILURE',
10
11
  ])
11
12
 
12
13
  export function isRegionError(err) {
@@ -18,7 +19,7 @@ export function isRegionError(err) {
18
19
 
19
20
  export function isSwitchableError(err) {
20
21
  if (!err || typeof err !== 'object') return false
21
- if (err.name === 'TimeoutError' || err.code === 'TIMEOUT' || err.code === 'EMPTY_RESPONSE') return true
22
+ if (err.name === 'TimeoutError' || err.code === 'TIMEOUT' || err.code === 'EMPTY_RESPONSE' || err.code === 'SOFT_FAILURE' || err.isSoftFailure) return true
22
23
  if (err.name === 'AbortError' || err.code === 'ABORT_ERR' || err.code === 'ERR_ABORTED' || (err.message && /aborted/i.test(err.message))) return false
23
24
  const code = err.code || (err.failure && err.failure.code)
24
25
  if (SWITCH_CODES.has(code)) return true
@@ -263,6 +264,33 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
263
264
  }
264
265
  }
265
266
 
267
+ const strategy = (opts && opts.rotationStrategy) || 'round-robin'
268
+ if (strategy === 'least-loaded') {
269
+ const aRem = getAccountRemainingPercent(a)
270
+ const bRem = getAccountRemainingPercent(b)
271
+ if (aRem != null && bRem != null) {
272
+ if (Math.abs(aRem - bRem) >= 5) {
273
+ return bRem - aRem
274
+ }
275
+ } else if (aRem != null && bRem == null) {
276
+ return -1
277
+ } else if (aRem == null && bRem != null) {
278
+ return 1
279
+ }
280
+ if (a.usagePercent != null && b.usagePercent != null) {
281
+ if (Math.abs(Number(a.usagePercent) - Number(b.usagePercent)) >= 5) {
282
+ return Number(a.usagePercent) - Number(b.usagePercent)
283
+ }
284
+ }
285
+ } else if (strategy === 'priority') {
286
+ const aWeight = a.weight || (a.isPro ? 5 : 1)
287
+ const bWeight = b.weight || (b.isPro ? 5 : 1)
288
+ if (aWeight !== bWeight) return bWeight - aWeight
289
+ const aIdx = list.indexOf(a)
290
+ const bIdx = list.indexOf(b)
291
+ if (aIdx !== -1 && bIdx !== -1 && aIdx !== bIdx) return aIdx - bIdx
292
+ }
293
+
266
294
  // 0. Auto-Pacing: lower pacing risk preferred (#352)
267
295
  const aRisk = Number(a.pacingRisk) || 0
268
296
  const bRisk = Number(b.pacingRisk) || 0
@@ -333,14 +361,17 @@ export function pickAccount(accounts, nowMs, opts) {
333
361
  return res ? res.account : null
334
362
  }
335
363
 
336
- export function markCooldown(account, nowMs, cooldownMs, family) {
364
+ export function markCooldown(account, nowMs, cooldownMs, family, customUntil = null) {
337
365
  const wait = Number(cooldownMs)
338
366
  const ms = Number.isFinite(wait) && wait > 0 ? wait : 30 * 60 * 1000
367
+ const until = (customUntil && Number(customUntil) > (Number(nowMs) || 0))
368
+ ? Number(customUntil)
369
+ : ((Number(nowMs) || 0) + ms)
339
370
  const prev = Array.isArray(account.cooldownFamilies) ? account.cooldownFamilies.slice() : []
340
371
  const fams = family ? (prev.includes(family) ? prev : prev.concat(family)) : prev
341
372
  return {
342
373
  ...account,
343
- cooldownUntil: (Number(nowMs) || 0) + ms,
374
+ cooldownUntil: until,
344
375
  ...(fams.length ? { cooldownFamilies: fams } : {}),
345
376
  }
346
377
  }
@@ -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)
@@ -0,0 +1,136 @@
1
+ export const SOFT_FAILURE_PATTERNS = [
2
+ /currently (?:unable to process|unavailable|overloaded)/i,
3
+ /experiencing (?:exceptionally )?(?:high|heavy) (?:traffic|demand|load)/i,
4
+ /capacity (?:is )?temporarily (?:unavailable|limited|exceeded)/i,
5
+ /model is (?:currently )?(?:overloaded|busy)/i,
6
+ /due to high traffic/i,
7
+ /usage cap reached, switching to lower/i,
8
+ /you have reached your (?:current )?usage limit/i,
9
+ /server is currently under heavy load/i,
10
+ /i apologize, but (?:i am unable to assist|i cannot fulfill this request due to (?:high demand|capacity|server load))/i,
11
+ /our systems are experiencing heavy load/i,
12
+ ]
13
+
14
+ const SUSPICIOUS_ROOTS = [
15
+ 'i am',
16
+ 'i apologize',
17
+ 'we are',
18
+ 'capacity',
19
+ 'the model',
20
+ 'due to',
21
+ 'usage cap',
22
+ 'you have reached',
23
+ 'server is',
24
+ 'our systems',
25
+ ]
26
+
27
+ export function extractChunkText(chunk) {
28
+ if (!chunk) return ''
29
+ if (typeof chunk === 'string') return chunk
30
+ if (typeof chunk.delta === 'string') return chunk.delta
31
+ if (typeof chunk.content === 'string') return chunk.content
32
+ if (typeof chunk.text === 'string') return chunk.text
33
+ if (chunk.choices && Array.isArray(chunk.choices) && chunk.choices[0]) {
34
+ const c0 = chunk.choices[0]
35
+ if (c0.delta && typeof c0.delta.content === 'string') return c0.delta.content
36
+ if (typeof c0.text === 'string') return c0.text
37
+ }
38
+ if (chunk.candidates && Array.isArray(chunk.candidates) && chunk.candidates[0]) {
39
+ const cand = chunk.candidates[0]
40
+ if (cand.content && Array.isArray(cand.content.parts)) {
41
+ return cand.content.parts.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('')
42
+ }
43
+ }
44
+ return ''
45
+ }
46
+
47
+ export function detectSoftFailure(text) {
48
+ if (!text || typeof text !== 'string') return null
49
+ const cleaned = text.trim()
50
+ if (!cleaned) return null
51
+ for (const pattern of SOFT_FAILURE_PATTERNS) {
52
+ if (pattern.test(cleaned)) {
53
+ return pattern.toString()
54
+ }
55
+ }
56
+ return null
57
+ }
58
+
59
+ export function isCandidatePrefix(text) {
60
+ if (!text) return true
61
+ const low = text.toLowerCase().trim()
62
+ if (low.length < 3) return true
63
+ for (const root of SUSPICIOUS_ROOTS) {
64
+ if (root.startsWith(low) || low.startsWith(root)) {
65
+ return true
66
+ }
67
+ }
68
+ return false
69
+ }
70
+
71
+ export function createSoftFailureError(pattern, text) {
72
+ const preview = String(text || '').slice(0, 120).replace(/\s+/g, ' ')
73
+ const err = new Error(`Soft-failure detected: ${preview}`)
74
+ return Object.assign(err, {
75
+ name: 'SoftFailureError',
76
+ code: 'SOFT_FAILURE',
77
+ status: 429,
78
+ isSoftFailure: true,
79
+ degraded: true,
80
+ pattern,
81
+ })
82
+ }
83
+
84
+ export async function* withSoftFailureSentinel(stream, options = {}) {
85
+ const enabled = options.enabled !== false
86
+ if (!enabled || !stream) {
87
+ if (stream) {
88
+ for await (const chunk of stream) {
89
+ yield chunk
90
+ }
91
+ }
92
+ return
93
+ }
94
+
95
+ const bufferLimitChars = Number(options.bufferLimitChars) || 250
96
+ const maxBufferChunks = Number(options.maxBufferChunks) || 4
97
+
98
+ const bufferedChunks = []
99
+ let accumulatedText = ''
100
+ let flushed = false
101
+
102
+ for await (const chunk of stream) {
103
+ if (flushed) {
104
+ yield chunk
105
+ continue
106
+ }
107
+
108
+ bufferedChunks.push(chunk)
109
+ accumulatedText += extractChunkText(chunk)
110
+
111
+ const matched = detectSoftFailure(accumulatedText)
112
+ if (matched) {
113
+ throw createSoftFailureError(matched, accumulatedText)
114
+ }
115
+
116
+ const candidate = isCandidatePrefix(accumulatedText)
117
+ if (!candidate || accumulatedText.length >= bufferLimitChars || bufferedChunks.length >= maxBufferChunks) {
118
+ flushed = true
119
+ for (const b of bufferedChunks) {
120
+ yield b
121
+ }
122
+ bufferedChunks.length = 0
123
+ }
124
+ }
125
+
126
+ if (!flushed) {
127
+ const matched = detectSoftFailure(accumulatedText)
128
+ if (matched) {
129
+ throw createSoftFailureError(matched, accumulatedText)
130
+ }
131
+ for (const b of bufferedChunks) {
132
+ yield b
133
+ }
134
+ bufferedChunks.length = 0
135
+ }
136
+ }
@@ -1,9 +1,12 @@
1
1
  import { defaultConcurrencyQueue } from './concurrency-queue.js'
2
2
  import { recordSessionCacheUsage } from './session-pin.js'
3
3
  import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
4
- import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
4
+ import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED, REASON_DEGRADED } 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'
8
+ import { withSoftFailureSentinel } from './sentinel.js'
9
+ import { resolveAccurateReset } from './reset-clock.js'
7
10
 
8
11
  export async function* streamWithRotation({
9
12
  accounts,
@@ -54,6 +57,7 @@ export async function* streamWithRotation({
54
57
  autoPacing: options && (options.autoPacing !== undefined ? options.autoPacing : (options.config && options.config.autoPacing)),
55
58
  drainBeforeReset: options && (options.drainBeforeReset !== undefined ? options.drainBeforeReset : (options.config && options.config.drainBeforeReset)),
56
59
  drainSafetyPercent: options && (options.drainSafetyPercent !== undefined ? options.drainSafetyPercent : (options.config && options.config.drainSafetyPercent)),
60
+ rotationStrategy: options && (options.rotationStrategy !== undefined ? options.rotationStrategy : (options.config && options.config.rotationStrategy)),
57
61
  })
58
62
  const account = pickRes.account
59
63
 
@@ -146,7 +150,10 @@ export async function* streamWithRotation({
146
150
  streamOnce(account, { ...options, _runawayState: runawayState }),
147
151
  runawayOpts
148
152
  )
149
- for await (const chunk of guardedStream) {
153
+ const sentineledStream = withSoftFailureSentinel(guardedStream, {
154
+ enabled: (options?.enableSoftFailureSentinel !== false) && (options?.config?.enableSoftFailureSentinel !== false),
155
+ })
156
+ for await (const chunk of sentineledStream) {
150
157
  firstChunkDelivered = true
151
158
  const cachedTokens = Number(
152
159
  (chunk && chunk.usage && (chunk.usage.cacheReadTokens || chunk.usage.cachedTokens)) ||
@@ -163,6 +170,14 @@ export async function* streamWithRotation({
163
170
  return
164
171
  } catch (err) {
165
172
  lastError = err
173
+ defaultErrorBuffer.record({
174
+ provider: options && options.provider,
175
+ ref: account.ref || account.id,
176
+ model: options && options.model,
177
+ status: err && (err.status || err.statusCode),
178
+ code: err && (err.code || err.name),
179
+ message: err && (err.message || String(err)),
180
+ })
166
181
  // If chunks were already yielded to the caller, never rotate mid-stream
167
182
  // as that would repeat or scramble generated tokens.
168
183
  if (firstChunkDelivered) throw err
@@ -186,18 +201,24 @@ export async function* streamWithRotation({
186
201
  currentAttempt.status = 'switched'
187
202
  currentAttempt.switchError = err ? (err.code || err.status || err.message) : 'switchable'
188
203
 
189
- // Move slot to cooldown and quarantine (#172)
190
- const cooled = markCooldown(account, nowMs(), cooldownMs, modelFamily(options && options.provider, options && options.model))
204
+ const accurateUntil = resolveAccurateReset(err, account, options && options.provider, nowMs())
205
+ const isSoft = Boolean(err && (err.code === 'SOFT_FAILURE' || err.isSoftFailure))
206
+
207
+ // Move slot to cooldown and quarantine (#172, #538)
208
+ const effectiveCooldownMs = isSoft ? Math.min(cooldownMs, 5 * 60 * 1000) : cooldownMs
209
+ const cooled = markCooldown(account, nowMs(), effectiveCooldownMs, modelFamily(options && options.provider, options && options.model), accurateUntil)
191
210
  account.cooldownUntil = cooled.cooldownUntil
192
211
  if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
193
212
 
194
213
  const status = Number(err && (err.status || err.statusCode) || 0)
195
214
  const code = String(err && err.code || '')
196
- const reason = (status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
197
- ? REASON_REVOKED
198
- : (status === 403 || code === 'HARD_LIMIT' || code === 'LICENSE_REQUIRED')
199
- ? REASON_HARD_LIMIT
200
- : REASON_RATE_LIMIT
215
+ const reason = isSoft
216
+ ? REASON_DEGRADED
217
+ : (status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
218
+ ? REASON_REVOKED
219
+ : (status === 403 || code === 'HARD_LIMIT' || code === 'LICENSE_REQUIRED')
220
+ ? REASON_HARD_LIMIT
221
+ : REASON_RATE_LIMIT
201
222
 
202
223
  if (err && err.isWarmupProbeFailure && err.warmupAccount) {
203
224
  account.quarantineUntil = err.warmupAccount.quarantineUntil
@@ -205,7 +226,7 @@ export async function* streamWithRotation({
205
226
  account.quarantineAttempts = err.warmupAccount.quarantineAttempts
206
227
  account.status = err.warmupAccount.status
207
228
  } else {
208
- const quarantined = putInQuarantine(account, reason, nowMs())
229
+ const quarantined = putInQuarantine(account, reason, nowMs(), accurateUntil)
209
230
  account.quarantineUntil = quarantined.quarantineUntil
210
231
  account.quarantineReason = quarantined.quarantineReason
211
232
  account.quarantineAttempts = (account.quarantineAttempts || 0) + 1
@@ -213,6 +234,10 @@ export async function* streamWithRotation({
213
234
 
214
235
  currentAttempt.quarantineReason = account.quarantineReason || reason
215
236
  currentAttempt.quarantineUntil = account.quarantineUntil
237
+ if (accurateUntil) {
238
+ currentAttempt.accurateReset = true
239
+ currentAttempt.resetAt = accurateUntil
240
+ }
216
241
 
217
242
  try {
218
243
  notifyQuarantineEntered({
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.71",
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",