@goodandready/dsh-subscriptions 0.6.62 → 0.6.63
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/lib/client.js +129 -2
- package/lib/diagnostic-wizard.js +408 -0
- package/lib/diagnostics.js +2 -0
- package/lib/rotate.js +23 -8
- package/lib/routes/status.js +34 -0
- package/lib/session-pin.js +216 -10
- package/lib/stream-rotate.js +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -100,6 +100,8 @@ graph LR
|
|
|
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
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
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.
|
|
103
|
+
* **Cache-Aware Session Pinning (#520)**: Retains session affinity for prompt-caching providers (Claude, Codex, Antigravity, Grok) to maximize token cache reuse while strictly releasing pins when an account enters cooldown, quarantine, or quota exhaustion.
|
|
104
|
+
* **Guided Diagnostic Wizard (#519)**: Interactive step-by-step checklist verifying credentials, network/proxy connectivity, token freshness, and model catalog reachability without paid inference and with full secret redaction.
|
|
103
105
|
|
|
104
106
|
---
|
|
105
107
|
|
package/README.ru.md
CHANGED
|
@@ -100,6 +100,8 @@ graph LR
|
|
|
100
100
|
* **Динамический расчёт Cooldown**: парсинг заголовков `Retry-After`, `x-ratelimit-reset`, дат ISO и миллисекундных меток с автоматическим возвратом остывших аккаунтов в строй.
|
|
101
101
|
* **Трассировка решений ротации и объяснение маршрутизации (#517)**: Синхронная фиксация причин выбора аккаунтов, отказов и каскадных переходов в реальном времени с кодами причин без ретроспективной реконструкции; визуализация в телеметрии настроек и диагностике.
|
|
102
102
|
* **Динамический каталог моделей с пуловой доступностью и квотами (#507)**: Обогащенная витрина моделей с расчетом здоровья пула (карантин одного аккаунта не блокирует модель при наличии живых), тегами возможностей (`vision`, `tools`, `reasoning`, `coding`, `fast`), размером контекста и таймером сброса квот.
|
|
103
|
+
* **Cache-Aware Session Pinning (#520)**: Сохранение привязки сессии к подходящему аккаунту для провайдеров с поддержкой кэширования промптов (Claude, Codex, Antigravity, Grok) с гарантированным немедленным освобождением при кулдауне, карантине или исчерпании квоты.
|
|
104
|
+
* **Мастер интерактивной диагностики (#519)**: Пошаговый чек-лист состояния аккаунтов (валидность ключей, прокси, токен, доступность каталога) без платного инференса и с автоматическим маскированием секретов.
|
|
103
105
|
|
|
104
106
|
---
|
|
105
107
|
|
package/lib/client.js
CHANGED
|
@@ -594,6 +594,21 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
594
594
|
"forecastRunway": "Estimated runway",
|
|
595
595
|
"forecastBurnRate": "Burn rate",
|
|
596
596
|
"forecastExhaustWarning": "Quota projected to exhaust before reset window",
|
|
597
|
+
"wizardButton": "Diagnose",
|
|
598
|
+
"wizardTitle": "Diagnostic Wizard",
|
|
599
|
+
"wizardAll": "Account Wizard",
|
|
600
|
+
"wizardIncludeNetwork": "Include active network checks (proxy ping & model catalog discovery)",
|
|
601
|
+
"wizardRunning": "Running diagnostic checklist...",
|
|
602
|
+
"wizardStepCreds": "Credentials & Expiry",
|
|
603
|
+
"wizardStepProxy": "Proxy & Gateway",
|
|
604
|
+
"wizardStepToken": "Token Validation",
|
|
605
|
+
"wizardStepCatalog": "Model Catalog Reachability",
|
|
606
|
+
"wizardHealthy": "Healthy",
|
|
607
|
+
"wizardDegraded": "Degraded / Warnings",
|
|
608
|
+
"wizardFailing": "Issues Detected",
|
|
609
|
+
"wizardRecommendations": "Recommendations & Next Steps",
|
|
610
|
+
"wizardCopy": "Copy Masked Diagnostic Report",
|
|
611
|
+
"cachePinnedBadge": "Cache Pinned",
|
|
597
612
|
"field.enableToasts": "Show popup toast notifications on quarantine or quota events"
|
|
598
613
|
};
|
|
599
614
|
|
|
@@ -837,6 +852,21 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
837
852
|
"forecastRunway": "预计可用时间",
|
|
838
853
|
"forecastBurnRate": "消耗速率",
|
|
839
854
|
"forecastExhaustWarning": "预计在额度重置前耗尽",
|
|
855
|
+
"wizardButton": "诊断",
|
|
856
|
+
"wizardTitle": "账户诊断向导",
|
|
857
|
+
"wizardAll": "诊断向导",
|
|
858
|
+
"wizardIncludeNetwork": "包含活跃网络探测(代理连通性与模型目录探测)",
|
|
859
|
+
"wizardRunning": "正在执行诊断检查清单...",
|
|
860
|
+
"wizardStepCreds": "凭据完整性与有效期",
|
|
861
|
+
"wizardStepProxy": "网络代理与网关",
|
|
862
|
+
"wizardStepToken": "令牌有效性验证",
|
|
863
|
+
"wizardStepCatalog": "模型目录可达性",
|
|
864
|
+
"wizardHealthy": "健康正常",
|
|
865
|
+
"wizardDegraded": "降级预警",
|
|
866
|
+
"wizardFailing": "发现故障",
|
|
867
|
+
"wizardRecommendations": "排查建议与修复步骤",
|
|
868
|
+
"wizardCopy": "复制脱敏诊断报告",
|
|
869
|
+
"cachePinnedBadge": "缓存粘性",
|
|
840
870
|
"field.enableToasts": "当账户发生隔离或额度达到警戒线时显示即时弹窗提醒"
|
|
841
871
|
};
|
|
842
872
|
// #51: live countdown to the quota window reset (account.quota.resetAt).
|
|
@@ -1071,7 +1101,31 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1071
1101
|
const [backupErr, setBackupErr] = React.useState('')
|
|
1072
1102
|
const [backupInfo, setBackupInfo] = React.useState('')
|
|
1073
1103
|
const [backupLoading, setBackupLoading] = React.useState(false)
|
|
1074
|
-
|
|
1104
|
+
const [wizardModal, setWizardModal] = React.useState(null)
|
|
1105
|
+
|
|
1106
|
+
const runWizard = async (provider, index, ref, includeNetwork = false) => {
|
|
1107
|
+
setWizardModal({
|
|
1108
|
+
provider: provider || 'all',
|
|
1109
|
+
index: index || 1,
|
|
1110
|
+
ref: ref || null,
|
|
1111
|
+
loading: true,
|
|
1112
|
+
report: null,
|
|
1113
|
+
includeNetwork,
|
|
1114
|
+
error: '',
|
|
1115
|
+
})
|
|
1116
|
+
try {
|
|
1117
|
+
const res = await fetch('/dsh-subscriptions/diagnostic-wizard', {
|
|
1118
|
+
method: 'POST',
|
|
1119
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1120
|
+
body: JSON.stringify({ provider, index, ref, includeNetwork }),
|
|
1121
|
+
})
|
|
1122
|
+
const d = await res.json()
|
|
1123
|
+
if (!d || !d.ok) throw new Error((d && d.error && d.error.message) || ('HTTP ' + res.status))
|
|
1124
|
+
setWizardModal((m) => Object.assign({}, m, { loading: false, report: d.report, error: '' }))
|
|
1125
|
+
} catch (e) {
|
|
1126
|
+
setWizardModal((m) => Object.assign({}, m, { loading: false, error: cleanErrorMessage(e && e.message || e) }))
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1075
1129
|
const doExportBackup = async () => {
|
|
1076
1130
|
setBackupErr(''); setBackupInfo(''); setBackupLoading(true)
|
|
1077
1131
|
try {
|
|
@@ -1629,7 +1683,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1629
1683
|
React.createElement('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('rotationDecisionsDesc')),
|
|
1630
1684
|
React.createElement('div', { className: 'dsub-trace-box' },
|
|
1631
1685
|
telemetry.recentDecisions.map((dec, idx) => {
|
|
1632
|
-
const reasonBadge = dec.cascaded ? ('🔀 ' + t('rotationCascaded')) : (dec.decisionReason === 'session_pinned' ? '📌 Pinned' : ('⚡ ' + dec.decisionReason))
|
|
1686
|
+
const reasonBadge = dec.cascaded ? ('🔀 ' + t('rotationCascaded')) : (dec.decisionReason === 'cache_pinned' ? ('📌 ' + t('cachePinnedBadge')) : (dec.decisionReason === 'session_pinned' ? '📌 Pinned' : ('⚡ ' + dec.decisionReason)))
|
|
1633
1687
|
return React.createElement('div', { key: idx, className: 'dsub-trace-row' },
|
|
1634
1688
|
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
|
|
1635
1689
|
dec.provider ? React.createElement('span', { className: 'dsub-brand dsub-brand-' + dec.provider, style: { fontSize: '9px', padding: '1px 4px' } }, dec.provider.toUpperCase()) : null,
|
|
@@ -1960,6 +2014,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1960
2014
|
),
|
|
1961
2015
|
React.createElement('div', { className: 'dsub-block' },
|
|
1962
2016
|
React.createElement('div', { className: 'dsub-row' },
|
|
2017
|
+
React.createElement('button', { type: 'button', className: 'dsub-mini', onClick: () => runWizard(null, null, null, false) }, '🩺 ' + t('wizardAll')),
|
|
1963
2018
|
React.createElement('button', { type: 'button', className: 'dsub-mini', onClick: genDiag }, t('diagGenerate')),
|
|
1964
2019
|
React.createElement('button', { type: 'button', className: 'dsub-mini', onClick: runAnalyzeSession }, analyzingCache ? '…' : t('cacheAnalyze')),
|
|
1965
2020
|
React.createElement('button', { type: 'button', className: 'dsub-mini', onClick: () => { setBackupModal('export'); setBackupErr(''); setBackupInfo('') } }, '🔒 ' + t('backupExport')),
|
|
@@ -1978,6 +2033,72 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1978
2033
|
}, t('diagCopy')),
|
|
1979
2034
|
React.createElement('pre', { className: 'dsub-diag' }, diag),
|
|
1980
2035
|
) : null,
|
|
2036
|
+
wizardModal ? React.createElement('div', { className: 'dsub-modalWrap', onClick: (e) => { if (e.target === e.currentTarget) setWizardModal(null) } },
|
|
2037
|
+
React.createElement('div', { className: 'dsub-modal' },
|
|
2038
|
+
React.createElement('div', { className: 'dsub-modalHead' },
|
|
2039
|
+
React.createElement('div', { className: 'dsub-modalTitleWrap' },
|
|
2040
|
+
React.createElement('div', { className: 'dsub-modalIcon' }, '🩺'),
|
|
2041
|
+
React.createElement('div', null,
|
|
2042
|
+
React.createElement('div', { style: { fontWeight: 700, fontSize: '15px' } }, t('wizardTitle')),
|
|
2043
|
+
React.createElement('div', { style: { fontSize: '11.5px', opacity: 0.7 } },
|
|
2044
|
+
wizardModal.provider && wizardModal.provider !== 'all' ? (wizardModal.provider.toUpperCase() + ' #' + wizardModal.index + (wizardModal.ref ? ' (' + wizardModal.ref + ')' : '')) : t('wizardAll')
|
|
2045
|
+
),
|
|
2046
|
+
),
|
|
2047
|
+
),
|
|
2048
|
+
React.createElement('button', { type: 'button', className: 'dsub-mini', onClick: () => setWizardModal(null) }, '✕ ' + t('closeModal')),
|
|
2049
|
+
),
|
|
2050
|
+
React.createElement('div', { style: { marginTop: '14px' } },
|
|
2051
|
+
React.createElement('label', { className: 'dsub-row', style: { marginBottom: '10px' } },
|
|
2052
|
+
React.createElement('input', {
|
|
2053
|
+
type: 'checkbox',
|
|
2054
|
+
checked: !!wizardModal.includeNetwork,
|
|
2055
|
+
onChange: (e) => runWizard(wizardModal.provider === 'all' ? null : wizardModal.provider, wizardModal.index, wizardModal.ref, e.target.checked),
|
|
2056
|
+
}),
|
|
2057
|
+
React.createElement('span', { style: { fontSize: '12px' } }, t('wizardIncludeNetwork')),
|
|
2058
|
+
),
|
|
2059
|
+
wizardModal.loading ? React.createElement('div', { className: 'dsub-alert-ok', style: { marginBottom: '12px' } }, '⏳ ' + t('wizardRunning')) : null,
|
|
2060
|
+
wizardModal.error ? React.createElement('div', { className: 'dsub-alert-bad', style: { marginBottom: '12px' } }, wizardModal.error) : null,
|
|
2061
|
+
wizardModal.report ? React.createElement('div', null,
|
|
2062
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' } },
|
|
2063
|
+
React.createElement('span', {
|
|
2064
|
+
className: 'dsub-badge ' + (wizardModal.report.overallStatus === 'healthy' ? 'dsub-badge-ok' : (wizardModal.report.overallStatus === 'degraded' ? 'dsub-badge-warn' : 'dsub-badge-bad'))
|
|
2065
|
+
}, (wizardModal.report.overallStatus === 'healthy' ? '🟢 ' + t('wizardHealthy') : (wizardModal.report.overallStatus === 'degraded' ? '🟡 ' + t('wizardDegraded') : '🔴 ' + t('wizardFailing')))),
|
|
2066
|
+
React.createElement('span', { style: { fontSize: '11.5px', opacity: 0.7 } },
|
|
2067
|
+
wizardModal.report.summary.passed + ' passed · ' + wizardModal.report.summary.warnings + ' warn · ' + wizardModal.report.summary.failed + ' fail'
|
|
2068
|
+
),
|
|
2069
|
+
),
|
|
2070
|
+
React.createElement('div', { className: 'dsub-trace-box', style: { marginBottom: '12px' } },
|
|
2071
|
+
wizardModal.report.steps.map((st, idx) => {
|
|
2072
|
+
const icon = st.status === 'pass' ? '✅' : (st.status === 'warn' ? '⚠️' : (st.status === 'fail' ? '❌' : '⚪'))
|
|
2073
|
+
const color = st.status === 'pass' ? 'var(--dsw-alias-state-success-primary)' : (st.status === 'warn' ? 'var(--dsw-alias-state-warn-primary)' : (st.status === 'fail' ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)'))
|
|
2074
|
+
return React.createElement('div', { key: idx, className: 'dsub-trace-row' },
|
|
2075
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
|
|
2076
|
+
React.createElement('span', null, icon),
|
|
2077
|
+
React.createElement('span', { style: { fontWeight: 600 } }, st.name),
|
|
2078
|
+
st.latencyMs != null ? React.createElement('span', { style: { fontSize: '10px', opacity: 0.6 } }, st.latencyMs + 'ms') : null,
|
|
2079
|
+
),
|
|
2080
|
+
React.createElement('span', { style: { fontSize: '11px', color: color } }, st.message),
|
|
2081
|
+
)
|
|
2082
|
+
})
|
|
2083
|
+
),
|
|
2084
|
+
wizardModal.report.recommendations && wizardModal.report.recommendations.length ? React.createElement('div', { style: { marginBottom: '12px' } },
|
|
2085
|
+
React.createElement('div', { style: { fontSize: '11.5px', fontWeight: 600, color: 'var(--dsw-alias-state-warn-primary)', marginBottom: '4px' } }, '💡 ' + t('wizardRecommendations')),
|
|
2086
|
+
wizardModal.report.recommendations.map((rec, rIdx) => React.createElement('div', { key: rIdx, className: 'dsub-alert-bad', style: { marginBottom: '4px', fontSize: '11.5px' } }, rec))
|
|
2087
|
+
) : null,
|
|
2088
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '10px' } },
|
|
2089
|
+
React.createElement('button', {
|
|
2090
|
+
type: 'button',
|
|
2091
|
+
className: 'dsub-mini',
|
|
2092
|
+
onClick: () => {
|
|
2093
|
+
const txt = JSON.stringify(wizardModal.report, null, 2)
|
|
2094
|
+
try { navigator.clipboard.writeText(txt) } catch { /* ignore */ }
|
|
2095
|
+
},
|
|
2096
|
+
}, '📋 ' + t('wizardCopy')),
|
|
2097
|
+
),
|
|
2098
|
+
) : null,
|
|
2099
|
+
),
|
|
2100
|
+
),
|
|
2101
|
+
) : null,
|
|
1981
2102
|
backupModal ? React.createElement('div', { className: 'dsub-panel-box', style: { marginTop: '10px' } },
|
|
1982
2103
|
React.createElement('div', { className: 'dsub-panel-title' },
|
|
1983
2104
|
React.createElement('span', null, backupModal === 'export' ? ('🔒 ' + t('backupExportTitle')) : ('📥 ' + t('backupImportTitle'))),
|
|
@@ -2078,6 +2199,12 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2078
2199
|
disabled: checking[key],
|
|
2079
2200
|
onClick: () => doCheck(row.slot.provider, row.slot.index).catch((e) => setErr(cleanErrorMessage(e.message || e))),
|
|
2080
2201
|
}, checking[key] ? t('checking') : t('check')),
|
|
2202
|
+
React.createElement('button', {
|
|
2203
|
+
type: 'button', className: 'dsub-mini',
|
|
2204
|
+
title: t('wizardTitle'),
|
|
2205
|
+
onClick: () => runWizard(row.slot.provider, row.slot.index, row.slot.ref, false),
|
|
2206
|
+
}, '🩺 ' + t('wizardButton')),
|
|
2207
|
+
|
|
2081
2208
|
(account && account.quarantineUntil && Number(account.quarantineUntil) > Date.now() ? React.createElement(React.Fragment, null,
|
|
2082
2209
|
React.createElement('button', {
|
|
2083
2210
|
type: 'button', className: 'dsub-mini', title: t('probeWarmupTitle'),
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// Guided Diagnostic Wizard for accounts, credentials, and network connectivity (#519).
|
|
2
|
+
// Enforces 0 Cyrillic characters.
|
|
3
|
+
|
|
4
|
+
import { maskEmail, maskText, cleanErrorMessage } from './mask.js'
|
|
5
|
+
import { getVendor } from './vendors/index.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mask sensitive credentials inside diagnostic reports.
|
|
9
|
+
* @param {any} val
|
|
10
|
+
* @returns {any}
|
|
11
|
+
*/
|
|
12
|
+
export function sanitizeDiagnosticData(val) {
|
|
13
|
+
if (typeof val === 'string') {
|
|
14
|
+
return maskText(val)
|
|
15
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED]')
|
|
16
|
+
.replace(/key=[A-Za-z0-9._~+/-]+/gi, 'key=[REDACTED]')
|
|
17
|
+
.replace(/token=[A-Za-z0-9._~+/-]+/gi, 'token=[REDACTED]')
|
|
18
|
+
}
|
|
19
|
+
if (Array.isArray(val)) {
|
|
20
|
+
return val.map(sanitizeDiagnosticData)
|
|
21
|
+
}
|
|
22
|
+
if (val && typeof val === 'object') {
|
|
23
|
+
const out = {}
|
|
24
|
+
for (const [k, v] of Object.entries(val)) {
|
|
25
|
+
if (/secret|token|password|auth|refresh|access_token|key/i.test(k) && typeof v === 'string') {
|
|
26
|
+
out[k] = v.length > 8 ? v.slice(0, 4) + '...' + v.slice(-4) : '••••••'
|
|
27
|
+
} else {
|
|
28
|
+
out[k] = sanitizeDiagnosticData(v)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
return val
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run diagnostic checks on a specific subscription account.
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} options
|
|
40
|
+
* @param {Object} options.account
|
|
41
|
+
* @param {string} [options.rawBlob]
|
|
42
|
+
* @param {Object} [options.store]
|
|
43
|
+
* @param {Function} [options.live]
|
|
44
|
+
* @param {boolean} [options.includeNetwork]
|
|
45
|
+
* @param {typeof fetch} [options.fetchImpl]
|
|
46
|
+
* @param {number} [options.timeoutMs]
|
|
47
|
+
* @returns {Promise<Object>}
|
|
48
|
+
*/
|
|
49
|
+
export async function runDiagnosticWizard(options = {}) {
|
|
50
|
+
const account = options.account || {}
|
|
51
|
+
const ref = account.ref || account.id || 'unknown'
|
|
52
|
+
const provider = account.provider || 'unknown'
|
|
53
|
+
const includeNetwork = Boolean(options.includeNetwork)
|
|
54
|
+
const fetchImpl = options.fetchImpl || globalThis.fetch || fetch
|
|
55
|
+
const timeoutMs = options.timeoutMs || 8000
|
|
56
|
+
const now = Date.now()
|
|
57
|
+
|
|
58
|
+
let raw = options.rawBlob || null
|
|
59
|
+
if (!raw && options.store && typeof options.store.resolveRaw === 'function') {
|
|
60
|
+
try {
|
|
61
|
+
raw = await options.store.resolveRaw(ref)
|
|
62
|
+
} catch { /* ignore resolve error */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const steps = []
|
|
66
|
+
const recommendations = []
|
|
67
|
+
|
|
68
|
+
// -------------------------------------------------------------
|
|
69
|
+
// STEP 1: Credentials & Expiry Check (Passive)
|
|
70
|
+
// -------------------------------------------------------------
|
|
71
|
+
let blob = null
|
|
72
|
+
let credStatus = 'pass'
|
|
73
|
+
let credCode = 'CREDENTIAL_OK'
|
|
74
|
+
let credMsg = 'Credentials present and formatted correctly'
|
|
75
|
+
let expiresAt = null
|
|
76
|
+
|
|
77
|
+
if (!raw) {
|
|
78
|
+
credStatus = 'fail'
|
|
79
|
+
credCode = 'MISSING_CREDENTIAL'
|
|
80
|
+
credMsg = 'No credential blob stored for this account'
|
|
81
|
+
recommendations.push('Connect this account using OAuth login or provide an API key.')
|
|
82
|
+
} else {
|
|
83
|
+
try {
|
|
84
|
+
blob = JSON.parse(raw)
|
|
85
|
+
} catch {
|
|
86
|
+
credStatus = 'fail'
|
|
87
|
+
credCode = 'CORRUPT_CREDENTIAL_DATA'
|
|
88
|
+
credMsg = 'Stored credential payload could not be parsed as JSON'
|
|
89
|
+
recommendations.push('Re-authenticate or re-import this account to fix corrupt data.')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (blob) {
|
|
93
|
+
expiresAt = blob.expiresAt || blob.expires_at || null
|
|
94
|
+
const hasToken = Boolean(
|
|
95
|
+
blob.access_token || blob.accessToken || blob.token || blob.apiKey || blob.key || blob.sessionToken
|
|
96
|
+
)
|
|
97
|
+
const hasRefreshToken = Boolean(blob.refresh_token || blob.refreshToken)
|
|
98
|
+
|
|
99
|
+
if (!hasToken && !hasRefreshToken) {
|
|
100
|
+
credStatus = 'fail'
|
|
101
|
+
credCode = 'NO_TOKEN'
|
|
102
|
+
credMsg = 'Credential data exists but contains no access or refresh token'
|
|
103
|
+
recommendations.push('Re-authenticate this account to obtain an active access token.')
|
|
104
|
+
} else if (expiresAt && typeof expiresAt === 'number') {
|
|
105
|
+
const remainingMs = expiresAt - now
|
|
106
|
+
if (remainingMs <= 0) {
|
|
107
|
+
if (hasRefreshToken) {
|
|
108
|
+
credStatus = 'warn'
|
|
109
|
+
credCode = 'EXPIRED_ACCESS_TOKEN'
|
|
110
|
+
credMsg = `Access token expired ${Math.round(Math.abs(remainingMs) / 60000)}m ago; refresh token available`
|
|
111
|
+
recommendations.push('Run token refresh or reconnect account.')
|
|
112
|
+
} else {
|
|
113
|
+
credStatus = 'fail'
|
|
114
|
+
credCode = 'TOKEN_EXPIRED_NO_REFRESH'
|
|
115
|
+
credMsg = 'Access token is expired and no refresh token is present'
|
|
116
|
+
recommendations.push('Reconnect this account to obtain fresh credentials.')
|
|
117
|
+
}
|
|
118
|
+
} else if (remainingMs < 10 * 60 * 1000) {
|
|
119
|
+
credStatus = 'warn'
|
|
120
|
+
credCode = 'EXPIRING_SOON'
|
|
121
|
+
credMsg = `Access token expires in ${Math.round(remainingMs / 60000)}m`
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (blob.validationUrl) {
|
|
126
|
+
credStatus = 'warn'
|
|
127
|
+
credCode = 'VALIDATION_REQUIRED'
|
|
128
|
+
credMsg = blob.validationMessage ? cleanErrorMessage(blob.validationMessage) : 'Provider requires account verification'
|
|
129
|
+
recommendations.push(`Visit provider validation URL: ${blob.validationUrl}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
steps.push({
|
|
135
|
+
step: 'credentials',
|
|
136
|
+
name: 'Credentials & Expiry',
|
|
137
|
+
status: credStatus,
|
|
138
|
+
code: credCode,
|
|
139
|
+
message: credMsg,
|
|
140
|
+
expiresAt,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// -------------------------------------------------------------
|
|
144
|
+
// STEP 2: Network & Proxy Verification
|
|
145
|
+
// -------------------------------------------------------------
|
|
146
|
+
const proxyUrl = account.proxyUrl || (options.live && options.live() && options.live().proxyUrl) || null
|
|
147
|
+
let proxyStatus = 'pass'
|
|
148
|
+
let proxyCode = 'DIRECT_CONNECTION'
|
|
149
|
+
let proxyMsg = 'Direct network connection configured'
|
|
150
|
+
let proxyLatencyMs = null
|
|
151
|
+
|
|
152
|
+
if (proxyUrl) {
|
|
153
|
+
let parsed = null
|
|
154
|
+
try {
|
|
155
|
+
parsed = new URL(proxyUrl)
|
|
156
|
+
if (!['http:', 'https:', 'socks5:', 'socks5h:'].includes(parsed.protocol)) {
|
|
157
|
+
proxyStatus = 'fail'
|
|
158
|
+
proxyCode = 'UNSUPPORTED_PROXY_PROTOCOL'
|
|
159
|
+
proxyMsg = `Unsupported proxy protocol: ${parsed.protocol}`
|
|
160
|
+
recommendations.push('Use http://, https://, or socks5:// for proxy URL.')
|
|
161
|
+
} else {
|
|
162
|
+
proxyCode = 'PROXY_CONFIG_VALID'
|
|
163
|
+
proxyMsg = `Proxy configured: ${parsed.protocol}//${parsed.host}`
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
proxyStatus = 'fail'
|
|
167
|
+
proxyCode = 'INVALID_PROXY_URL'
|
|
168
|
+
proxyMsg = 'Proxy URL syntax is invalid'
|
|
169
|
+
recommendations.push('Check the proxyUrl setting for formatting errors.')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (proxyStatus === 'pass' && includeNetwork) {
|
|
173
|
+
const t0 = Date.now()
|
|
174
|
+
try {
|
|
175
|
+
const controller = new AbortController()
|
|
176
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
177
|
+
// Light probe: connect to target or HEAD request
|
|
178
|
+
await fetchImpl(proxyUrl, {
|
|
179
|
+
method: 'HEAD',
|
|
180
|
+
signal: controller.signal,
|
|
181
|
+
}).catch((e) => {
|
|
182
|
+
// If proxy accepts HTTP or rejects HEAD with 405/404, TCP connection succeeded
|
|
183
|
+
if (e && e.name !== 'AbortError' && e.code !== 'ECONNREFUSED' && e.code !== 'ENOTFOUND') {
|
|
184
|
+
return { ok: true }
|
|
185
|
+
}
|
|
186
|
+
throw e
|
|
187
|
+
})
|
|
188
|
+
clearTimeout(timer)
|
|
189
|
+
proxyLatencyMs = Date.now() - t0
|
|
190
|
+
proxyCode = 'PROXY_REACHABLE'
|
|
191
|
+
proxyMsg = `Proxy reachable (${proxyLatencyMs}ms)`
|
|
192
|
+
} catch (e) {
|
|
193
|
+
proxyStatus = 'fail'
|
|
194
|
+
proxyCode = 'PROXY_UNREACHABLE'
|
|
195
|
+
proxyMsg = `Proxy connection failed: ${cleanErrorMessage(e && e.message ? e.message : e)}`
|
|
196
|
+
recommendations.push('Verify that proxy server is running and accessible.')
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
proxyStatus = 'skipped'
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
steps.push({
|
|
204
|
+
step: 'proxy',
|
|
205
|
+
name: 'Proxy & Gateway',
|
|
206
|
+
status: proxyStatus,
|
|
207
|
+
code: proxyCode,
|
|
208
|
+
message: proxyMsg,
|
|
209
|
+
latencyMs: proxyLatencyMs,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// -------------------------------------------------------------
|
|
213
|
+
// STEP 3: Token Validation & Freshness
|
|
214
|
+
// -------------------------------------------------------------
|
|
215
|
+
let tokenStatus = 'skipped'
|
|
216
|
+
let tokenCode = 'NETWORK_CHECK_SKIPPED'
|
|
217
|
+
let tokenMsg = 'Token validation skipped (enable network checks to run)'
|
|
218
|
+
let tokenLatencyMs = null
|
|
219
|
+
|
|
220
|
+
if (!includeNetwork) {
|
|
221
|
+
// Passive: based on expiry from Step 1
|
|
222
|
+
if (credStatus === 'pass') {
|
|
223
|
+
tokenStatus = 'pass'
|
|
224
|
+
tokenCode = 'TOKEN_VALID_PASSIVE'
|
|
225
|
+
tokenMsg = 'Access token appears valid locally'
|
|
226
|
+
} else {
|
|
227
|
+
tokenStatus = credStatus
|
|
228
|
+
tokenCode = credCode
|
|
229
|
+
tokenMsg = credMsg
|
|
230
|
+
}
|
|
231
|
+
} else if (blob) {
|
|
232
|
+
const t0 = Date.now()
|
|
233
|
+
const remainingMs = expiresAt ? expiresAt - now : null
|
|
234
|
+
const isFresh = remainingMs !== null && remainingMs > 15 * 60 * 1000
|
|
235
|
+
|
|
236
|
+
if (isFresh && !blob.validationUrl) {
|
|
237
|
+
tokenStatus = 'pass'
|
|
238
|
+
tokenCode = 'TOKEN_FRESH'
|
|
239
|
+
tokenMsg = `Access token valid for another ${Math.round(remainingMs / 60000)}m; refresh not required`
|
|
240
|
+
} else {
|
|
241
|
+
// Attempt safe singleflight token refresh if vendor supports it
|
|
242
|
+
let vendor = null
|
|
243
|
+
try { vendor = getVendor(provider) } catch { /* unknown provider */ }
|
|
244
|
+
if (vendor && typeof vendor.refresh === 'function' && blob.refresh_token) {
|
|
245
|
+
try {
|
|
246
|
+
const controller = new AbortController()
|
|
247
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
248
|
+
const refreshed = await vendor.refresh(blob, {
|
|
249
|
+
fetchImpl,
|
|
250
|
+
signal: controller.signal,
|
|
251
|
+
config: options.live ? options.live() : {},
|
|
252
|
+
})
|
|
253
|
+
clearTimeout(timer)
|
|
254
|
+
tokenLatencyMs = Date.now() - t0
|
|
255
|
+
if (refreshed && (refreshed.access_token || refreshed.token)) {
|
|
256
|
+
tokenStatus = 'pass'
|
|
257
|
+
tokenCode = 'TOKEN_REFRESH_OK'
|
|
258
|
+
tokenMsg = `Token refreshed successfully (${tokenLatencyMs}ms)`
|
|
259
|
+
} else {
|
|
260
|
+
tokenStatus = 'warn'
|
|
261
|
+
tokenCode = 'TOKEN_REFRESH_PARTIAL'
|
|
262
|
+
tokenMsg = 'Token refresh completed without new access_token'
|
|
263
|
+
}
|
|
264
|
+
} catch (e) {
|
|
265
|
+
const errStatus = Number(e && (e.status || e.statusCode) || 0)
|
|
266
|
+
tokenLatencyMs = Date.now() - t0
|
|
267
|
+
if (errStatus === 401 || errStatus === 403 || (e && e.code === 'INVALID_GRANT')) {
|
|
268
|
+
tokenStatus = 'fail'
|
|
269
|
+
tokenCode = 'TOKEN_REVOKED'
|
|
270
|
+
tokenMsg = 'Token refresh rejected by provider (invalid_grant or revoked)'
|
|
271
|
+
recommendations.push('Log in again with this provider to reauthorize.')
|
|
272
|
+
} else if (errStatus === 429) {
|
|
273
|
+
tokenStatus = 'warn'
|
|
274
|
+
tokenCode = 'RATE_LIMITED'
|
|
275
|
+
tokenMsg = 'Token refresh rate limited (429)'
|
|
276
|
+
recommendations.push('Wait before requesting further token refreshes.')
|
|
277
|
+
} else {
|
|
278
|
+
tokenStatus = 'fail'
|
|
279
|
+
tokenCode = 'REFRESH_ERROR'
|
|
280
|
+
tokenMsg = `Token refresh failed: ${cleanErrorMessage(e && e.message ? e.message : e)}`
|
|
281
|
+
recommendations.push('Verify network connection to provider authentication servers.')
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
tokenStatus = 'pass'
|
|
286
|
+
tokenCode = 'STATIC_CREDENTIAL_READY'
|
|
287
|
+
tokenMsg = 'Static credentials ready for use'
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
steps.push({
|
|
293
|
+
step: 'token_validation',
|
|
294
|
+
name: 'Token Validation',
|
|
295
|
+
status: tokenStatus,
|
|
296
|
+
code: tokenCode,
|
|
297
|
+
message: tokenMsg,
|
|
298
|
+
latencyMs: tokenLatencyMs,
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
// -------------------------------------------------------------
|
|
302
|
+
// STEP 4: Model Catalog Discovery (Passive or Active listModels)
|
|
303
|
+
// -------------------------------------------------------------
|
|
304
|
+
let catalogStatus = 'skipped'
|
|
305
|
+
let catalogCode = 'NETWORK_CHECK_SKIPPED'
|
|
306
|
+
let catalogMsg = 'Model catalog reachability check skipped'
|
|
307
|
+
let catalogLatencyMs = null
|
|
308
|
+
let modelCount = 0
|
|
309
|
+
|
|
310
|
+
if (!includeNetwork) {
|
|
311
|
+
catalogStatus = 'skipped'
|
|
312
|
+
} else {
|
|
313
|
+
const t0 = Date.now()
|
|
314
|
+
const vendor = getVendor(provider)
|
|
315
|
+
if (vendor && typeof vendor.listModels === 'function') {
|
|
316
|
+
try {
|
|
317
|
+
const controller = new AbortController()
|
|
318
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
319
|
+
const models = await vendor.listModels(account, {
|
|
320
|
+
fetchImpl,
|
|
321
|
+
signal: controller.signal,
|
|
322
|
+
config: options.live ? options.live() : {},
|
|
323
|
+
})
|
|
324
|
+
clearTimeout(timer)
|
|
325
|
+
catalogLatencyMs = Date.now() - t0
|
|
326
|
+
modelCount = Array.isArray(models) ? models.length : 0
|
|
327
|
+
if (modelCount > 0) {
|
|
328
|
+
catalogStatus = 'pass'
|
|
329
|
+
catalogCode = 'CATALOG_OK'
|
|
330
|
+
catalogMsg = `${modelCount} models reachable and verified (${catalogLatencyMs}ms)`
|
|
331
|
+
} else {
|
|
332
|
+
catalogStatus = 'warn'
|
|
333
|
+
catalogCode = 'CATALOG_EMPTY'
|
|
334
|
+
catalogMsg = 'Catalog request succeeded but 0 models returned'
|
|
335
|
+
recommendations.push('Check provider plan features and permissions.')
|
|
336
|
+
}
|
|
337
|
+
} catch (e) {
|
|
338
|
+
catalogLatencyMs = Date.now() - t0
|
|
339
|
+
const status = Number(e && (e.status || e.statusCode) || 0)
|
|
340
|
+
if (status === 401 || status === 403) {
|
|
341
|
+
catalogStatus = 'fail'
|
|
342
|
+
catalogCode = 'CATALOG_UNAUTHORIZED'
|
|
343
|
+
catalogMsg = 'Provider rejected catalog request with 401/403'
|
|
344
|
+
recommendations.push('Account credentials do not have access to models catalog.')
|
|
345
|
+
} else {
|
|
346
|
+
catalogStatus = 'fail'
|
|
347
|
+
catalogCode = 'CATALOG_UNREACHABLE'
|
|
348
|
+
catalogMsg = `Failed to fetch model catalog: ${cleanErrorMessage(e && e.message ? e.message : e)}`
|
|
349
|
+
recommendations.push('Verify provider API endpoint reachability.')
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
catalogStatus = 'pass'
|
|
354
|
+
catalogCode = 'STATIC_CATALOG'
|
|
355
|
+
catalogMsg = 'Standard static catalog available'
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
steps.push({
|
|
360
|
+
step: 'models_catalog',
|
|
361
|
+
name: 'Model Catalog Reachability',
|
|
362
|
+
status: catalogStatus,
|
|
363
|
+
code: catalogCode,
|
|
364
|
+
message: catalogMsg,
|
|
365
|
+
latencyMs: catalogLatencyMs,
|
|
366
|
+
modelCount,
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
// -------------------------------------------------------------
|
|
370
|
+
// Overall Summary & Sanitization
|
|
371
|
+
// -------------------------------------------------------------
|
|
372
|
+
let passed = 0
|
|
373
|
+
let warnings = 0
|
|
374
|
+
let failed = 0
|
|
375
|
+
let skipped = 0
|
|
376
|
+
|
|
377
|
+
for (const s of steps) {
|
|
378
|
+
if (s.status === 'pass') passed++
|
|
379
|
+
else if (s.status === 'warn') warnings++
|
|
380
|
+
else if (s.status === 'fail') failed++
|
|
381
|
+
else skipped++
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const overallStatus = failed > 0 ? 'failing' : (warnings > 0 ? 'degraded' : 'healthy')
|
|
385
|
+
|
|
386
|
+
const report = {
|
|
387
|
+
account: {
|
|
388
|
+
ref,
|
|
389
|
+
provider,
|
|
390
|
+
label: account.label ? maskEmail(account.label) : ref,
|
|
391
|
+
index: account.index || 1,
|
|
392
|
+
},
|
|
393
|
+
generatedAt: new Date(now).toISOString(),
|
|
394
|
+
networkChecksIncluded: includeNetwork,
|
|
395
|
+
overallStatus,
|
|
396
|
+
summary: {
|
|
397
|
+
totalSteps: steps.length,
|
|
398
|
+
passed,
|
|
399
|
+
warnings,
|
|
400
|
+
failed,
|
|
401
|
+
skipped,
|
|
402
|
+
},
|
|
403
|
+
steps,
|
|
404
|
+
recommendations,
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return sanitizeDiagnosticData(report)
|
|
408
|
+
}
|
package/lib/diagnostics.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defaultConcurrencyQueue } from './concurrency-queue.js'
|
|
2
2
|
import { calculateCacheMetrics } from './analytics.js'
|
|
3
|
+
import { getSessionPinStats } from './session-pin.js'
|
|
3
4
|
import { maskText } from './mask.js'
|
|
4
5
|
import { normalizeSlots } from './accounts.js'
|
|
5
6
|
import { PROVIDERS } from './refs.js'
|
|
@@ -50,6 +51,7 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
|
|
|
50
51
|
requests: { total: rows.length, byStatus, lastErrors },
|
|
51
52
|
cache: calculateCacheMetrics(rows),
|
|
52
53
|
queue: defaultConcurrencyQueue.stats(),
|
|
54
|
+
pins: getSessionPinStats(),
|
|
53
55
|
decisions: {
|
|
54
56
|
recent: rows.filter((r) => r && r.decisionTrace).slice(0, 10).map((r) => ({
|
|
55
57
|
ts: r.ts,
|
package/lib/rotate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { computePacingRisk, calculateBurnRatePerHour, calculateBurnUrgency } from "./forecast.js"
|
|
2
2
|
import { isQuarantined } from './quarantine.js'
|
|
3
|
-
import { getPinnedAccountRef, pinSession } from './session-pin.js'
|
|
3
|
+
import { getPinnedAccountRef, pinSession, touchSessionPin, unpinSession, isCacheAwareProvider } from './session-pin.js'
|
|
4
4
|
|
|
5
5
|
const SWITCH_CODES = new Set([
|
|
6
6
|
'RATE_LIMIT',
|
|
@@ -98,9 +98,9 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
98
98
|
return true
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// Sticky Session Pinning (#170, #517)
|
|
101
|
+
// Sticky & Cache-Aware Session Pinning (#170, #517, #520)
|
|
102
102
|
if (targetSession) {
|
|
103
|
-
const pinnedRef = getPinnedAccountRef(targetSession)
|
|
103
|
+
const pinnedRef = getPinnedAccountRef(targetSession, now)
|
|
104
104
|
if (pinnedRef) {
|
|
105
105
|
const pinned = list.find((a) => (a.ref || a.id) === pinnedRef)
|
|
106
106
|
if (pinned && pinned.hasToken && !isQuarantined(pinned, now)) {
|
|
@@ -110,42 +110,53 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
110
110
|
(pinned.quota.remainingPercent != null && Number(pinned.quota.remainingPercent) <= 0)
|
|
111
111
|
) && (!pinned.quota.resetAt || Number(pinned.quota.resetAt) > now))
|
|
112
112
|
if (!isCooldown && !isPinnedZero && !isQuotaExhausted(pinned) && !isUsageExhausted(pinned)) {
|
|
113
|
+
const cacheAware = isCacheAwareProvider(pinned.provider)
|
|
114
|
+
const decisionReason = cacheAware ? 'cache_pinned' : 'session_pinned'
|
|
115
|
+
touchSessionPin(targetSession, { provider: pinned.provider, model: opts && opts.model })
|
|
113
116
|
evaluations.push({
|
|
114
117
|
ref: pinnedRef,
|
|
115
118
|
eligible: true,
|
|
116
119
|
tier: 0,
|
|
117
|
-
reason:
|
|
120
|
+
reason: decisionReason,
|
|
118
121
|
pinned: true,
|
|
122
|
+
cacheAware,
|
|
119
123
|
})
|
|
120
124
|
return {
|
|
121
125
|
account: pinned,
|
|
122
126
|
trace: {
|
|
123
127
|
chosenRef: pinnedRef,
|
|
124
|
-
decisionReason
|
|
128
|
+
decisionReason,
|
|
125
129
|
tier: 0,
|
|
126
130
|
evaluations,
|
|
127
131
|
selectionMetrics: {
|
|
128
132
|
pinned: true,
|
|
133
|
+
cacheAware,
|
|
129
134
|
remainingPercent: getAccountRemainingPercent(pinned),
|
|
130
135
|
healthScore: pinned.healthScore ?? 100,
|
|
131
136
|
},
|
|
132
137
|
},
|
|
133
138
|
}
|
|
134
139
|
} else {
|
|
140
|
+
const releaseReason = isCooldown ? 'pinned_cooldown' : isPinnedZero ? 'pinned_zero_quota' : 'pinned_exhausted'
|
|
141
|
+
unpinSession(targetSession, releaseReason)
|
|
135
142
|
evaluations.push({
|
|
136
143
|
ref: pinnedRef,
|
|
137
144
|
eligible: false,
|
|
138
|
-
reason:
|
|
145
|
+
reason: releaseReason,
|
|
139
146
|
pinned: true,
|
|
140
147
|
})
|
|
141
148
|
}
|
|
142
149
|
} else if (pinned) {
|
|
150
|
+
const releaseReason = !pinned.hasToken ? 'pinned_no_token' : 'pinned_quarantine'
|
|
151
|
+
unpinSession(targetSession, releaseReason)
|
|
143
152
|
evaluations.push({
|
|
144
153
|
ref: pinnedRef,
|
|
145
154
|
eligible: false,
|
|
146
|
-
reason:
|
|
155
|
+
reason: releaseReason,
|
|
147
156
|
pinned: true,
|
|
148
157
|
})
|
|
158
|
+
} else {
|
|
159
|
+
unpinSession(targetSession, 'pinned_not_found')
|
|
149
160
|
}
|
|
150
161
|
}
|
|
151
162
|
}
|
|
@@ -277,7 +288,11 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
|
|
|
277
288
|
const chosen = tier[0]
|
|
278
289
|
const chosenRef = chosen.ref || chosen.id
|
|
279
290
|
if (targetSession && chosen) {
|
|
280
|
-
pinSession(targetSession, chosenRef
|
|
291
|
+
pinSession(targetSession, chosenRef, {
|
|
292
|
+
provider: chosen.provider,
|
|
293
|
+
model: opts && opts.model,
|
|
294
|
+
cacheAware: isCacheAwareProvider(chosen.provider),
|
|
295
|
+
})
|
|
281
296
|
}
|
|
282
297
|
return {
|
|
283
298
|
account: chosen,
|
package/lib/routes/status.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defaultConcurrencyQueue } from '../concurrency-queue.js'
|
|
2
2
|
import { calculateAnalytics } from '../analytics.js'
|
|
3
|
+
import { runDiagnosticWizard } from '../diagnostic-wizard.js'
|
|
3
4
|
import { buildEnrichedCatalog } from '../models-catalog.js'
|
|
4
5
|
// #374: Sensitive GET routes protected by same-origin check
|
|
5
6
|
import { readFileSync } from 'node:fs'
|
|
@@ -580,4 +581,37 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
580
581
|
writeJson(res, 200, { ok: true, queue: queueStats })
|
|
581
582
|
}),
|
|
582
583
|
}), 'dsh-subscriptions: /queue')
|
|
584
|
+
|
|
585
|
+
// #519: Interactive Diagnostic Wizard for accounts and connectivity
|
|
586
|
+
ctx.effect(() => ctx.webServer.register({
|
|
587
|
+
kind: 'exact',
|
|
588
|
+
path: '/dsh-subscriptions/diagnostic-wizard',
|
|
589
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
590
|
+
if (req.method !== 'POST') {
|
|
591
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
595
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
let body = {}
|
|
599
|
+
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch { /* ignore */ }
|
|
600
|
+
const accounts = await accountsView()
|
|
601
|
+
const targetAccount = accounts.find((a) => {
|
|
602
|
+
if (body.ref && (a.ref === body.ref || a.id === body.ref)) return true
|
|
603
|
+
if (body.provider && body.index !== undefined && a.provider === body.provider && a.index === Number(body.index)) return true
|
|
604
|
+
return false
|
|
605
|
+
}) || (body.provider ? { provider: body.provider, index: Number(body.index) || 1 } : (accounts[0] || {}))
|
|
606
|
+
|
|
607
|
+
const report = await runDiagnosticWizard({
|
|
608
|
+
account: targetAccount,
|
|
609
|
+
store,
|
|
610
|
+
live,
|
|
611
|
+
includeNetwork: Boolean(body.includeNetwork),
|
|
612
|
+
fetchImpl: fetch,
|
|
613
|
+
})
|
|
614
|
+
writeJson(res, 200, { ok: true, report })
|
|
615
|
+
}),
|
|
616
|
+
}), 'dsh-subscriptions: /diagnostic-wizard')
|
|
583
617
|
}
|
package/lib/session-pin.js
CHANGED
|
@@ -1,52 +1,258 @@
|
|
|
1
|
+
// Cache-aware session affinity and pinning with lifecycle management (#170, #517, #520).
|
|
2
|
+
// Enforces 0 Cyrillic characters.
|
|
3
|
+
|
|
4
|
+
export const CACHE_AWARE_PROVIDERS = new Set(['claude', 'codex', 'antigravity', 'grok'])
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check if a provider supports prompt/prefix caching where pinning benefits cache hit rates.
|
|
8
|
+
* @param {string|undefined} provider
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export function isCacheAwareProvider(provider) {
|
|
12
|
+
if (!provider || typeof provider !== 'string') return false
|
|
13
|
+
return CACHE_AWARE_PROVIDERS.has(provider.toLowerCase().trim())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TTL_MS = 30 * 60 * 1000 // 30 minutes
|
|
17
|
+
const MAX_PINS = 200
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} SessionPinEntry
|
|
21
|
+
* @property {string} accountRef
|
|
22
|
+
* @property {string} [provider]
|
|
23
|
+
* @property {string} [model]
|
|
24
|
+
* @property {number} pinnedAt
|
|
25
|
+
* @property {number} lastHitAt
|
|
26
|
+
* @property {number} expiresAt
|
|
27
|
+
* @property {number} hits
|
|
28
|
+
* @property {boolean} cacheAware
|
|
29
|
+
* @property {number} cacheHits
|
|
30
|
+
* @property {number} cacheTokensSaved
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** @type {Map<string, SessionPinEntry>} */
|
|
1
34
|
const sessionPins = new Map()
|
|
2
|
-
|
|
35
|
+
|
|
36
|
+
const stats = {
|
|
37
|
+
totalPins: 0,
|
|
38
|
+
retainedHits: 0,
|
|
39
|
+
cacheAwareHits: 0,
|
|
40
|
+
releasedCount: 0,
|
|
41
|
+
cacheTokensSaved: 0,
|
|
42
|
+
releasesByReason: {},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Record a release reason in statistics.
|
|
47
|
+
* @param {string} reason
|
|
48
|
+
*/
|
|
49
|
+
function recordReleaseReason(reason) {
|
|
50
|
+
const r = String(reason || 'manual')
|
|
51
|
+
stats.releasedCount++
|
|
52
|
+
stats.releasesByReason[r] = (stats.releasesByReason[r] || 0) + 1
|
|
53
|
+
}
|
|
3
54
|
|
|
4
55
|
/**
|
|
5
56
|
* Prune expired session pin entries.
|
|
6
57
|
* @param {number} [now]
|
|
7
|
-
* @returns {
|
|
58
|
+
* @returns {number} count of pruned pins
|
|
8
59
|
*/
|
|
9
60
|
export function pruneSessionPins(now = Date.now()) {
|
|
61
|
+
let pruned = 0
|
|
10
62
|
for (const [id, entry] of sessionPins.entries()) {
|
|
11
63
|
if (now > entry.expiresAt) {
|
|
12
64
|
sessionPins.delete(id)
|
|
65
|
+
recordReleaseReason('pin_released_expired')
|
|
66
|
+
pruned++
|
|
13
67
|
}
|
|
14
68
|
}
|
|
69
|
+
return pruned
|
|
15
70
|
}
|
|
16
71
|
|
|
17
72
|
/**
|
|
18
|
-
* Bind a session id to an account ref
|
|
73
|
+
* Bind a session id to an account ref with cache awareness and metadata.
|
|
19
74
|
* @param {string|undefined} sessionId
|
|
20
75
|
* @param {string|undefined} accountRef
|
|
21
|
-
* @param {
|
|
76
|
+
* @param {Object} [options]
|
|
77
|
+
* @param {string} [options.provider]
|
|
78
|
+
* @param {string} [options.model]
|
|
79
|
+
* @param {number} [options.ttlMs]
|
|
80
|
+
* @param {boolean} [options.cacheAware]
|
|
22
81
|
* @returns {void}
|
|
23
82
|
*/
|
|
24
|
-
export function pinSession(sessionId, accountRef,
|
|
83
|
+
export function pinSession(sessionId, accountRef, options = {}) {
|
|
25
84
|
if (!sessionId || !accountRef) return
|
|
26
|
-
if (sessionPins.size >=
|
|
85
|
+
if (sessionPins.size >= MAX_PINS) {
|
|
27
86
|
pruneSessionPins()
|
|
28
87
|
}
|
|
88
|
+
|
|
89
|
+
const now = Date.now()
|
|
90
|
+
const ttl = typeof options === 'number'
|
|
91
|
+
? options
|
|
92
|
+
: (options && typeof options.ttlMs === 'number' ? options.ttlMs : DEFAULT_TTL_MS)
|
|
93
|
+
const provider = options.provider || undefined
|
|
94
|
+
const cacheAware = options.cacheAware !== undefined ? Boolean(options.cacheAware) : isCacheAwareProvider(provider)
|
|
95
|
+
|
|
96
|
+
const existing = sessionPins.get(sessionId)
|
|
97
|
+
if (existing && existing.accountRef === accountRef) {
|
|
98
|
+
// Sliding TTL on re-pinning same account
|
|
99
|
+
existing.lastHitAt = now
|
|
100
|
+
existing.expiresAt = now + ttl
|
|
101
|
+
if (options.model) existing.model = options.model
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
29
105
|
sessionPins.set(sessionId, {
|
|
30
106
|
accountRef,
|
|
31
|
-
|
|
107
|
+
provider,
|
|
108
|
+
model: options.model || undefined,
|
|
109
|
+
pinnedAt: now,
|
|
110
|
+
lastHitAt: now,
|
|
111
|
+
expiresAt: now + ttl,
|
|
112
|
+
hits: 0,
|
|
113
|
+
cacheAware,
|
|
114
|
+
cacheHits: 0,
|
|
115
|
+
cacheTokensSaved: 0,
|
|
32
116
|
})
|
|
117
|
+
stats.totalPins++
|
|
33
118
|
}
|
|
34
119
|
|
|
35
120
|
/**
|
|
121
|
+
* Retrieve bound account ref or null if missing/expired.
|
|
36
122
|
* @param {string|undefined} sessionId
|
|
37
|
-
* @
|
|
123
|
+
* @param {number} [now]
|
|
124
|
+
* @returns {string|null}
|
|
38
125
|
*/
|
|
39
|
-
export function getPinnedAccountRef(sessionId) {
|
|
126
|
+
export function getPinnedAccountRef(sessionId, now = Date.now()) {
|
|
40
127
|
if (!sessionId) return null
|
|
41
128
|
const entry = sessionPins.get(sessionId)
|
|
42
129
|
if (!entry) return null
|
|
43
|
-
if (
|
|
130
|
+
if (now > entry.expiresAt) {
|
|
44
131
|
sessionPins.delete(sessionId)
|
|
132
|
+
recordReleaseReason('pin_released_expired')
|
|
45
133
|
return null
|
|
46
134
|
}
|
|
47
135
|
return entry.accountRef
|
|
48
136
|
}
|
|
49
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Retrieve full session pin details.
|
|
140
|
+
* @param {string|undefined} sessionId
|
|
141
|
+
* @param {number} [now]
|
|
142
|
+
* @returns {SessionPinEntry|null}
|
|
143
|
+
*/
|
|
144
|
+
export function getPinnedSessionDetails(sessionId, now = Date.now()) {
|
|
145
|
+
if (!sessionId) return null
|
|
146
|
+
const entry = sessionPins.get(sessionId)
|
|
147
|
+
if (!entry) return null
|
|
148
|
+
if (now > entry.expiresAt) {
|
|
149
|
+
sessionPins.delete(sessionId)
|
|
150
|
+
recordReleaseReason('pin_released_expired')
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
return { ...entry }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Touch an active session pin (record hit, sliding TTL extension).
|
|
158
|
+
* @param {string|undefined} sessionId
|
|
159
|
+
* @param {Object} [options]
|
|
160
|
+
* @param {number} [options.ttlMs]
|
|
161
|
+
* @param {string} [options.model]
|
|
162
|
+
* @param {string} [options.provider]
|
|
163
|
+
* @returns {boolean} true if pin existed and was updated
|
|
164
|
+
*/
|
|
165
|
+
export function touchSessionPin(sessionId, options = {}) {
|
|
166
|
+
if (!sessionId) return false
|
|
167
|
+
const entry = sessionPins.get(sessionId)
|
|
168
|
+
if (!entry) return false
|
|
169
|
+
|
|
170
|
+
const now = Date.now()
|
|
171
|
+
if (now > entry.expiresAt) {
|
|
172
|
+
sessionPins.delete(sessionId)
|
|
173
|
+
recordReleaseReason('pin_released_expired')
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const ttl = typeof options === 'number'
|
|
178
|
+
? options
|
|
179
|
+
: (options && typeof options.ttlMs === 'number' ? options.ttlMs : DEFAULT_TTL_MS)
|
|
180
|
+
entry.lastHitAt = now
|
|
181
|
+
entry.expiresAt = now + ttl
|
|
182
|
+
entry.hits++
|
|
183
|
+
if (options.provider) entry.provider = options.provider
|
|
184
|
+
if (options.model) entry.model = options.model
|
|
185
|
+
|
|
186
|
+
stats.retainedHits++
|
|
187
|
+
if (entry.cacheAware) {
|
|
188
|
+
stats.cacheAwareHits++
|
|
189
|
+
}
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Explicitly release a session pin with a documented reason code.
|
|
195
|
+
* @param {string|undefined} sessionId
|
|
196
|
+
* @param {string} [reason] e.g. 'pinned_cooldown', 'pinned_quarantine', 'pinned_quota', 'manual'
|
|
197
|
+
* @returns {boolean} true if a pin was removed
|
|
198
|
+
*/
|
|
199
|
+
export function unpinSession(sessionId, reason = 'manual') {
|
|
200
|
+
if (!sessionId) return false
|
|
201
|
+
const exists = sessionPins.has(sessionId)
|
|
202
|
+
if (exists) {
|
|
203
|
+
sessionPins.delete(sessionId)
|
|
204
|
+
recordReleaseReason(reason)
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
207
|
+
return false
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Record actual token caching metrics for a pinned session.
|
|
212
|
+
* @param {string|undefined} sessionId
|
|
213
|
+
* @param {number} cacheReadTokens
|
|
214
|
+
*/
|
|
215
|
+
export function recordSessionCacheUsage(sessionId, cacheReadTokens = 0) {
|
|
216
|
+
if (!sessionId || typeof cacheReadTokens !== 'number' || cacheReadTokens <= 0) return
|
|
217
|
+
const entry = sessionPins.get(sessionId)
|
|
218
|
+
if (!entry) return
|
|
219
|
+
|
|
220
|
+
entry.cacheHits++
|
|
221
|
+
entry.cacheTokensSaved += cacheReadTokens
|
|
222
|
+
stats.cacheTokensSaved += cacheReadTokens
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get cumulative statistics and current state of session pinning.
|
|
227
|
+
* @returns {Object}
|
|
228
|
+
*/
|
|
229
|
+
export function getSessionPinStats() {
|
|
230
|
+
let cacheAwareActive = 0
|
|
231
|
+
for (const entry of sessionPins.values()) {
|
|
232
|
+
if (entry.cacheAware) cacheAwareActive++
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
totalPins: stats.totalPins,
|
|
237
|
+
activePins: sessionPins.size,
|
|
238
|
+
cacheAwareActivePins: cacheAwareActive,
|
|
239
|
+
retainedHits: stats.retainedHits,
|
|
240
|
+
cacheAwareHits: stats.cacheAwareHits,
|
|
241
|
+
releasedCount: stats.releasedCount,
|
|
242
|
+
cacheTokensSaved: stats.cacheTokensSaved,
|
|
243
|
+
releasesByReason: { ...stats.releasesByReason },
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Reset all session pins and statistics (useful for tests and full resets).
|
|
249
|
+
*/
|
|
50
250
|
export function clearSessionPins() {
|
|
51
251
|
sessionPins.clear()
|
|
252
|
+
stats.totalPins = 0
|
|
253
|
+
stats.retainedHits = 0
|
|
254
|
+
stats.cacheAwareHits = 0
|
|
255
|
+
stats.releasedCount = 0
|
|
256
|
+
stats.cacheTokensSaved = 0
|
|
257
|
+
stats.releasesByReason = {}
|
|
52
258
|
}
|
package/lib/stream-rotate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defaultConcurrencyQueue } from './concurrency-queue.js'
|
|
2
|
+
import { recordSessionCacheUsage } from './session-pin.js'
|
|
2
3
|
import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
|
|
3
4
|
import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
|
|
4
5
|
import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
|
|
@@ -147,6 +148,14 @@ export async function* streamWithRotation({
|
|
|
147
148
|
)
|
|
148
149
|
for await (const chunk of guardedStream) {
|
|
149
150
|
firstChunkDelivered = true
|
|
151
|
+
const cachedTokens = Number(
|
|
152
|
+
(chunk && chunk.usage && (chunk.usage.cacheReadTokens || chunk.usage.cachedTokens)) ||
|
|
153
|
+
(chunk && chunk.tokens && (chunk.tokens.cacheRead || chunk.tokens.cached)) ||
|
|
154
|
+
0
|
|
155
|
+
)
|
|
156
|
+
if (cachedTokens > 0 && options && options.sessionId) {
|
|
157
|
+
recordSessionCacheUsage(options.sessionId, cachedTokens)
|
|
158
|
+
}
|
|
150
159
|
yield chunk
|
|
151
160
|
}
|
|
152
161
|
currentAttempt.status = 'success'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-subscriptions",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.63",
|
|
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",
|