@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 +1 -1
- package/README.ru.md +1 -1
- package/README.zh.md +1 -1
- package/lib/adapter-manager.js +2 -0
- package/lib/adapter.js +8 -0
- package/lib/client.js +41 -0
- package/lib/config-schema.js +4 -0
- package/lib/diagnostics.js +2 -0
- package/lib/quarantine.js +6 -2
- package/lib/reset-clock.js +87 -0
- package/lib/rotate.js +34 -3
- package/lib/sentinel.js +136 -0
- package/lib/stream-rotate.js +26 -10
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
135
|
+
### 🛡️ 弹性容灾、故障诊断与跨服务商回退 (v0.6.71)
|
|
136
136
|
* **跨服务商级联故障转移与优先级排序 (#536)**: 支持自定义跨服务商回退顺序,可在 WebUI 中通过 ▲/▼ 交互调整优先级。
|
|
137
137
|
* **一键链路测试与延迟检测 (#536)**: 在账户列表或状态弹窗中一键发起探活并显示往返延迟。
|
|
138
138
|
* **冷却倒计时与就绪通知 (#536)**: 实时展示受限账户恢复倒计时,解除冷却时自动弹出就绪通知。
|
package/lib/adapter-manager.js
CHANGED
|
@@ -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' },
|
package/lib/config-schema.js
CHANGED
|
@@ -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('')
|
package/lib/diagnostics.js
CHANGED
|
@@ -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:
|
|
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:
|
|
374
|
+
cooldownUntil: until,
|
|
344
375
|
...(fams.length ? { cooldownFamilies: fams } : {}),
|
|
345
376
|
}
|
|
346
377
|
}
|
package/lib/sentinel.js
ADDED
|
@@ -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
|
+
}
|
package/lib/stream-rotate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
199
|
-
const
|
|
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 =
|
|
206
|
-
?
|
|
207
|
-
: (status ===
|
|
208
|
-
?
|
|
209
|
-
:
|
|
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.
|
|
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",
|