@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
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,3 +1,5 @@
|
|
|
1
|
+
import { defaultConcurrencyQueue } from './concurrency-queue.js'
|
|
2
|
+
import { recordSessionCacheUsage } from './session-pin.js'
|
|
1
3
|
import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
|
|
2
4
|
import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
|
|
3
5
|
import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
|
|
@@ -16,6 +18,7 @@ export async function* streamWithRotation({
|
|
|
16
18
|
offlineFallback, // #174: optional fallback generator if all accounts exhausted
|
|
17
19
|
webhookUrl,
|
|
18
20
|
fetchImpl = fetch,
|
|
21
|
+
concurrencyQueue = defaultConcurrencyQueue,
|
|
19
22
|
}) {
|
|
20
23
|
const pool = (accounts || []).map((account) => ({ ...account }))
|
|
21
24
|
let lastError = null
|
|
@@ -100,10 +103,36 @@ export async function* streamWithRotation({
|
|
|
100
103
|
|
|
101
104
|
tried.add(account.ref || account.id)
|
|
102
105
|
|
|
106
|
+
const queueKey = account.ref || account.id || 'default'
|
|
107
|
+
const queueConfig = {
|
|
108
|
+
signal: options && options.signal,
|
|
109
|
+
timeoutMs: options && (options.queueTimeoutMs !== undefined ? options.queueTimeoutMs : (options.config && options.config.queueTimeoutMs)),
|
|
110
|
+
maxConcurrency: options && (options.accountMaxConcurrency !== undefined ? options.accountMaxConcurrency : (options.config && options.config.accountMaxConcurrency)),
|
|
111
|
+
maxQueueSize: options && (options.maxQueueSize !== undefined ? options.maxQueueSize : (options.config && options.config.maxQueueSize)),
|
|
112
|
+
enabled: options && (options.enableQueue !== undefined ? options.enableQueue : (options.config && options.config.enableQueue !== undefined ? options.config.enableQueue : true)),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let releaseSlot = null
|
|
116
|
+
try {
|
|
117
|
+
releaseSlot = await concurrencyQueue.acquire(queueKey, queueConfig)
|
|
118
|
+
} catch (queueErr) {
|
|
119
|
+
if (queueErr && (queueErr.code === 'ABORT_ERR' || queueErr.name === 'AbortError')) {
|
|
120
|
+
currentAttempt.status = 'aborted'
|
|
121
|
+
currentAttempt.error = queueErr.message
|
|
122
|
+
syncTrace('aborted')
|
|
123
|
+
throw queueErr
|
|
124
|
+
}
|
|
125
|
+
currentAttempt.status = queueErr && queueErr.code === 'QUEUE_OVERFLOW' ? 'queue_overflow' : 'queue_timeout'
|
|
126
|
+
currentAttempt.error = queueErr ? queueErr.message : String(queueErr)
|
|
127
|
+
lastError = queueErr
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
103
131
|
let firstChunkDelivered = false
|
|
104
132
|
let regionRetries = 0
|
|
105
133
|
|
|
106
|
-
|
|
134
|
+
try {
|
|
135
|
+
while (true) {
|
|
107
136
|
try {
|
|
108
137
|
const runawayState = options && options._runawayState ? options._runawayState : { tripped: false, reason: null }
|
|
109
138
|
const runawayOpts = {
|
|
@@ -119,6 +148,14 @@ export async function* streamWithRotation({
|
|
|
119
148
|
)
|
|
120
149
|
for await (const chunk of guardedStream) {
|
|
121
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
|
+
}
|
|
122
159
|
yield chunk
|
|
123
160
|
}
|
|
124
161
|
currentAttempt.status = 'success'
|
|
@@ -207,5 +244,10 @@ export async function* streamWithRotation({
|
|
|
207
244
|
break
|
|
208
245
|
}
|
|
209
246
|
}
|
|
247
|
+
} finally {
|
|
248
|
+
if (typeof releaseSlot === 'function') {
|
|
249
|
+
releaseSlot()
|
|
250
|
+
}
|
|
251
|
+
}
|
|
210
252
|
}
|
|
211
253
|
}
|
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",
|