@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 +7 -0
- package/README.ru.md +7 -0
- package/README.zh.md +7 -0
- package/lib/adapter-manager.js +3 -1
- package/lib/adapter.js +8 -0
- package/lib/cascade.js +27 -2
- package/lib/client.js +184 -4
- package/lib/config-schema.js +6 -0
- package/lib/diagnostics.js +8 -1
- package/lib/error-buffer.js +49 -0
- package/lib/index.js +36 -27
- package/lib/quarantine.js +6 -2
- package/lib/reset-clock.js +87 -0
- package/lib/rotate.js +34 -3
- package/lib/routes/status.js +43 -6
- package/lib/sentinel.js +136 -0
- package/lib/stream-rotate.js +35 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -207,6 +207,13 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
207
207
|
* **In-App Toast Alerts (`enableToasts`)**: Lightweight floating toast alerts in the DSH chat interface notifying you when accounts enter quarantine, recover from cooldowns, or exceed quota safety thresholds.
|
|
208
208
|
* **Automated Webhook Integration**: Instant event notifications dispatched via `notifyQuarantineEntered` and `notifyQuarantineReleased`.
|
|
209
209
|
|
|
210
|
+
### 24. 🛡️ Resilience, Diagnostics & Fallback Enhancements (v0.6.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`。
|
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),
|
|
@@ -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
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Resolves the next candidate fallback provider from the cascade chain.
|
|
34
|
+
* @param {string} currentProvider - currently failing provider
|
|
35
|
+
* @param {Set<string>|Array<string>} availableProviders - providers that currently have logged-in accounts
|
|
36
|
+
* @param {Record<string, string[]>} [customChains] - user-configured custom chains
|
|
37
|
+
* @param {Set<string>} [visited] - set of already attempted providers in this request cascade
|
|
38
|
+
* @param {string[]} [globalOrder] - configured global fallback priority sequence
|
|
39
|
+
* @returns {string|null} - the next candidate provider or null if none available
|
|
40
|
+
*/
|
|
41
|
+
export function resolveFallbackVendor(currentProvider, availableProviders, customChains = {}, visited = new Set(), globalOrder = []) {
|
|
33
42
|
const availSet = new Set(availableProviders || [])
|
|
34
|
-
const
|
|
43
|
+
const custom = customChains && customChains[currentProvider]
|
|
44
|
+
if (Array.isArray(custom) && custom.length > 0) {
|
|
45
|
+
for (const candidate of custom) {
|
|
46
|
+
if (candidate !== currentProvider && !visited.has(candidate) && availSet.has(candidate)) {
|
|
47
|
+
return candidate
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (Array.isArray(globalOrder) && globalOrder.length > 0) {
|
|
53
|
+
for (const candidate of globalOrder) {
|
|
54
|
+
if (candidate !== currentProvider && !visited.has(candidate) && availSet.has(candidate)) {
|
|
55
|
+
return candidate
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
35
59
|
|
|
60
|
+
const chain = DEFAULT_CASCADE_CHAINS[currentProvider] || []
|
|
36
61
|
for (const candidate of chain) {
|
|
37
62
|
if (candidate !== currentProvider && !visited.has(candidate) && availSet.has(candidate)) {
|
|
38
63
|
return candidate
|
package/lib/client.js
CHANGED
|
@@ -430,6 +430,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())
|
|
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('
|
|
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
|
-
|
|
3161
|
-
|
|
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') }
|
package/lib/config-schema.js
CHANGED
|
@@ -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('')
|
package/lib/diagnostics.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getSessionPinStats } from './session-pin.js'
|
|
|
6
6
|
import { maskText } from './mask.js'
|
|
7
7
|
import { normalizeSlots } from './accounts.js'
|
|
8
8
|
import { PROVIDERS } from './refs.js'
|
|
9
|
+
import { defaultErrorBuffer } from './error-buffer.js'
|
|
9
10
|
|
|
10
11
|
function scrubReport(v) {
|
|
11
12
|
if (typeof v === 'string') return maskText(v)
|
|
@@ -18,7 +19,7 @@ function scrubReport(v) {
|
|
|
18
19
|
return v
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export async function createDiagnosticsReport({ live, store, history, NS, pkgVersion }) {
|
|
22
|
+
export async function createDiagnosticsReport({ live, store, history, NS, pkgVersion, heartbeatStats }) {
|
|
22
23
|
const cfg = live()
|
|
23
24
|
const slots = normalizeSlots(cfg.slots)
|
|
24
25
|
const mk = () => ({ loggedIn: false, slots: 0, configured: 0, cooldown: 0, proxy: 0, maxUsagePercent: null })
|
|
@@ -51,6 +52,8 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
|
|
|
51
52
|
runtime: { node: process.version, platform: process.platform, arch: process.arch, uptimeSec: Math.round(process.uptime()) },
|
|
52
53
|
providers,
|
|
53
54
|
requests: { total: rows.length, byStatus, lastErrors },
|
|
55
|
+
recentErrors: defaultErrorBuffer.getEntries(),
|
|
56
|
+
heartbeat: heartbeatStats || { lastRunAt: null, proactiveRefreshCount: 0 },
|
|
54
57
|
cache: calculateCacheMetrics(rows),
|
|
55
58
|
queue: defaultConcurrencyQueue.stats(),
|
|
56
59
|
pins: getSessionPinStats(),
|
|
@@ -78,6 +81,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
|
|
419
|
+
const heartbeatStats = { lastRunAt: 0, proactiveRefreshCount: 0 }
|
|
420
|
+
const runProactiveHeartbeat = async () => {
|
|
421
|
+
heartbeatStats.lastRunAt = Date.now()
|
|
422
|
+
const cfg = live()
|
|
423
|
+
const ahead = Number(cfg.refreshAheadMs) || 5 * 60 * 1000
|
|
424
|
+
const retryMs = Number(cfg.refreshRetryMs) || 10 * 60 * 1000
|
|
425
|
+
const now = Date.now()
|
|
426
|
+
let refreshed = 0
|
|
427
|
+
for (const slot of normalizeSlots(cfg.slots)) {
|
|
428
|
+
const ref = slot.ref
|
|
429
|
+
try {
|
|
430
|
+
const info = await store.describeRef(ref)
|
|
431
|
+
if (!info.configured) continue
|
|
432
|
+
if (info.cooldownUntil && info.cooldownUntil > now) continue
|
|
433
|
+
const raw = await store.resolveRaw(ref)
|
|
434
|
+
if (!raw) continue
|
|
435
|
+
const blob = await store.loadBlob(ref).catch(() => null)
|
|
436
|
+
if (!blob || !blob.refreshToken) continue
|
|
437
|
+
if (!blob.expiresAt) continue
|
|
438
|
+
if (blob.expiresAt - now > ahead) continue
|
|
439
|
+
if (typeof store.shouldSkipRefresh === "function" && store.shouldSkipRefresh(ref, now, retryMs)) continue
|
|
440
|
+
await store.ensureFresh(slot.provider, blob, ref).then(() => {
|
|
441
|
+
refreshed++
|
|
442
|
+
heartbeatStats.proactiveRefreshCount++
|
|
443
|
+
}).catch((e) => {
|
|
444
|
+
if (logger && logger.warn) logger.warn("[dsh-subscriptions] background refresh failed for " + ref + ": " + String(e && e.message || e))
|
|
445
|
+
})
|
|
446
|
+
} catch { /* best-effort */ }
|
|
447
|
+
}
|
|
448
|
+
return refreshed
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const diagnosticsReport = () => createDiagnosticsReport({ live, store, history, NS, pkgVersion, heartbeatStats })
|
|
420
452
|
const accountsView = () => createAccountsView({ live, store, fetch, pmL })
|
|
421
453
|
|
|
422
454
|
// Ponytail: background refresh ahead of expiry, single timer
|
|
423
455
|
ctx.effect(() => {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const ahead = Number(cfg.refreshAheadMs) || 5 * 60 * 1000
|
|
427
|
-
const retryMs = Number(cfg.refreshRetryMs) || 10 * 60 * 1000
|
|
428
|
-
const now = Date.now()
|
|
429
|
-
for (const slot of normalizeSlots(cfg.slots)) {
|
|
430
|
-
const ref = slot.ref
|
|
431
|
-
try {
|
|
432
|
-
const info = await store.describeRef(ref)
|
|
433
|
-
if (!info.configured) continue
|
|
434
|
-
if (info.cooldownUntil && info.cooldownUntil > now) continue
|
|
435
|
-
const raw = await store.resolveRaw(ref)
|
|
436
|
-
if (!raw) continue
|
|
437
|
-
const blob = await store.loadBlob(ref).catch(() => null)
|
|
438
|
-
if (!blob || !blob.refreshToken) continue
|
|
439
|
-
if (!blob.expiresAt) continue
|
|
440
|
-
if (blob.expiresAt - now > ahead) continue
|
|
441
|
-
if (typeof store.shouldSkipRefresh === 'function' && store.shouldSkipRefresh(ref, now, retryMs)) continue
|
|
442
|
-
await store.ensureFresh(slot.provider, blob, ref).catch((e) => {
|
|
443
|
-
if (logger && logger.warn) logger.warn('[dsh-subscriptions] background refresh failed for ' + ref + ': ' + String(e && e.message || e))
|
|
444
|
-
})
|
|
445
|
-
} catch { /* best-effort */ }
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
tick().catch(() => {})
|
|
449
|
-
const id = setInterval(() => { tick().catch(() => {}) }, 60 * 1000)
|
|
456
|
+
runProactiveHeartbeat().catch(() => {})
|
|
457
|
+
const id = setInterval(() => { runProactiveHeartbeat().catch(() => {}) }, 60 * 1000)
|
|
450
458
|
return () => clearInterval(id)
|
|
451
459
|
}, 'dsh-subscriptions: refresh ahead')
|
|
452
460
|
|
|
@@ -509,6 +517,7 @@ export function apply(ctx, rawConfig) {
|
|
|
509
517
|
refForSlot,
|
|
510
518
|
resetCredits,
|
|
511
519
|
diagnosticsReport,
|
|
520
|
+
triggerHeartbeat: runProactiveHeartbeat,
|
|
512
521
|
pending,
|
|
513
522
|
completeOAuth,
|
|
514
523
|
fetchForRef,
|
package/lib/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/routes/status.js
CHANGED
|
@@ -19,6 +19,7 @@ import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson
|
|
|
19
19
|
import { quotaSnapshot } from '../ratelimit.js'
|
|
20
20
|
import { PROVIDERS, displayName, droppedCredentialRefs, isProvider, oauthRef } from '../refs.js'
|
|
21
21
|
import { getVendor } from '../vendors/index.js'
|
|
22
|
+
import { defaultErrorBuffer } from '../error-buffer.js'
|
|
22
23
|
|
|
23
24
|
export function registerStatusRoutes(ctx, state) {
|
|
24
25
|
const {
|
|
@@ -298,6 +299,40 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
298
299
|
}),
|
|
299
300
|
}), 'dsh-subscriptions: /diagnostics')
|
|
300
301
|
|
|
302
|
+
ctx.effect(() => ctx.webServer.register({
|
|
303
|
+
kind: 'exact',
|
|
304
|
+
path: '/dsh-subscriptions/diagnostics/errors/clear',
|
|
305
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
306
|
+
if (req.method !== 'POST') {
|
|
307
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
311
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
defaultErrorBuffer.clear()
|
|
315
|
+
writeJson(res, 200, { ok: true, cleared: true })
|
|
316
|
+
}),
|
|
317
|
+
}), 'dsh-subscriptions: /diagnostics/errors/clear')
|
|
318
|
+
|
|
319
|
+
ctx.effect(() => ctx.webServer.register({
|
|
320
|
+
kind: 'exact',
|
|
321
|
+
path: '/dsh-subscriptions/heartbeat/run',
|
|
322
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
323
|
+
if (req.method !== 'POST') {
|
|
324
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
328
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
const refreshedCount = typeof state.triggerHeartbeat === 'function' ? await state.triggerHeartbeat() : 0
|
|
332
|
+
writeJson(res, 200, { ok: true, refreshed: refreshedCount })
|
|
333
|
+
}),
|
|
334
|
+
}), 'dsh-subscriptions: /heartbeat/run')
|
|
335
|
+
|
|
301
336
|
|
|
302
337
|
// ponytail: cheap per-vendor probe; never sets cooldown, never returns tokens
|
|
303
338
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -345,10 +380,6 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
345
380
|
return
|
|
346
381
|
}
|
|
347
382
|
const vendor = getVendor(provider)
|
|
348
|
-
if (typeof vendor.check !== 'function') {
|
|
349
|
-
writeJson(res, 200, { ok: true, provider, index: index, ref, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: info.quota || null })
|
|
350
|
-
return
|
|
351
|
-
}
|
|
352
383
|
let capturedQuota = null
|
|
353
384
|
const probeFetch = async (url, init = {}) => {
|
|
354
385
|
const doFetch = (typeof fetchForRef === "function" && fetchForRef(ref)) || fetch
|
|
@@ -360,8 +391,14 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
360
391
|
return res2
|
|
361
392
|
}
|
|
362
393
|
try {
|
|
363
|
-
|
|
364
|
-
|
|
394
|
+
if (typeof vendor.check === 'function') {
|
|
395
|
+
await vendor.check(blob, vendorConfig(provider, live()), probeFetch)
|
|
396
|
+
} else if (typeof vendor.listModels === 'function') {
|
|
397
|
+
await vendor.listModels(blob, vendorConfig(provider, live()), probeFetch)
|
|
398
|
+
} else if (typeof vendor.usage === 'function') {
|
|
399
|
+
await vendor.usage(blob, vendorConfig(provider, live()), probeFetch)
|
|
400
|
+
}
|
|
401
|
+
const latencyMs = Date.now() - t0
|
|
365
402
|
writeJson(res, 200, { ok: true, provider, index: index, ref, latencyMs, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: capturedQuota || info.quota || null, usagePercent: info.usagePercent ?? null })
|
|
366
403
|
} catch (e) {
|
|
367
404
|
const isTimeout = isTimeoutError(e)
|
package/lib/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,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
|
-
|
|
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
|
-
|
|
190
|
-
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)
|
|
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 =
|
|
197
|
-
?
|
|
198
|
-
: (status ===
|
|
199
|
-
?
|
|
200
|
-
:
|
|
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.
|
|
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",
|