@goodandready/dsh-subscriptions 0.6.70 → 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,7 +207,7 @@ 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)
210
+ ### 24. 🛡️ Resilience, Diagnostics & Fallback Enhancements (v0.6.71)
211
211
  * **Cross-Vendor Cascading Fallback & Provider Priority Ordering (#536)**: Configure fallback ordering across subscription providers with interactive reordering in WebUI.
212
212
  * **1-Click Connection Smoke Test & Latency Ping (#536)**: Test individual account connections on the spot with real-time latency badges.
213
213
  * **Live Cooldown Countdown & Availability Toasts (#536)**: Exact countdown timer for cooled-down accounts and automated toast alerts upon recovery.
package/README.ru.md CHANGED
@@ -183,7 +183,7 @@ 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)
186
+ ### 20. 🛡️ Отказоустойчивость, диагностика и порядок фолбэка (v0.6.71)
187
187
  * **Межвендорный каскадный фолбэк с настройкой очередности (#536)**: Настройка порядка переключения провайдеров при исчерпании квот с интерактивным изменением приоритета (▲/▼) в WebUI.
188
188
  * **1-Click проверка подключения и пинг задержки (#536)**: Проверка конкретного аккаунта в один клик с отображением задержки (`✓ 240ms`).
189
189
  * **Живой таймер кулдауна и уведомления о восстановлении (#536)**: Точный обратный отсчет кулдауна и всплывающие in-app тосты при возвращении аккаунта в строй.
package/README.zh.md CHANGED
@@ -132,7 +132,7 @@ graph LR
132
132
  queueTimeoutMs: 30000 # v0.6.62: 队列最大等待超时时间(毫秒)
133
133
  * **自动化 Webhook 告警集成**:通过 `notifyQuarantineEntered` 与 `notifyQuarantineReleased` 自动向外部通知系统派发隔离生命周期事件。
134
134
 
135
- ### 🛡️ 弹性容灾、故障诊断与跨服务商回退 (v0.6.70)
135
+ ### 🛡️ 弹性容灾、故障诊断与跨服务商回退 (v0.6.71)
136
136
  * **跨服务商级联故障转移与优先级排序 (#536)**: 支持自定义跨服务商回退顺序,可在 WebUI 中通过 ▲/▼ 交互调整优先级。
137
137
  * **一键链路测试与延迟检测 (#536)**: 在账户列表或状态弹窗中一键发起探活并显示往返延迟。
138
138
  * **冷却倒计时与就绪通知 (#536)**: 实时展示受限账户恢复倒计时,解除冷却时自动弹出就绪通知。
@@ -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),
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/client.js CHANGED
@@ -436,6 +436,13 @@ const cssId = 'dsh-subscriptions/settings.module.css'
436
436
  "cascadingFallbackHint": "Automatically route queries to alternative providers when all accounts of a primary provider are exhausted.",
437
437
  "cascadingFallbackOrder": "Fallback Provider Order",
438
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.",
439
446
  "recentErrors": "Recent Runtime Errors",
440
447
  "recentErrorsDesc": "Last 20 runtime failures across model streams and providers (sanitized).",
441
448
  "recentErrorsClear": "Clear Log",
@@ -719,6 +726,13 @@ const cssId = 'dsh-subscriptions/settings.module.css'
719
726
  "cascadingFallbackHint": "当首选服务商所有账户配额耗尽或限流时,自动无缝转接至备选服务商。",
720
727
  "cascadingFallbackOrder": "故障转移供应商顺序",
721
728
  "cascadingFallbackOrderHint": "首选服务商耗尽时按此顺序依次尝试备选服务商。",
729
+ "rotationStrategy": "账户负载均衡策略",
730
+ "rotationStrategyHint": "在同一服务商的多个可用账户之间分发请求的方式。",
731
+ "rotationRoundRobin": "轮询均衡(标准交替)",
732
+ "rotationLeastLoaded": "最少负载优先(配额公平共享)",
733
+ "rotationPriority": "优先级顺序(按序号/权重顺序)",
734
+ "softFailureSentinel": "软性拒绝与容量哨兵",
735
+ "softFailureSentinelHint": "自动识别 HTTP 200 状态下的服务商容量道歉并无缝转接至下一个正常账户。",
722
736
  "recentErrors": "最近运行错误记录",
723
737
  "recentErrorsDesc": "最近 20 条流式传输与 API 调用失败记录(已脱敏)。",
724
738
  "recentErrorsClear": "清空记录",
@@ -2158,6 +2172,33 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2158
2172
  )
2159
2173
  ) : null,
2160
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')),
2201
+ ),
2161
2202
  ),
2162
2203
  ),
2163
2204
  React.createElement('div', { className: 'dsub-block' },
@@ -110,6 +110,10 @@ export const Config = z.object({
110
110
  .description('#349 Opt-in cross-vendor fallback when all accounts of a provider are exhausted or rate limited.'),
111
111
  cascadingFallbackOrder: z.array(z.string()).volatile().default(PROVIDERS)
112
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.'),
113
117
  cascadingChain: z.any().volatile().default({})
114
118
  .description('#349 Custom cross-vendor fallback chains (e.g. { claude: ["copilot", "cursor"] }).'),
115
119
  webhookUrl: z.string().volatile().default('')
@@ -83,6 +83,8 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
83
83
  proxySlots: slots.filter((s) => s.proxyUrl).length,
84
84
  cascadingFallback: !!cfg.cascadingFallback,
85
85
  cascadingFallbackOrder: cfg.cascadingFallbackOrder || PROVIDERS,
86
+ rotationStrategy: cfg.rotationStrategy || 'round-robin',
87
+ enableSoftFailureSentinel: cfg.enableSoftFailureSentinel !== false,
86
88
  },
87
89
  })
88
90
  }
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
  }
