@goodandready/dsh-subscriptions 0.6.61 → 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 +6 -0
- package/README.ru.md +6 -0
- package/README.zh.md +4 -0
- package/lib/analytics.js +338 -0
- package/lib/client.js +298 -2
- package/lib/concurrency-queue.js +222 -0
- package/lib/config-schema.js +8 -0
- package/lib/diagnostic-wizard.js +408 -0
- package/lib/diagnostics.js +6 -0
- package/lib/history.js +5 -0
- package/lib/rotate.js +23 -8
- package/lib/routes/status.js +76 -0
- package/lib/session-pin.js +216 -10
- package/lib/stream-rotate.js +43 -1
- package/package.json +1 -1
|
@@ -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,3 +1,6 @@
|
|
|
1
|
+
import { defaultConcurrencyQueue } from './concurrency-queue.js'
|
|
2
|
+
import { calculateCacheMetrics } from './analytics.js'
|
|
3
|
+
import { getSessionPinStats } from './session-pin.js'
|
|
1
4
|
import { maskText } from './mask.js'
|
|
2
5
|
import { normalizeSlots } from './accounts.js'
|
|
3
6
|
import { PROVIDERS } from './refs.js'
|
|
@@ -46,6 +49,9 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
|
|
|
46
49
|
runtime: { node: process.version, platform: process.platform, arch: process.arch, uptimeSec: Math.round(process.uptime()) },
|
|
47
50
|
providers,
|
|
48
51
|
requests: { total: rows.length, byStatus, lastErrors },
|
|
52
|
+
cache: calculateCacheMetrics(rows),
|
|
53
|
+
queue: defaultConcurrencyQueue.stats(),
|
|
54
|
+
pins: getSessionPinStats(),
|
|
49
55
|
decisions: {
|
|
50
56
|
recent: rows.filter((r) => r && r.decisionTrace).slice(0, 10).map((r) => ({
|
|
51
57
|
ts: r.ts,
|
package/lib/history.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { calculateAnalytics } from './analytics.js'
|
|
1
2
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
3
|
import { join } from 'node:path'
|
|
3
4
|
import { homedir } from 'node:os'
|
|
@@ -298,4 +299,8 @@ export class HistoryStore {
|
|
|
298
299
|
recentDecisions,
|
|
299
300
|
}
|
|
300
301
|
}
|
|
302
|
+
|
|
303
|
+
getAnalytics(accounts = [], nowMs = Date.now()) {
|
|
304
|
+
return calculateAnalytics(this.rows, accounts, nowMs)
|
|
305
|
+
}
|
|
301
306
|
}
|
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,3 +1,6 @@
|
|
|
1
|
+
import { defaultConcurrencyQueue } from '../concurrency-queue.js'
|
|
2
|
+
import { calculateAnalytics } from '../analytics.js'
|
|
3
|
+
import { runDiagnosticWizard } from '../diagnostic-wizard.js'
|
|
1
4
|
import { buildEnrichedCatalog } from '../models-catalog.js'
|
|
2
5
|
// #374: Sensitive GET routes protected by same-origin check
|
|
3
6
|
import { readFileSync } from 'node:fs'
|
|
@@ -49,6 +52,7 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
49
52
|
liveConfig: live(),
|
|
50
53
|
hideDeprecated: !!live().hideDeprecatedModels,
|
|
51
54
|
}),
|
|
55
|
+
queue: defaultConcurrencyQueue.stats(),
|
|
52
56
|
}
|
|
53
57
|
}
|
|
54
58
|
|
|
@@ -538,4 +542,76 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
538
542
|
}),
|
|
539
543
|
}), 'dsh-subscriptions: /alerts')
|
|
540
544
|
|
|
545
|
+
|
|
546
|
+
// #511: Detailed cache and subscription analytics endpoint
|
|
547
|
+
ctx.effect(() => ctx.webServer.register({
|
|
548
|
+
kind: 'exact',
|
|
549
|
+
path: '/dsh-subscriptions/analytics',
|
|
550
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
551
|
+
if (req.method !== 'GET') {
|
|
552
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
556
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
const accounts = await accountsView()
|
|
560
|
+
const analytics = typeof history.getAnalytics === 'function'
|
|
561
|
+
? history.getAnalytics(accounts)
|
|
562
|
+
: calculateAnalytics(history.all(), accounts)
|
|
563
|
+
writeJson(res, 200, { ok: true, analytics })
|
|
564
|
+
}),
|
|
565
|
+
}), 'dsh-subscriptions: /analytics')
|
|
566
|
+
|
|
567
|
+
// #518: Concurrency and request queue status
|
|
568
|
+
ctx.effect(() => ctx.webServer.register({
|
|
569
|
+
kind: 'exact',
|
|
570
|
+
path: '/dsh-subscriptions/queue',
|
|
571
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
572
|
+
if (req.method !== 'GET') {
|
|
573
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
577
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
const queueStats = defaultConcurrencyQueue.stats()
|
|
581
|
+
writeJson(res, 200, { ok: true, queue: queueStats })
|
|
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')
|
|
541
617
|
}
|