@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.
@@ -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,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
- while (true) {
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.61",
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",