@goodandready/dsh-subscriptions 0.6.62 → 0.6.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -100,6 +100,9 @@ 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.
105
+ * **Desktop Electron Compatibility (#524)**: Non-HTTP app schemes (`dsh-app://`) bypass web-only host equality checks while strictly preserving loopback socket verification and fail-closed cross-site protection.
103
106
 
104
107
  ---
105
108
 
package/README.ru.md CHANGED
@@ -100,6 +100,9 @@ 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)**: Пошаговый чек-лист состояния аккаунтов (валидность ключей, прокси, токен, доступность каталога) без платного инференса и с автоматическим маскированием секретов.
105
+ * **Совместимость с DSH Desktop (#524)**: Специфичные схемы Electron (`dsh-app://`) пропускаются к проверке loopback-сокета, устраняя ложные блокировки 403 на десктопных клиентах при сохранении полной защиты от cross-site атак.
103
106
 
104
107
  ---
105
108
 
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
+ }
@@ -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/http.js CHANGED
@@ -93,13 +93,17 @@ export function isTrustedSettingsRequest(request, targetCtx) {
93
93
  const secFetchSite = String(headers['sec-fetch-site'] || '').toLowerCase()
94
94
  if (secFetchSite === 'cross-site') return false
95
95
 
96
- // 2. If origin header is present, it must match host
96
+ // 2. If origin header is present, verify origin matches host (#524)
97
97
  const host = String(Array.isArray(headers.host) ? headers.host[0] : (headers.host || headers['x-forwarded-host'] || ''))
98
98
  const origin = headers.origin
99
99
  if (origin) {
100
100
  try {
101
101
  const u = new URL(origin)
102
- if (!host || u.host.toLowerCase() !== host.toLowerCase()) {
102
+ if (u.protocol === 'http:' || u.protocol === 'https:') {
103
+ if (!host || u.host.toLowerCase() !== host.toLowerCase()) {
104
+ return false
105
+ }
106
+ } else if (u.protocol !== 'dsh-app:') {
103
107
  return false
104
108
  }
105
109
  } catch {
@@ -107,12 +111,16 @@ export function isTrustedSettingsRequest(request, targetCtx) {
107
111
  }
108
112
  }
109
113
 
110
- // 3. If referer header is present and no origin, referer must match host
114
+ // 3. If referer header is present and no origin, verify http/https referer matches host (#524)
111
115
  const referer = headers.referer
112
116
  if (!origin && referer) {
113
117
  try {
114
118
  const u = new URL(referer)
115
- if (!host || u.host.toLowerCase() !== host.toLowerCase()) {
119
+ if (u.protocol === 'http:' || u.protocol === 'https:') {
120
+ if (!host || u.host.toLowerCase() !== host.toLowerCase()) {
121
+ return false
122
+ }
123
+ } else if (u.protocol !== 'dsh-app:') {
116
124
  return false
117
125
  }
118
126
  } catch {
@@ -166,13 +174,21 @@ export function isTrustedSettingsRequest(request, targetCtx) {
166
174
  if (origin && host) {
167
175
  try {
168
176
  const u = new URL(origin)
169
- if (u.host.toLowerCase() === host.toLowerCase()) return true
177
+ if (u.protocol === 'http:' || u.protocol === 'https:') {
178
+ if (u.host.toLowerCase() === host.toLowerCase()) return true
179
+ } else if (u.protocol === 'dsh-app:') {
180
+ return true
181
+ }
170
182
  } catch { /* bestEffort */ }
171
183
  }
172
184
  if (referer && host) {
173
185
  try {
174
186
  const u = new URL(referer)
175
- if (u.host.toLowerCase() === host.toLowerCase()) return true
187
+ if (u.protocol === 'http:' || u.protocol === 'https:') {
188
+ if (u.host.toLowerCase() === host.toLowerCase()) return true
189
+ } else if (u.protocol === 'dsh-app:') {
190
+ return true
191
+ }
176
192
  } catch { /* bestEffort */ }
177
193
  }
178
194
  }
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: 'session_pinned',
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: 'session_pinned',
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: isCooldown ? 'pinned_cooldown' : isPinnedZero ? 'pinned_zero_quota' : 'pinned_exhausted',
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: !pinned.hasToken ? 'pinned_no_token' : 'pinned_quarantine',
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,
@@ -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
  }
@@ -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
- const DEFAULT_TTL_MS = 30 * 60 * 1000 // 30 mins
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 {void}
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 for ttlMs (default 30 minutes).
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 {number} [ttlMs]
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, ttlMs = DEFAULT_TTL_MS) {
83
+ export function pinSession(sessionId, accountRef, options = {}) {
25
84
  if (!sessionId || !accountRef) return
26
- if (sessionPins.size >= 50) {
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
- expiresAt: Date.now() + ttlMs
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
- * @returns {string|null} the bound account ref, or null when absent/expired
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 (Date.now() > entry.expiresAt) {
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
  }
@@ -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.62",
3
+ "version": "0.6.64",
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",