@goodandready/dsh-subscriptions 0.6.60 → 0.6.61
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 +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/lib/adapter-manager.js +14 -0
- package/lib/adapter.js +18 -6
- package/lib/client.js +98 -8
- package/lib/diagnostics.js +13 -0
- package/lib/history.js +17 -0
- package/lib/models-catalog.js +240 -0
- package/lib/rotate.js +115 -14
- package/lib/routes/status.js +32 -1
- package/lib/session-pin.js +4 -0
- package/lib/stream-rotate.js +68 -5
- package/lib/subscriptions.js +23 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,6 +98,8 @@ graph LR
|
|
|
98
98
|
* **Automatic 429 Failover**: When an account encounters a rate limit (`HTTP 429`, `RATE_LIMIT`, `QUOTA_EXCEEDED`), traffic instantly fails over to the next healthy account in the pool.
|
|
99
99
|
* **Preemptive Quota Switching (`switchAtRemaining`)**: Automatically rotates to the next account before hitting zero if the rate-limit window reset is imminent.
|
|
100
100
|
* **Dynamic Cooldown Calculation**: Parses upstream headers (`Retry-After`, `x-ratelimit-reset`, ISO dates, epoch timestamps) and auto-restores cooled-down accounts when their window resets.
|
|
101
|
+
* **Synchronous Decision Tracing & Routing Explanation (#517)**: Real-time decision traces capture every account selection, rotation step, failure reason, and cascade fallback without post-hoc reconstruction, exposed in WebUI telemetry and diagnostics.
|
|
102
|
+
* **Dynamic Model Catalog & Live Quota Visualizer (#507)**: Dynamic model catalog with pool-level health calculation (individual quarantine degrades rather than disables the pool), context window badges, capability tags (`vision`, `tools`, `reasoning`, `coding`, `fast`), and live reset countdowns.
|
|
101
103
|
|
|
102
104
|
---
|
|
103
105
|
|
package/README.ru.md
CHANGED
|
@@ -98,6 +98,8 @@ graph LR
|
|
|
98
98
|
* **Автоматическое переключение при 429**: при превышении лимита запросов трафик мгновенно переключается на следующий свободный аккаунт.
|
|
99
99
|
* **Упреждающее переключение квот (`switchAtRemaining`)**: смена аккаунта до падения в ошибку, если окно сброса близко.
|
|
100
100
|
* **Динамический расчёт Cooldown**: парсинг заголовков `Retry-After`, `x-ratelimit-reset`, дат ISO и миллисекундных меток с автоматическим возвратом остывших аккаунтов в строй.
|
|
101
|
+
* **Трассировка решений ротации и объяснение маршрутизации (#517)**: Синхронная фиксация причин выбора аккаунтов, отказов и каскадных переходов в реальном времени с кодами причин без ретроспективной реконструкции; визуализация в телеметрии настроек и диагностике.
|
|
102
|
+
* **Динамический каталог моделей с пуловой доступностью и квотами (#507)**: Обогащенная витрина моделей с расчетом здоровья пула (карантин одного аккаунта не блокирует модель при наличии живых), тегами возможностей (`vision`, `tools`, `reasoning`, `coding`, `fast`), размером контекста и таймером сброса квот.
|
|
101
103
|
|
|
102
104
|
---
|
|
103
105
|
|
package/README.zh.md
CHANGED
|
@@ -91,6 +91,8 @@ graph LR
|
|
|
91
91
|
### 多账号轮换与限流保护
|
|
92
92
|
* 每个供应商可挂多个账号(如 `CODEX_OAUTH_1`、`CODEX_OAUTH_2`)。
|
|
93
93
|
* 遇到 429/配额耗尽自动切换到下一个健康账号;支持按剩余量提前切换和动态冷却恢复。
|
|
94
|
+
* **轮换决策追踪与路由解释 (#517)**:实时同步记录账号选择、轮换切换、失败原因与级联回退的完整决策链路(无需事后推导),并在 WebUI 遥测与诊断报告中可视化展示。
|
|
95
|
+
* **动态模型目录与配额可用性 (#507)**:基于账号池计算模型综合可用性(单个账号隔离不影响具备可用账号的模型),展示上下文窗口、能力标签(视觉、工具、推理、编码、快速)及动态配额倒计时。
|
|
94
96
|
|
|
95
97
|
### 安全与无头登录
|
|
96
98
|
* OAuth 令牌绝不通过 HTTP API 返回,也不在 Web 界面渲染;仅存放于宿主加密凭据存储。
|
package/lib/adapter-manager.js
CHANGED
|
@@ -92,6 +92,20 @@ export function createAdapterManager({
|
|
|
92
92
|
method: 'POST',
|
|
93
93
|
status: 200,
|
|
94
94
|
kind: 'cascade',
|
|
95
|
+
decisionTrace: {
|
|
96
|
+
attempts: [
|
|
97
|
+
{
|
|
98
|
+
attempt: 1,
|
|
99
|
+
kind: 'cascade_fallback',
|
|
100
|
+
fromProvider: provider,
|
|
101
|
+
toProvider: nextVendor,
|
|
102
|
+
model: targetModel,
|
|
103
|
+
reason: 'primary_pool_exhausted',
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
finalOutcome: 'cascade_fallback',
|
|
107
|
+
chosenRef: String(nextVendor).toUpperCase() + '_CASCADE_FALLBACK',
|
|
108
|
+
},
|
|
95
109
|
})
|
|
96
110
|
} catch { /* ignore cascade history recording error */ }
|
|
97
111
|
}
|
package/lib/adapter.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inferModelCapabilities, isDeprecatedId } from './models-catalog.js'
|
|
1
2
|
import { fetchWithTimeout } from "./http.js"
|
|
2
3
|
import { LlmAdapter, LlmError, attributionHeaders } from '@deepseek-ai/dsh-llm'
|
|
3
4
|
import { displayName } from './refs.js'
|
|
@@ -10,10 +11,7 @@ import { quotaSnapshot, parseBody } from './ratelimit.js'
|
|
|
10
11
|
import { toTokenUsage } from './wire.js'
|
|
11
12
|
import { isSwitchableError } from './rotate.js'
|
|
12
13
|
|
|
13
|
-
// #94:
|
|
14
|
-
function isDeprecatedId(id) {
|
|
15
|
-
return /(^|[-_:])(test|preview|dev|alpha|beta|snapshot|experimental|legacy|deprecated)([-_:]|$)/i.test(id)
|
|
16
|
-
}
|
|
14
|
+
// #94: isDeprecatedId imported from ./models-catalog.js
|
|
17
15
|
|
|
18
16
|
function asLlmError(err) {
|
|
19
17
|
if (err instanceof LlmError) return err
|
|
@@ -74,8 +72,19 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
74
72
|
if (typeof this.deps.hideDeprecatedModels === 'function' && this.deps.hideDeprecatedModels()) {
|
|
75
73
|
models = models.filter((m) => !isDeprecatedId(String((m && m.id) || '')))
|
|
76
74
|
}
|
|
77
|
-
//
|
|
78
|
-
models = models.map((m) =>
|
|
75
|
+
// Enrich models with provider and confirmed capability tags (#507)
|
|
76
|
+
models = models.map((m) => {
|
|
77
|
+
if (!m) return m
|
|
78
|
+
const prov = (!m.provider || m.provider === "custom") ? provider : m.provider
|
|
79
|
+
const caps = inferModelCapabilities(prov, m.id)
|
|
80
|
+
return {
|
|
81
|
+
...m,
|
|
82
|
+
provider: prov,
|
|
83
|
+
contextWindow: m.contextWindow || caps.contextWindow,
|
|
84
|
+
contextLabel: caps.contextLabel,
|
|
85
|
+
capabilities: Array.isArray(m.capabilities) && m.capabilities.length ? m.capabilities : caps.capabilities,
|
|
86
|
+
}
|
|
87
|
+
})
|
|
79
88
|
return models
|
|
80
89
|
}
|
|
81
90
|
|
|
@@ -127,8 +136,10 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
127
136
|
const effectiveDrainSafetyPercent = options.drainSafetyPercent !== undefined
|
|
128
137
|
? options.drainSafetyPercent
|
|
129
138
|
: (typeof deps.drainSafetyPercent === 'function' ? deps.drainSafetyPercent() : (deps.drainSafetyPercent ?? 5))
|
|
139
|
+
const decisionTrace = {}
|
|
130
140
|
const streamOptions = {
|
|
131
141
|
...options,
|
|
142
|
+
_decisionTrace: decisionTrace,
|
|
132
143
|
autoPacing: effectiveAutoPacing,
|
|
133
144
|
drainBeforeReset: effectiveDrainBeforeReset,
|
|
134
145
|
drainSafetyPercent: effectiveDrainSafetyPercent,
|
|
@@ -218,6 +229,7 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
218
229
|
kind: 'stream',
|
|
219
230
|
...(outcome === 'error' && errorCaught ? { error: errorCaught.message || String(errorCaught) } : {}),
|
|
220
231
|
...(outcome === 'runaway' ? { reason: finishReason || (opts && opts._runawayState && opts._runawayState.reason) || 'runaway_loop_detected' } : {}),
|
|
232
|
+
...(decisionTrace && decisionTrace.attempts && decisionTrace.attempts.length ? { decisionTrace } : {}),
|
|
221
233
|
})
|
|
222
234
|
} catch { /* ignore quota capture failure */ }
|
|
223
235
|
}
|
package/lib/client.js
CHANGED
|
@@ -130,6 +130,15 @@ window.__ModuleLoader__.load({
|
|
|
130
130
|
'.dsub-model-chip{display:inline-flex;align-items:center;gap:6px;padding:3px 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);font-size:11.5px;color:var(--dsw-alias-label-primary)}' +
|
|
131
131
|
'.dsub-model-chip code{font-family:ui-monospace,monospace;font-weight:600}' +
|
|
132
132
|
'.dsub-model-tag{font-size:10px;padding:1px 4px;border-radius:3px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-weight:600}' +
|
|
133
|
+
'.dsub-trace-box{padding:8px 12px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);margin-top:8px;font-size:12px}' +
|
|
134
|
+
'.dsub-trace-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l3)}' +
|
|
135
|
+
'.dsub-trace-row:last-child{border-bottom:none}' +
|
|
136
|
+
'.dsub-model-status-available{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent)}' +
|
|
137
|
+
'.dsub-model-status-degraded{color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent)}' +
|
|
138
|
+
'.dsub-model-status-quarantined{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}' +
|
|
139
|
+
'.dsub-model-status-cooldown{color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent)}' +
|
|
140
|
+
'.dsub-model-status-exhausted{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}' +
|
|
141
|
+
'.dsub-model-status-unconfigured{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-3)}' +
|
|
133
142
|
'.dsub-heroBars{display:flex;flex-direction:column;gap:8px;margin-top:10px}' +
|
|
134
143
|
'.dsub-barLabelRow{display:flex;justify-content:space-between;font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-bottom:3px}' +
|
|
135
144
|
'.dsub-barTrack{height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}' +
|
|
@@ -408,6 +417,17 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
408
417
|
"smokeFail": "Smoke test failed",
|
|
409
418
|
"modelsCatalogTitle": "Curated Subscription Models",
|
|
410
419
|
"modelsCatalogDesc": "Available model catalog across active subscriptions in DeepSeek Harness.",
|
|
420
|
+
"rotationDecisionsTitle": "Rotation & Routing Decisions",
|
|
421
|
+
"rotationDecisionsDesc": "Real-time decision trace explaining account selection, skips, and fallback reasons.",
|
|
422
|
+
"rotationAttempt": "attempt",
|
|
423
|
+
"rotationAttempts": "attempts",
|
|
424
|
+
"rotationCascaded": "Cascaded fallback",
|
|
425
|
+
"modelPoolAvailable": "Available",
|
|
426
|
+
"modelPoolDegraded": "Degraded",
|
|
427
|
+
"modelPoolQuarantined": "Quarantined",
|
|
428
|
+
"modelPoolCooldown": "Cooldown",
|
|
429
|
+
"modelPoolExhausted": "Exhausted",
|
|
430
|
+
"modelPoolUnconfigured": "Unconfigured",
|
|
411
431
|
"resetCredits": "Reset credits",
|
|
412
432
|
"resetCreditsHint": "ChatGPT reset cards: emergency reset for the 5-hour window. Requires a 5s confirmation.",
|
|
413
433
|
"resetCreditsAction": "Reset ChatGPT Limit",
|
|
@@ -619,6 +639,17 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
619
639
|
"smokeFail": "冒烟测试失败",
|
|
620
640
|
"modelsCatalogTitle": "精选订阅模型",
|
|
621
641
|
"modelsCatalogDesc": "当前 DeepSeek Harness 活跃订阅下可用模型一览。",
|
|
642
|
+
"rotationDecisionsTitle": "轮询调度与路由决策",
|
|
643
|
+
"rotationDecisionsDesc": "实时决策追踪,透明展示每次请求的账号选择、跳过与回退原因。",
|
|
644
|
+
"rotationAttempt": "次尝试",
|
|
645
|
+
"rotationAttempts": "次尝试",
|
|
646
|
+
"rotationCascaded": "级联回退",
|
|
647
|
+
"modelPoolAvailable": "就绪可用",
|
|
648
|
+
"modelPoolDegraded": "降级运行",
|
|
649
|
+
"modelPoolQuarantined": "已隔离",
|
|
650
|
+
"modelPoolCooldown": "冷却中",
|
|
651
|
+
"modelPoolExhausted": "额度耗尽",
|
|
652
|
+
"modelPoolUnconfigured": "未配置",
|
|
622
653
|
"resetCredits": "重置 ChatGPT 额度",
|
|
623
654
|
"resetCreditsHint": "ChatGPT 重置卡:紧急重置 5 小时额度窗口。扣除前需等待 5 秒倒计时确认。",
|
|
624
655
|
"resetCreditsAction": "重置 ChatGPT 限制",
|
|
@@ -878,6 +909,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
878
909
|
const [draft, setDraft] = React.useState(null)
|
|
879
910
|
const [accounts, setAccounts] = React.useState([])
|
|
880
911
|
const [providers, setProviders] = React.useState([])
|
|
912
|
+
const [modelsCatalog, setModelsCatalog] = React.useState([])
|
|
881
913
|
const [paste, setPaste] = React.useState({})
|
|
882
914
|
const [device, setDevice] = React.useState({}) // #90 key -> {state,userCode,authUrl,intervalMs,status}
|
|
883
915
|
const [proxyRes, setProxyRes] = React.useState({}) // #88 key -> {ok,latencyMs,error}
|
|
@@ -1115,6 +1147,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1115
1147
|
setDraft(JSON.parse(JSON.stringify((data && data.config) || {})))
|
|
1116
1148
|
setAccounts((data && data.accounts) || [])
|
|
1117
1149
|
setProviders((data && data.providers) || [])
|
|
1150
|
+
setModelsCatalog((data && data.modelsCatalog) || [])
|
|
1118
1151
|
}
|
|
1119
1152
|
|
|
1120
1153
|
const reload = () => {
|
|
@@ -1473,6 +1506,26 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1473
1506
|
React.createElement('div', { className: 'dsub-stat-lbl' }, t('statsLastActivity')),
|
|
1474
1507
|
),
|
|
1475
1508
|
) : null,
|
|
1509
|
+
telemetry && Array.isArray(telemetry.recentDecisions) && telemetry.recentDecisions.length ? React.createElement('details', { className: 'dsub-group', style: { marginBottom: '14px' } },
|
|
1510
|
+
React.createElement('summary', { className: 'dsub-groupHead' }, '🧭 ' + t('rotationDecisionsTitle')),
|
|
1511
|
+
React.createElement('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('rotationDecisionsDesc')),
|
|
1512
|
+
React.createElement('div', { className: 'dsub-trace-box' },
|
|
1513
|
+
telemetry.recentDecisions.map((dec, idx) => {
|
|
1514
|
+
const reasonBadge = dec.cascaded ? ('🔀 ' + t('rotationCascaded')) : (dec.decisionReason === 'session_pinned' ? '📌 Pinned' : ('⚡ ' + dec.decisionReason))
|
|
1515
|
+
return React.createElement('div', { key: idx, className: 'dsub-trace-row' },
|
|
1516
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
|
|
1517
|
+
dec.provider ? React.createElement('span', { className: 'dsub-brand dsub-brand-' + dec.provider, style: { fontSize: '9px', padding: '1px 4px' } }, dec.provider.toUpperCase()) : null,
|
|
1518
|
+
React.createElement('span', { style: { fontWeight: 600 } }, dec.ref || '—'),
|
|
1519
|
+
dec.model ? React.createElement('code', { style: { fontSize: '11px', opacity: 0.8 } }, dec.model) : null,
|
|
1520
|
+
),
|
|
1521
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
|
|
1522
|
+
React.createElement('span', { className: 'dsub-model-tag' }, reasonBadge),
|
|
1523
|
+
React.createElement('span', { style: { fontSize: '10.5px', opacity: 0.6 } }, dec.attemptsCount > 1 ? (dec.attemptsCount + ' ' + t('rotationAttempts')) : ('1 ' + t('rotationAttempt'))),
|
|
1524
|
+
),
|
|
1525
|
+
)
|
|
1526
|
+
})
|
|
1527
|
+
),
|
|
1528
|
+
) : null,
|
|
1476
1529
|
accounts && accounts.length ? React.createElement('details', { className: 'dsub-group', open: true, style: { marginBottom: '14px' } },
|
|
1477
1530
|
React.createElement('summary', { className: 'dsub-groupHead' }, '📊 ' + (t('healthMatrix') || 'Health Matrix & Quota Runway')),
|
|
1478
1531
|
React.createElement('div', { style: { padding: '8px 0', overflowX: 'auto' } },
|
|
@@ -1648,14 +1701,51 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1648
1701
|
React.createElement('summary', { className: 'dsub-groupHead' }, '🎯 ' + t('modelsCatalogTitle')),
|
|
1649
1702
|
React.createElement('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('modelsCatalogDesc')),
|
|
1650
1703
|
React.createElement('div', { className: 'dsub-model-grid' },
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1704
|
+
(Array.isArray(modelsCatalog) && modelsCatalog.length ? modelsCatalog : [
|
|
1705
|
+
{ id: 'gpt-4o', contextLabel: '128k', capabilities: ['vision'], pool: { status: 'available' } },
|
|
1706
|
+
{ id: 'o1 / o3-mini', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: 'available' } },
|
|
1707
|
+
{ id: 'claude-3-7-sonnet', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: 'available' } },
|
|
1708
|
+
{ id: 'claude-3-5-sonnet', contextLabel: '200k', capabilities: ['coding'], pool: { status: 'available' } },
|
|
1709
|
+
{ id: 'grok-beta', contextLabel: '128k', capabilities: ['fast'], pool: { status: 'available' } },
|
|
1710
|
+
{ id: 'gemini-2.5-pro', contextLabel: '1M', capabilities: ['vision'], pool: { status: 'available' } },
|
|
1711
|
+
{ id: 'kimi-k1.5', contextLabel: '128k', capabilities: ['math'], pool: { status: 'available' } },
|
|
1712
|
+
{ id: 'glm-4-plus', contextLabel: '128k', capabilities: ['chinese'], pool: { status: 'available' } },
|
|
1713
|
+
]).map((m, idx) => {
|
|
1714
|
+
const poolStatus = m.pool ? m.pool.status : 'unconfigured'
|
|
1715
|
+
const statusClass = 'dsub-model-status-' + poolStatus
|
|
1716
|
+
let poolLabel = ''
|
|
1717
|
+
if (poolStatus === 'available') {
|
|
1718
|
+
poolLabel = '🟢 ' + (m.pool && m.pool.remainingPercent != null ? (m.pool.remainingPercent + '%') : t('modelPoolAvailable'))
|
|
1719
|
+
} else if (poolStatus === 'degraded') {
|
|
1720
|
+
poolLabel = '🟡 ' + t('modelPoolDegraded') + (m.pool ? ' (' + m.pool.healthyAccounts + '/' + m.pool.totalAccounts + ')' : '')
|
|
1721
|
+
} else if (poolStatus === 'quarantined') {
|
|
1722
|
+
poolLabel = '⛔ ' + t('modelPoolQuarantined')
|
|
1723
|
+
} else if (poolStatus === 'cooldown') {
|
|
1724
|
+
poolLabel = '❄️ ' + t('modelPoolCooldown')
|
|
1725
|
+
} else if (poolStatus === 'exhausted') {
|
|
1726
|
+
poolLabel = '⚠️ ' + t('modelPoolExhausted')
|
|
1727
|
+
} else {
|
|
1728
|
+
poolLabel = '⚪ ' + t('modelPoolUnconfigured')
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
const tags = []
|
|
1732
|
+
if (m.contextLabel) tags.push(m.contextLabel)
|
|
1733
|
+
if (Array.isArray(m.capabilities)) {
|
|
1734
|
+
for (const c of m.capabilities) {
|
|
1735
|
+
if (c === 'vision') tags.push('Vision')
|
|
1736
|
+
else if (c === 'reasoning') tags.push('Reasoning')
|
|
1737
|
+
else if (c === 'coding') tags.push('Coding')
|
|
1738
|
+
else if (c === 'fast') tags.push('Fast')
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
return React.createElement('div', { key: (m.id || idx) + '_' + idx, className: 'dsub-model-chip' },
|
|
1743
|
+
m.provider ? React.createElement('span', { className: 'dsub-brand dsub-brand-' + m.provider, style: { fontSize: '9px', padding: '1px 4px' } }, m.provider.toUpperCase()) : null,
|
|
1744
|
+
React.createElement('code', null, m.name || m.id),
|
|
1745
|
+
tags.length ? React.createElement('span', { className: 'dsub-model-tag' }, tags.join(' · ')) : null,
|
|
1746
|
+
React.createElement('span', { className: 'dsub-model-tag ' + statusClass }, poolLabel),
|
|
1747
|
+
)
|
|
1748
|
+
})
|
|
1659
1749
|
),
|
|
1660
1750
|
),
|
|
1661
1751
|
React.createElement('details', { className: 'dsub-details' },
|
package/lib/diagnostics.js
CHANGED
|
@@ -46,6 +46,19 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
|
|
|
46
46
|
runtime: { node: process.version, platform: process.platform, arch: process.arch, uptimeSec: Math.round(process.uptime()) },
|
|
47
47
|
providers,
|
|
48
48
|
requests: { total: rows.length, byStatus, lastErrors },
|
|
49
|
+
decisions: {
|
|
50
|
+
recent: rows.filter((r) => r && r.decisionTrace).slice(0, 10).map((r) => ({
|
|
51
|
+
ts: r.ts,
|
|
52
|
+
provider: r.provider,
|
|
53
|
+
ref: r.ref,
|
|
54
|
+
outcome: r.decisionTrace.finalOutcome || 'success',
|
|
55
|
+
attempts: (r.decisionTrace.attempts || []).map((a) => ({
|
|
56
|
+
ref: a.ref,
|
|
57
|
+
status: a.status,
|
|
58
|
+
decision: a.decisionReason || a.kind,
|
|
59
|
+
})),
|
|
60
|
+
})),
|
|
61
|
+
},
|
|
49
62
|
settings: {
|
|
50
63
|
cooldownMs: cfg.cooldownMs,
|
|
51
64
|
switchAtRemaining: cfg.switchAtRemaining,
|
package/lib/history.js
CHANGED
|
@@ -261,6 +261,22 @@ export class HistoryStore {
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
const first = this.rows[0] // newest first
|
|
264
|
+
const recentDecisions = []
|
|
265
|
+
for (const r of this.rows) {
|
|
266
|
+
if (r && r.decisionTrace) {
|
|
267
|
+
recentDecisions.push({
|
|
268
|
+
ts: r.ts,
|
|
269
|
+
provider: r.provider,
|
|
270
|
+
ref: r.ref,
|
|
271
|
+
model: r.model || null,
|
|
272
|
+
decisionReason: r.decisionTrace.finalOutcome || (r.decisionTrace.attempts && r.decisionTrace.attempts[0] && r.decisionTrace.attempts[0].decisionReason) || 'selected',
|
|
273
|
+
attemptsCount: Array.isArray(r.decisionTrace.attempts) ? r.decisionTrace.attempts.length : 1,
|
|
274
|
+
cascaded: r.decisionTrace.finalOutcome === 'cascade_fallback' || r.kind === 'cascade',
|
|
275
|
+
})
|
|
276
|
+
if (recentDecisions.length >= 10) break
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
264
280
|
return {
|
|
265
281
|
totalRequests: total,
|
|
266
282
|
requests24h: count24h,
|
|
@@ -279,6 +295,7 @@ export class HistoryStore {
|
|
|
279
295
|
? Math.round((totalCacheReadTokens / totalPromptTokens) * 1000) / 10
|
|
280
296
|
: 0,
|
|
281
297
|
accounts: this.accountsTelemetry(),
|
|
298
|
+
recentDecisions,
|
|
282
299
|
}
|
|
283
300
|
}
|
|
284
301
|
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { PROVIDERS, displayName } from './refs.js'
|
|
2
|
+
|
|
3
|
+
export function isDeprecatedId(id) {
|
|
4
|
+
return /(^|[-_:])(test|preview|dev|alpha|beta|snapshot|experimental|legacy|deprecated)([-_:]|$)/i.test(String(id || ''))
|
|
5
|
+
}
|
|
6
|
+
import { getVendor } from './vendors/index.js'
|
|
7
|
+
import { getAccountRemainingPercent } from './rotate.js'
|
|
8
|
+
|
|
9
|
+
export const CAPABILITY_TAGS = {
|
|
10
|
+
VISION: 'vision',
|
|
11
|
+
TOOLS: 'tools',
|
|
12
|
+
REASONING: 'reasoning',
|
|
13
|
+
CODING: 'coding',
|
|
14
|
+
FAST: 'fast',
|
|
15
|
+
MATH: 'math',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Infer context window and capability tags for a given provider and model.
|
|
20
|
+
* @param {string} provider
|
|
21
|
+
* @param {string} modelId
|
|
22
|
+
* @returns {{ contextWindow: number, contextLabel: string, capabilities: string[] }}
|
|
23
|
+
*/
|
|
24
|
+
export function inferModelCapabilities(provider, modelId) {
|
|
25
|
+
const id = String(modelId || '').toLowerCase()
|
|
26
|
+
const p = String(provider || '').toLowerCase()
|
|
27
|
+
const caps = new Set([CAPABILITY_TAGS.TOOLS])
|
|
28
|
+
let contextWindow = 128000
|
|
29
|
+
let contextLabel = '128k'
|
|
30
|
+
|
|
31
|
+
// Vision detection
|
|
32
|
+
if (
|
|
33
|
+
id.includes('vision') ||
|
|
34
|
+
id.includes('gpt-4o') ||
|
|
35
|
+
id.includes('gpt-5') ||
|
|
36
|
+
id.includes('gpt-6') ||
|
|
37
|
+
id.includes('claude') ||
|
|
38
|
+
id.includes('gemini') ||
|
|
39
|
+
id.includes('grok-2') ||
|
|
40
|
+
id.includes('grok-3')
|
|
41
|
+
) {
|
|
42
|
+
caps.add(CAPABILITY_TAGS.VISION)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Reasoning detection
|
|
46
|
+
if (
|
|
47
|
+
id.includes('thinking') ||
|
|
48
|
+
id.includes('reasoning') ||
|
|
49
|
+
id.includes('o1') ||
|
|
50
|
+
id.includes('o3') ||
|
|
51
|
+
id.includes('gpt-5') ||
|
|
52
|
+
id.includes('gpt-6') ||
|
|
53
|
+
id.includes('claude-opus') ||
|
|
54
|
+
id.includes('claude-sonnet-5') ||
|
|
55
|
+
id.includes('claude-fable') ||
|
|
56
|
+
id.includes('claude-3-7') ||
|
|
57
|
+
id.includes('k1.5') ||
|
|
58
|
+
id.includes('r1')
|
|
59
|
+
) {
|
|
60
|
+
caps.add(CAPABILITY_TAGS.REASONING)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Coding detection
|
|
64
|
+
if (
|
|
65
|
+
id.includes('codex') ||
|
|
66
|
+
id.includes('coder') ||
|
|
67
|
+
id.includes('coding') ||
|
|
68
|
+
id.includes('sonnet') ||
|
|
69
|
+
id.includes('gpt-5') ||
|
|
70
|
+
id.includes('gpt-6') ||
|
|
71
|
+
id.includes('sol') ||
|
|
72
|
+
id.includes('terra') ||
|
|
73
|
+
id.includes('luna') ||
|
|
74
|
+
p === 'codex' ||
|
|
75
|
+
p === 'copilot' ||
|
|
76
|
+
p === 'cursor'
|
|
77
|
+
) {
|
|
78
|
+
caps.add(CAPABILITY_TAGS.CODING)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fast / Speed detection
|
|
82
|
+
if (id.includes('flash') || id.includes('mini') || id.includes('haiku') || id.includes('small') || id.includes('fast')) {
|
|
83
|
+
caps.add(CAPABILITY_TAGS.FAST)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Context window mapping
|
|
87
|
+
if (p === 'antigravity' && (id.includes('gemini-2.5') || id.includes('gemini-3'))) {
|
|
88
|
+
contextWindow = id.includes('pro') ? 2000000 : 1000000
|
|
89
|
+
contextLabel = id.includes('pro') ? '2M' : '1M'
|
|
90
|
+
} else if (id.includes('grok-3') || id.includes('1m')) {
|
|
91
|
+
contextWindow = 1000000
|
|
92
|
+
contextLabel = '1M'
|
|
93
|
+
} else if (
|
|
94
|
+
id.includes('claude-') ||
|
|
95
|
+
id.includes('o1') ||
|
|
96
|
+
id.includes('o3') ||
|
|
97
|
+
id.includes('gpt-5') ||
|
|
98
|
+
id.includes('gpt-6') ||
|
|
99
|
+
id.includes('kimi') ||
|
|
100
|
+
id.includes('200k')
|
|
101
|
+
) {
|
|
102
|
+
contextWindow = 200000
|
|
103
|
+
contextLabel = '200k'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
contextWindow,
|
|
108
|
+
contextLabel,
|
|
109
|
+
capabilities: Array.from(caps),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Calculates pool-wide status and quota for a provider from the account list.
|
|
115
|
+
* @param {string} provider
|
|
116
|
+
* @param {Array<object>} accounts
|
|
117
|
+
* @param {number} [nowMs]
|
|
118
|
+
* @returns {object}
|
|
119
|
+
*/
|
|
120
|
+
export function calculatePoolStatus(provider, accounts = [], nowMs = Date.now()) {
|
|
121
|
+
const p = String(provider || '').toLowerCase()
|
|
122
|
+
const matching = (accounts || []).filter((a) => a && String(a.provider).toLowerCase() === p)
|
|
123
|
+
|
|
124
|
+
if (!matching.length) {
|
|
125
|
+
return {
|
|
126
|
+
status: 'unconfigured',
|
|
127
|
+
totalAccounts: 0,
|
|
128
|
+
healthyAccounts: 0,
|
|
129
|
+
quarantinedAccounts: 0,
|
|
130
|
+
cooldownAccounts: 0,
|
|
131
|
+
remainingPercent: null,
|
|
132
|
+
resetAt: null,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let healthyCount = 0
|
|
137
|
+
let quarantinedCount = 0
|
|
138
|
+
let cooldownCount = 0
|
|
139
|
+
let maxRemainingPercent = null
|
|
140
|
+
let nearestResetAt = null
|
|
141
|
+
|
|
142
|
+
for (const acc of matching) {
|
|
143
|
+
const isQuarantined = Boolean(acc.quarantineUntil && Number(acc.quarantineUntil) > nowMs)
|
|
144
|
+
const isCooldown = Boolean(acc.cooldownUntil && Number(acc.cooldownUntil) > nowMs)
|
|
145
|
+
const rem = getAccountRemainingPercent(acc)
|
|
146
|
+
const isExhausted = (rem !== null && rem <= 0) || (acc.usagePercent != null && Number(acc.usagePercent) >= 100)
|
|
147
|
+
const hasToken = Boolean(acc.configured && acc.writable !== false)
|
|
148
|
+
|
|
149
|
+
if (isQuarantined) quarantinedCount++
|
|
150
|
+
if (isCooldown) cooldownCount++
|
|
151
|
+
|
|
152
|
+
if (hasToken && !isQuarantined && !isCooldown && !isExhausted) {
|
|
153
|
+
healthyCount++
|
|
154
|
+
if (rem !== null) {
|
|
155
|
+
maxRemainingPercent = maxRemainingPercent === null ? rem : Math.max(maxRemainingPercent, rem)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const resetAt = acc.quota?.resetAt ? Number(acc.quota.resetAt) : null
|
|
160
|
+
if (resetAt && resetAt > nowMs) {
|
|
161
|
+
if (!nearestResetAt || resetAt < nearestResetAt) {
|
|
162
|
+
nearestResetAt = resetAt
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let status = 'available'
|
|
168
|
+
if (healthyCount === 0) {
|
|
169
|
+
if (quarantinedCount === matching.length) {
|
|
170
|
+
status = 'quarantined'
|
|
171
|
+
} else if (cooldownCount === matching.length) {
|
|
172
|
+
status = 'cooldown'
|
|
173
|
+
} else {
|
|
174
|
+
status = 'exhausted'
|
|
175
|
+
}
|
|
176
|
+
} else if (healthyCount < matching.length) {
|
|
177
|
+
status = 'degraded'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
status,
|
|
182
|
+
totalAccounts: matching.length,
|
|
183
|
+
healthyAccounts: healthyCount,
|
|
184
|
+
quarantinedAccounts: quarantinedCount,
|
|
185
|
+
cooldownAccounts: cooldownCount,
|
|
186
|
+
remainingPercent: maxRemainingPercent !== null ? Math.round(maxRemainingPercent) : null,
|
|
187
|
+
resetAt: nearestResetAt,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Builds the enriched model catalog across all supported subscription vendors.
|
|
193
|
+
* @param {object} options
|
|
194
|
+
* @param {Array<object>} options.accounts
|
|
195
|
+
* @param {object} [options.liveConfig]
|
|
196
|
+
* @param {boolean} [options.hideDeprecated]
|
|
197
|
+
* @param {number} [options.nowMs]
|
|
198
|
+
* @returns {Array<object>}
|
|
199
|
+
*/
|
|
200
|
+
export function buildEnrichedCatalog({
|
|
201
|
+
accounts = [],
|
|
202
|
+
liveConfig = {},
|
|
203
|
+
hideDeprecated = false,
|
|
204
|
+
nowMs = Date.now(),
|
|
205
|
+
} = {}) {
|
|
206
|
+
const result = []
|
|
207
|
+
|
|
208
|
+
for (const provId of PROVIDERS) {
|
|
209
|
+
const vendor = getVendor(provId)
|
|
210
|
+
const pool = calculatePoolStatus(provId, accounts, nowMs)
|
|
211
|
+
const modelsKey = `${provId}Models`
|
|
212
|
+
let rawList = liveConfig && Array.isArray(liveConfig[modelsKey]) && liveConfig[modelsKey].length
|
|
213
|
+
? liveConfig[modelsKey]
|
|
214
|
+
: (vendor.defaults ? vendor.defaults().models : [])
|
|
215
|
+
|
|
216
|
+
if (!Array.isArray(rawList)) rawList = []
|
|
217
|
+
|
|
218
|
+
for (const item of rawList) {
|
|
219
|
+
const modelId = typeof item === 'string' ? item : (item.id || item.slug || item.name)
|
|
220
|
+
if (!modelId) continue
|
|
221
|
+
if (hideDeprecated && isDeprecatedId(modelId)) continue
|
|
222
|
+
|
|
223
|
+
const modelName = (typeof item === 'object' && (item.name || item.display_name)) ? (item.name || item.display_name) : modelId
|
|
224
|
+
const caps = inferModelCapabilities(provId, modelId)
|
|
225
|
+
|
|
226
|
+
result.push({
|
|
227
|
+
id: modelId,
|
|
228
|
+
name: modelName,
|
|
229
|
+
provider: provId,
|
|
230
|
+
providerName: displayName(provId),
|
|
231
|
+
contextWindow: caps.contextWindow,
|
|
232
|
+
contextLabel: caps.contextLabel,
|
|
233
|
+
capabilities: caps.capabilities,
|
|
234
|
+
pool,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return result
|
|
240
|
+
}
|
package/lib/rotate.js
CHANGED
|
@@ -63,7 +63,7 @@ export function getAccountRemainingPercent(acc) {
|
|
|
63
63
|
return null
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
export function
|
|
66
|
+
export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
67
67
|
const list = Array.isArray(accounts) ? accounts : []
|
|
68
68
|
const now = Number(nowMs) || 0
|
|
69
69
|
const thrRaw = opts && opts.switchAtRemaining != null ? Number(opts.switchAtRemaining) : 0
|
|
@@ -72,6 +72,8 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
72
72
|
const targetTag = opts && opts.tag
|
|
73
73
|
const targetSession = opts && opts.sessionId
|
|
74
74
|
|
|
75
|
+
const evaluations = []
|
|
76
|
+
|
|
75
77
|
function isQuotaExhausted(acc) {
|
|
76
78
|
if (!thr || thr <= 0) return false
|
|
77
79
|
const q = acc.quota
|
|
@@ -96,7 +98,7 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
96
98
|
return true
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
// Sticky Session Pinning (#170)
|
|
101
|
+
// Sticky Session Pinning (#170, #517)
|
|
100
102
|
if (targetSession) {
|
|
101
103
|
const pinnedRef = getPinnedAccountRef(targetSession)
|
|
102
104
|
if (pinnedRef) {
|
|
@@ -108,8 +110,42 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
108
110
|
(pinned.quota.remainingPercent != null && Number(pinned.quota.remainingPercent) <= 0)
|
|
109
111
|
) && (!pinned.quota.resetAt || Number(pinned.quota.resetAt) > now))
|
|
110
112
|
if (!isCooldown && !isPinnedZero && !isQuotaExhausted(pinned) && !isUsageExhausted(pinned)) {
|
|
111
|
-
|
|
113
|
+
evaluations.push({
|
|
114
|
+
ref: pinnedRef,
|
|
115
|
+
eligible: true,
|
|
116
|
+
tier: 0,
|
|
117
|
+
reason: 'session_pinned',
|
|
118
|
+
pinned: true,
|
|
119
|
+
})
|
|
120
|
+
return {
|
|
121
|
+
account: pinned,
|
|
122
|
+
trace: {
|
|
123
|
+
chosenRef: pinnedRef,
|
|
124
|
+
decisionReason: 'session_pinned',
|
|
125
|
+
tier: 0,
|
|
126
|
+
evaluations,
|
|
127
|
+
selectionMetrics: {
|
|
128
|
+
pinned: true,
|
|
129
|
+
remainingPercent: getAccountRemainingPercent(pinned),
|
|
130
|
+
healthScore: pinned.healthScore ?? 100,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
evaluations.push({
|
|
136
|
+
ref: pinnedRef,
|
|
137
|
+
eligible: false,
|
|
138
|
+
reason: isCooldown ? 'pinned_cooldown' : isPinnedZero ? 'pinned_zero_quota' : 'pinned_exhausted',
|
|
139
|
+
pinned: true,
|
|
140
|
+
})
|
|
112
141
|
}
|
|
142
|
+
} else if (pinned) {
|
|
143
|
+
evaluations.push({
|
|
144
|
+
ref: pinnedRef,
|
|
145
|
+
eligible: false,
|
|
146
|
+
reason: !pinned.hasToken ? 'pinned_no_token' : 'pinned_quarantine',
|
|
147
|
+
pinned: true,
|
|
148
|
+
})
|
|
113
149
|
}
|
|
114
150
|
}
|
|
115
151
|
}
|
|
@@ -121,7 +157,12 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
121
157
|
const tiers = [[], [], []]
|
|
122
158
|
|
|
123
159
|
for (const acc of list) {
|
|
124
|
-
if (!acc
|
|
160
|
+
if (!acc) continue
|
|
161
|
+
const ref = acc.ref || acc.id || 'unknown'
|
|
162
|
+
if (!acc.hasToken) {
|
|
163
|
+
evaluations.push({ ref, eligible: false, tier: null, reason: 'no_token' })
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
125
166
|
|
|
126
167
|
if (!opts || opts.autoPacing !== false) {
|
|
127
168
|
if (acc.pacingRisk === undefined) {
|
|
@@ -143,34 +184,62 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
143
184
|
}
|
|
144
185
|
|
|
145
186
|
// VIP Slot Reservation (#175)
|
|
146
|
-
if (acc.vipOnly && !isVipRequest)
|
|
187
|
+
if (acc.vipOnly && !isVipRequest) {
|
|
188
|
+
evaluations.push({ ref, eligible: false, tier: null, reason: 'vip_reserved' })
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
147
191
|
|
|
148
192
|
// Tag-based filtering (#173)
|
|
149
|
-
if (targetTag && Array.isArray(acc.tags) && !acc.tags.includes(targetTag))
|
|
193
|
+
if (targetTag && Array.isArray(acc.tags) && !acc.tags.includes(targetTag)) {
|
|
194
|
+
evaluations.push({ ref, eligible: false, tier: null, reason: 'tag_mismatch' })
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
150
197
|
|
|
151
198
|
const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family)
|
|
199
|
+
if (isCooldown) {
|
|
200
|
+
evaluations.push({ ref, eligible: false, tier: null, reason: 'cooldown', until: acc.cooldownUntil })
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
|
|
152
204
|
const inQuarantine = isQuarantined(acc, now)
|
|
205
|
+
if (inQuarantine) {
|
|
206
|
+
evaluations.push({ ref, eligible: false, tier: null, reason: 'quarantine', until: acc.quarantineUntil, quarantineReason: acc.quarantineReason })
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
153
210
|
const isHardExhausted = (acc.usagePercent != null && Number(acc.usagePercent) >= 100 && (!acc.quota || !acc.quota.resetAt || Number(acc.quota.resetAt) > now))
|
|
211
|
+
if (isHardExhausted) {
|
|
212
|
+
evaluations.push({ ref, eligible: false, tier: null, reason: 'hard_exhausted', usagePercent: acc.usagePercent })
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
154
216
|
const isZeroQuota = Boolean(acc.quota && (
|
|
155
217
|
(acc.quota.remaining != null && Number(acc.quota.remaining) <= 0) ||
|
|
156
218
|
(acc.quota.remainingPercent != null && Number(acc.quota.remainingPercent) <= 0)
|
|
157
219
|
) && (!acc.quota.resetAt || Number(acc.quota.resetAt) > now))
|
|
158
|
-
|
|
159
|
-
|
|
220
|
+
if (isZeroQuota) {
|
|
221
|
+
evaluations.push({ ref, eligible: false, tier: null, reason: 'zero_quota' })
|
|
160
222
|
continue
|
|
161
223
|
}
|
|
162
224
|
|
|
163
225
|
const softExhausted = isQuotaExhausted(acc) || isUsageExhausted(acc)
|
|
164
226
|
if (softExhausted) {
|
|
165
227
|
tiers[2].push(acc)
|
|
228
|
+
evaluations.push({ ref, eligible: true, tier: 2, reason: 'soft_exhausted', remainingPercent: getAccountRemainingPercent(acc) })
|
|
166
229
|
continue
|
|
167
230
|
}
|
|
168
231
|
|
|
169
|
-
if (!acc.quota)
|
|
170
|
-
|
|
232
|
+
if (!acc.quota) {
|
|
233
|
+
tiers[1].push(acc)
|
|
234
|
+
evaluations.push({ ref, eligible: true, tier: 1, reason: 'healthy_unknown_quota' })
|
|
235
|
+
} else {
|
|
236
|
+
tiers[0].push(acc)
|
|
237
|
+
evaluations.push({ ref, eligible: true, tier: 0, reason: 'healthy_known_quota', remainingPercent: getAccountRemainingPercent(acc) })
|
|
238
|
+
}
|
|
171
239
|
}
|
|
172
240
|
|
|
173
|
-
for (
|
|
241
|
+
for (let tierIdx = 0; tierIdx < tiers.length; tierIdx++) {
|
|
242
|
+
const tier = tiers[tierIdx]
|
|
174
243
|
if (tier.length) {
|
|
175
244
|
tier.sort((a, b) => {
|
|
176
245
|
// #485: Smart Quota Drainer - prioritize higher burn urgency first
|
|
@@ -206,14 +275,46 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
206
275
|
})
|
|
207
276
|
|
|
208
277
|
const chosen = tier[0]
|
|
278
|
+
const chosenRef = chosen.ref || chosen.id
|
|
209
279
|
if (targetSession && chosen) {
|
|
210
|
-
pinSession(targetSession,
|
|
280
|
+
pinSession(targetSession, chosenRef)
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
account: chosen,
|
|
284
|
+
trace: {
|
|
285
|
+
chosenRef,
|
|
286
|
+
decisionReason: tierIdx === 0 ? 'healthy_tier0' : tierIdx === 1 ? 'healthy_tier1' : 'tier2_fallback',
|
|
287
|
+
tier: tierIdx,
|
|
288
|
+
evaluations,
|
|
289
|
+
selectionMetrics: {
|
|
290
|
+
tier: tierIdx,
|
|
291
|
+
burnUrgency: chosen.burnUrgency != null ? Number(chosen.burnUrgency) : null,
|
|
292
|
+
pacingRisk: chosen.pacingRisk != null ? Number(chosen.pacingRisk) : null,
|
|
293
|
+
remainingPercent: getAccountRemainingPercent(chosen),
|
|
294
|
+
resetAt: chosen.quota?.resetAt || null,
|
|
295
|
+
weight: chosen.weight || (chosen.isPro ? 5 : 1),
|
|
296
|
+
healthScore: chosen.healthScore ?? 100,
|
|
297
|
+
},
|
|
298
|
+
},
|
|
211
299
|
}
|
|
212
|
-
return chosen
|
|
213
300
|
}
|
|
214
301
|
}
|
|
215
302
|
|
|
216
|
-
return
|
|
303
|
+
return {
|
|
304
|
+
account: null,
|
|
305
|
+
trace: {
|
|
306
|
+
chosenRef: null,
|
|
307
|
+
decisionReason: 'no_usable_account',
|
|
308
|
+
tier: null,
|
|
309
|
+
evaluations,
|
|
310
|
+
selectionMetrics: null,
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function pickAccount(accounts, nowMs, opts) {
|
|
316
|
+
const res = pickAccountWithTrace(accounts, nowMs, opts)
|
|
317
|
+
return res ? res.account : null
|
|
217
318
|
}
|
|
218
319
|
|
|
219
320
|
export function markCooldown(account, nowMs, cooldownMs, family) {
|
package/lib/routes/status.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildEnrichedCatalog } from '../models-catalog.js'
|
|
1
2
|
// #374: Sensitive GET routes protected by same-origin check
|
|
2
3
|
import { readFileSync } from 'node:fs'
|
|
3
4
|
import { getRecentAlerts, clearAlerts } from '../alerts.js'
|
|
@@ -36,12 +37,18 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
async function configResponse() {
|
|
40
|
+
const accts = await accountsView()
|
|
39
41
|
return {
|
|
40
42
|
ok: true,
|
|
41
43
|
revision: getCurrentRevision(),
|
|
42
44
|
config: publicConfig(live()),
|
|
43
|
-
accounts:
|
|
45
|
+
accounts: accts,
|
|
44
46
|
providers: PROVIDERS.map((id) => ({ id, name: displayName(id) })),
|
|
47
|
+
modelsCatalog: buildEnrichedCatalog({
|
|
48
|
+
accounts: accts,
|
|
49
|
+
liveConfig: live(),
|
|
50
|
+
hideDeprecated: !!live().hideDeprecatedModels,
|
|
51
|
+
}),
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -239,6 +246,30 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
239
246
|
}), 'dsh-subscriptions: /status')
|
|
240
247
|
|
|
241
248
|
|
|
249
|
+
ctx.effect(() => ctx.webServer.register({
|
|
250
|
+
kind: 'exact',
|
|
251
|
+
path: '/dsh-subscriptions/models',
|
|
252
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
253
|
+
if (req.method !== 'GET') {
|
|
254
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
258
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
const accts = await accountsView()
|
|
262
|
+
writeJson(res, 200, {
|
|
263
|
+
ok: true,
|
|
264
|
+
models: buildEnrichedCatalog({
|
|
265
|
+
accounts: accts,
|
|
266
|
+
liveConfig: live(),
|
|
267
|
+
hideDeprecated: !!live().hideDeprecatedModels,
|
|
268
|
+
}),
|
|
269
|
+
})
|
|
270
|
+
}),
|
|
271
|
+
}), 'dsh-subscriptions: /models')
|
|
272
|
+
|
|
242
273
|
ctx.effect(() => ctx.webServer.register({
|
|
243
274
|
kind: 'exact',
|
|
244
275
|
path: '/dsh-subscriptions/diagnostics',
|
package/lib/session-pin.js
CHANGED
package/lib/stream-rotate.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
|
|
2
2
|
import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
|
|
3
3
|
import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
|
|
4
4
|
import { withRunawayGuard } from './runaway-guard.js'
|
|
@@ -11,6 +11,7 @@ export async function* streamWithRotation({
|
|
|
11
11
|
streamOnce,
|
|
12
12
|
options,
|
|
13
13
|
onCooldown,
|
|
14
|
+
onDecisionTrace,
|
|
14
15
|
cascadeFallback, // #349: optional cross-vendor cascading fallback generator
|
|
15
16
|
offlineFallback, // #174: optional fallback generator if all accounts exhausted
|
|
16
17
|
webhookUrl,
|
|
@@ -20,8 +21,28 @@ export async function* streamWithRotation({
|
|
|
20
21
|
let lastError = null
|
|
21
22
|
const tried = new Set()
|
|
22
23
|
|
|
24
|
+
const decisionTrace = {
|
|
25
|
+
sessionId: options && options.sessionId,
|
|
26
|
+
provider: options && options.provider,
|
|
27
|
+
model: options && options.model,
|
|
28
|
+
attempts: [],
|
|
29
|
+
finalOutcome: null,
|
|
30
|
+
chosenRef: null,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const syncTrace = (outcome, extra = {}) => {
|
|
34
|
+
decisionTrace.finalOutcome = outcome
|
|
35
|
+
Object.assign(decisionTrace, extra)
|
|
36
|
+
if (options && options._decisionTrace) {
|
|
37
|
+
Object.assign(options._decisionTrace, decisionTrace)
|
|
38
|
+
}
|
|
39
|
+
if (typeof onDecisionTrace === 'function') {
|
|
40
|
+
try { onDecisionTrace(decisionTrace) } catch { /* ignore trace callback error */ }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
while (true) {
|
|
24
|
-
const
|
|
45
|
+
const pickRes = pickAccountWithTrace(pool, nowMs(), {
|
|
25
46
|
switchAtRemaining,
|
|
26
47
|
family: modelFamily(options && options.provider, options && options.model),
|
|
27
48
|
sessionId: options && options.sessionId,
|
|
@@ -31,24 +52,52 @@ export async function* streamWithRotation({
|
|
|
31
52
|
drainBeforeReset: options && (options.drainBeforeReset !== undefined ? options.drainBeforeReset : (options.config && options.config.drainBeforeReset)),
|
|
32
53
|
drainSafetyPercent: options && (options.drainSafetyPercent !== undefined ? options.drainSafetyPercent : (options.config && options.config.drainSafetyPercent)),
|
|
33
54
|
})
|
|
55
|
+
const account = pickRes.account
|
|
34
56
|
|
|
35
57
|
if (!account || tried.has(account.ref || account.id)) {
|
|
36
58
|
if (cascadeFallback) {
|
|
37
|
-
// #349 Cascading Cross-Vendor Fallback
|
|
59
|
+
// #349 Cascading Cross-Vendor Fallback (#517 trace)
|
|
60
|
+
decisionTrace.attempts.push({
|
|
61
|
+
attempt: decisionTrace.attempts.length + 1,
|
|
62
|
+
kind: 'cascade_fallback',
|
|
63
|
+
reason: 'primary_pool_exhausted',
|
|
64
|
+
lastError: lastError ? (lastError.code || lastError.message) : null,
|
|
65
|
+
})
|
|
66
|
+
syncTrace('cascade_fallback')
|
|
38
67
|
yield* cascadeFallback(options, lastError)
|
|
39
68
|
return
|
|
40
69
|
}
|
|
41
70
|
if (offlineFallback) {
|
|
42
|
-
// #174 Local Mock Server Offline Fallback
|
|
71
|
+
// #174 Local Mock Server Offline Fallback (#517 trace)
|
|
72
|
+
decisionTrace.attempts.push({
|
|
73
|
+
attempt: decisionTrace.attempts.length + 1,
|
|
74
|
+
kind: 'offline_fallback',
|
|
75
|
+
reason: 'all_accounts_exhausted',
|
|
76
|
+
lastError: lastError ? (lastError.code || lastError.message) : null,
|
|
77
|
+
})
|
|
78
|
+
syncTrace('offline_fallback')
|
|
43
79
|
yield* offlineFallback(options, lastError)
|
|
44
80
|
return
|
|
45
81
|
}
|
|
82
|
+
syncTrace('exhausted')
|
|
46
83
|
if (lastError) throw lastError
|
|
47
84
|
const err = /** @type {Error & { code?: string }} */ (new Error('no usable subscription account for this provider'))
|
|
48
85
|
err.code = 'RATE_LIMIT'
|
|
49
86
|
throw err
|
|
50
87
|
}
|
|
51
88
|
|
|
89
|
+
const currentAttempt = {
|
|
90
|
+
attempt: decisionTrace.attempts.length + 1,
|
|
91
|
+
ref: account.ref || account.id,
|
|
92
|
+
decisionReason: pickRes.trace.decisionReason,
|
|
93
|
+
tier: pickRes.trace.tier,
|
|
94
|
+
selectionMetrics: pickRes.trace.selectionMetrics,
|
|
95
|
+
evaluations: pickRes.trace.evaluations,
|
|
96
|
+
status: 'pending',
|
|
97
|
+
}
|
|
98
|
+
decisionTrace.attempts.push(currentAttempt)
|
|
99
|
+
decisionTrace.chosenRef = account.ref || account.id
|
|
100
|
+
|
|
52
101
|
tried.add(account.ref || account.id)
|
|
53
102
|
|
|
54
103
|
let firstChunkDelivered = false
|
|
@@ -72,6 +121,8 @@ export async function* streamWithRotation({
|
|
|
72
121
|
firstChunkDelivered = true
|
|
73
122
|
yield chunk
|
|
74
123
|
}
|
|
124
|
+
currentAttempt.status = 'success'
|
|
125
|
+
syncTrace('success', { chosenRef: account.ref || account.id })
|
|
75
126
|
return
|
|
76
127
|
} catch (err) {
|
|
77
128
|
lastError = err
|
|
@@ -82,12 +133,21 @@ export async function* streamWithRotation({
|
|
|
82
133
|
// Transient region 400 error: retry same account up to 2 times before rotating (#391 / GH #9)
|
|
83
134
|
if (isRegionError(err) && regionRetries < 2 && (!options || !options.signal || !options.signal.aborted)) {
|
|
84
135
|
regionRetries++
|
|
136
|
+
currentAttempt.regionRetries = regionRetries
|
|
85
137
|
await new Promise((r) => setTimeout(r, 400 * regionRetries))
|
|
86
138
|
if (options && options.signal && options.signal.aborted) throw err
|
|
87
139
|
continue
|
|
88
140
|
}
|
|
89
141
|
|
|
90
|
-
if (!isSwitchableError(err))
|
|
142
|
+
if (!isSwitchableError(err)) {
|
|
143
|
+
currentAttempt.status = 'fatal_error'
|
|
144
|
+
currentAttempt.error = err ? (err.message || String(err)) : 'unknown'
|
|
145
|
+
syncTrace('fatal_error')
|
|
146
|
+
throw err
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
currentAttempt.status = 'switched'
|
|
150
|
+
currentAttempt.switchError = err ? (err.code || err.status || err.message) : 'switchable'
|
|
91
151
|
|
|
92
152
|
// Move slot to cooldown and quarantine (#172)
|
|
93
153
|
const cooled = markCooldown(account, nowMs(), cooldownMs, modelFamily(options && options.provider, options && options.model))
|
|
@@ -114,6 +174,9 @@ export async function* streamWithRotation({
|
|
|
114
174
|
account.quarantineAttempts = (account.quarantineAttempts || 0) + 1
|
|
115
175
|
}
|
|
116
176
|
|
|
177
|
+
currentAttempt.quarantineReason = account.quarantineReason || reason
|
|
178
|
+
currentAttempt.quarantineUntil = account.quarantineUntil
|
|
179
|
+
|
|
117
180
|
try {
|
|
118
181
|
notifyQuarantineEntered({
|
|
119
182
|
ref: account.ref,
|
package/lib/subscriptions.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isProvider } from "./refs.js"
|
|
2
|
-
import {
|
|
2
|
+
import { pickAccountWithTrace, markCooldown, isSwitchableError, modelFamily } from "./rotate.js"
|
|
3
3
|
import { quotaSnapshot } from "./ratelimit.js"
|
|
4
4
|
import { pickFetch } from "./proxy.js"
|
|
5
5
|
import { fetchWithTimeout } from "./http.js"
|
|
@@ -118,14 +118,16 @@ export function createSubscriptionsService(deps) {
|
|
|
118
118
|
// copy pool for rotation
|
|
119
119
|
const pool = list.map((a) => ({ ...a }))
|
|
120
120
|
const tried = new Set()
|
|
121
|
+
const attempts = []
|
|
121
122
|
let lastError = null
|
|
122
123
|
while (true) {
|
|
123
|
-
const
|
|
124
|
+
const pickRes = pickAccountWithTrace(pool, Date.now(), {
|
|
124
125
|
switchAtRemaining: thr,
|
|
125
126
|
family: modelFamily(provider, body && body.model),
|
|
126
127
|
drainBeforeReset: typeof deps.drainBeforeReset === 'function' ? deps.drainBeforeReset() : deps.drainBeforeReset,
|
|
127
128
|
drainSafetyPercent: typeof deps.drainSafetyPercent === 'function' ? deps.drainSafetyPercent() : deps.drainSafetyPercent,
|
|
128
129
|
})
|
|
130
|
+
const account = pickRes.account
|
|
129
131
|
if (!account) {
|
|
130
132
|
if (lastError) throw lastError
|
|
131
133
|
const err = new Error("no usable subscription account for this provider")
|
|
@@ -139,6 +141,16 @@ export function createSubscriptionsService(deps) {
|
|
|
139
141
|
throw err
|
|
140
142
|
}
|
|
141
143
|
tried.add(account.ref)
|
|
144
|
+
const currentAttempt = {
|
|
145
|
+
attempt: attempts.length + 1,
|
|
146
|
+
ref: account.ref,
|
|
147
|
+
decisionReason: pickRes.trace.decisionReason,
|
|
148
|
+
tier: pickRes.trace.tier,
|
|
149
|
+
selectionMetrics: pickRes.trace.selectionMetrics,
|
|
150
|
+
evaluations: pickRes.trace.evaluations,
|
|
151
|
+
status: 'pending',
|
|
152
|
+
}
|
|
153
|
+
attempts.push(currentAttempt)
|
|
142
154
|
try {
|
|
143
155
|
const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
|
|
144
156
|
const cfg = deps.vendorConfig(provider)
|
|
@@ -235,7 +247,8 @@ export function createSubscriptionsService(deps) {
|
|
|
235
247
|
else err.code = "VENDOR"
|
|
236
248
|
throw err
|
|
237
249
|
}
|
|
238
|
-
|
|
250
|
+
currentAttempt.status = 'success'
|
|
251
|
+
// #65: record in the request and cost history with decision trace (#517)
|
|
239
252
|
if (typeof deps.recordHistory === "function") {
|
|
240
253
|
try {
|
|
241
254
|
deps.recordHistory({
|
|
@@ -247,11 +260,18 @@ export function createSubscriptionsService(deps) {
|
|
|
247
260
|
status: res.status || 200,
|
|
248
261
|
ms: Date.now() - t0,
|
|
249
262
|
kind: "request",
|
|
263
|
+
decisionTrace: {
|
|
264
|
+
attempts,
|
|
265
|
+
finalOutcome: 'success',
|
|
266
|
+
chosenRef: account.ref,
|
|
267
|
+
},
|
|
250
268
|
})
|
|
251
269
|
} catch { /* ignore subscriptions error */ }
|
|
252
270
|
}
|
|
253
271
|
return res
|
|
254
272
|
} catch (err) {
|
|
273
|
+
currentAttempt.status = 'failed'
|
|
274
|
+
currentAttempt.switchError = err ? (err.code || err.status || err.message) : 'switchable'
|
|
255
275
|
lastError = err
|
|
256
276
|
if (!isSwitchableError(err)) throw err
|
|
257
277
|
const cooled = markCooldown(account, Date.now(), cooldownMs, modelFamily(provider, body && body.model))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-subscriptions",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.61",
|
|
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",
|