@@ -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,10 +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
7
  import { defaultErrorBuffer } from './error-buffer.js'
8
+ import { withSoftFailureSentinel } from './sentinel.js'
9
+ import { resolveAccurateReset } from './reset-clock.js'
8
10
 
9
11
  export async function* streamWithRotation({
10
12
  accounts,
@@ -55,6 +57,7 @@ export async function* streamWithRotation({
55
57
  autoPacing: options && (options.autoPacing !== undefined ? options.autoPacing : (options.config && options.config.autoPacing)),
56
58
  drainBeforeReset: options && (options.drainBeforeReset !== undefined ? options.drainBeforeReset : (options.config && options.config.drainBeforeReset)),
57
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)),
58
61
  })
59
62
  const account = pickRes.account
60
63
 
@@ -147,7 +150,10 @@ export async function* streamWithRotation({
147
150
  streamOnce(account, { ...options, _runawayState: runawayState }),
148
151
  runawayOpts
149
152
  )
150
- 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) {
151
157
  firstChunkDelivered = true
152
158
  const cachedTokens = Number(
153
159
  (chunk && chunk.usage && (chunk.usage.cacheReadTokens || chunk.usage.cachedTokens)) ||
@@ -195,18 +201,24 @@ export async function* streamWithRotation({
195
201
  currentAttempt.status = 'switched'
196
202
  currentAttempt.switchError = err ? (err.code || err.status || err.message) : 'switchable'
197
203
 
198
- // Move slot to cooldown and quarantine (#172)
199
- 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)
200
210
  account.cooldownUntil = cooled.cooldownUntil
201
211
  if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
202
212
 
203
213
  const status = Number(err && (err.status || err.statusCode) || 0)
204
214
  const code = String(err && err.code || '')
205
- const reason = (status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
206
- ? REASON_REVOKED
207
- : (status === 403 || code === 'HARD_LIMIT' || code === 'LICENSE_REQUIRED')
208
- ? REASON_HARD_LIMIT
209
- : 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
210
222
 
211
223
  if (err && err.isWarmupProbeFailure && err.warmupAccount) {
212
224
  account.quarantineUntil = err.warmupAccount.quarantineUntil
@@ -214,7 +226,7 @@ export async function* streamWithRotation({
214
226
  account.quarantineAttempts = err.warmupAccount.quarantineAttempts
215
227
  account.status = err.warmupAccount.status
216
228
  } else {
217
- const quarantined = putInQuarantine(account, reason, nowMs())
229
+ const quarantined = putInQuarantine(account, reason, nowMs(), accurateUntil)
218
230
  account.quarantineUntil = quarantined.quarantineUntil
219
231
  account.quarantineReason = quarantined.quarantineReason
220
232
  account.quarantineAttempts = (account.quarantineAttempts || 0) + 1
@@ -222,6 +234,10 @@ export async function* streamWithRotation({
222
234
 
223
235
  currentAttempt.quarantineReason = account.quarantineReason || reason
224
236
  currentAttempt.quarantineUntil = account.quarantineUntil
237
+ if (accurateUntil) {
238
+ currentAttempt.accurateReset = true
239
+ currentAttempt.resetAt = accurateUntil
240
+ }
225
241
 
226
242
  try {
227
243
  notifyQuarantineEntered({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.70",
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",