@goodandready/dsh-subscriptions 0.6.60 → 0.6.62

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,4 +1,5 @@
1
- import { pickAccount, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
1
+ import { defaultConcurrencyQueue } from './concurrency-queue.js'
2
+ import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
2
3
  import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
3
4
  import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
4
5
  import { withRunawayGuard } from './runaway-guard.js'
@@ -11,17 +12,39 @@ export async function* streamWithRotation({
11
12
  streamOnce,
12
13
  options,
13
14
  onCooldown,
15
+ onDecisionTrace,
14
16
  cascadeFallback, // #349: optional cross-vendor cascading fallback generator
15
17
  offlineFallback, // #174: optional fallback generator if all accounts exhausted
16
18
  webhookUrl,
17
19
  fetchImpl = fetch,
20
+ concurrencyQueue = defaultConcurrencyQueue,
18
21
  }) {
19
22
  const pool = (accounts || []).map((account) => ({ ...account }))
20
23
  let lastError = null
21
24
  const tried = new Set()
22
25
 
26
+ const decisionTrace = {
27
+ sessionId: options && options.sessionId,
28
+ provider: options && options.provider,
29
+ model: options && options.model,
30
+ attempts: [],
31
+ finalOutcome: null,
32
+ chosenRef: null,
33
+ }
34
+
35
+ const syncTrace = (outcome, extra = {}) => {
36
+ decisionTrace.finalOutcome = outcome
37
+ Object.assign(decisionTrace, extra)
38
+ if (options && options._decisionTrace) {
39
+ Object.assign(options._decisionTrace, decisionTrace)
40
+ }
41
+ if (typeof onDecisionTrace === 'function') {
42
+ try { onDecisionTrace(decisionTrace) } catch { /* ignore trace callback error */ }
43
+ }
44
+ }
45
+
23
46
  while (true) {
24
- const account = pickAccount(pool, nowMs(), {
47
+ const pickRes = pickAccountWithTrace(pool, nowMs(), {
25
48
  switchAtRemaining,
26
49
  family: modelFamily(options && options.provider, options && options.model),
27
50
  sessionId: options && options.sessionId,
@@ -31,30 +54,84 @@ export async function* streamWithRotation({
31
54
  drainBeforeReset: options && (options.drainBeforeReset !== undefined ? options.drainBeforeReset : (options.config && options.config.drainBeforeReset)),
32
55
  drainSafetyPercent: options && (options.drainSafetyPercent !== undefined ? options.drainSafetyPercent : (options.config && options.config.drainSafetyPercent)),
33
56
  })
57
+ const account = pickRes.account
34
58
 
35
59
  if (!account || tried.has(account.ref || account.id)) {
36
60
  if (cascadeFallback) {
37
- // #349 Cascading Cross-Vendor Fallback
61
+ // #349 Cascading Cross-Vendor Fallback (#517 trace)
62
+ decisionTrace.attempts.push({
63
+ attempt: decisionTrace.attempts.length + 1,
64
+ kind: 'cascade_fallback',
65
+ reason: 'primary_pool_exhausted',
66
+ lastError: lastError ? (lastError.code || lastError.message) : null,
67
+ })
68
+ syncTrace('cascade_fallback')
38
69
  yield* cascadeFallback(options, lastError)
39
70
  return
40
71
  }
41
72
  if (offlineFallback) {
42
- // #174 Local Mock Server Offline Fallback
73
+ // #174 Local Mock Server Offline Fallback (#517 trace)
74
+ decisionTrace.attempts.push({
75
+ attempt: decisionTrace.attempts.length + 1,
76
+ kind: 'offline_fallback',
77
+ reason: 'all_accounts_exhausted',
78
+ lastError: lastError ? (lastError.code || lastError.message) : null,
79
+ })
80
+ syncTrace('offline_fallback')
43
81
  yield* offlineFallback(options, lastError)
44
82
  return
45
83
  }
84
+ syncTrace('exhausted')
46
85
  if (lastError) throw lastError
47
86
  const err = /** @type {Error & { code?: string }} */ (new Error('no usable subscription account for this provider'))
48
87
  err.code = 'RATE_LIMIT'
49
88
  throw err
50
89
  }
51
90
 
91
+ const currentAttempt = {
92
+ attempt: decisionTrace.attempts.length + 1,
93
+ ref: account.ref || account.id,
94
+ decisionReason: pickRes.trace.decisionReason,
95
+ tier: pickRes.trace.tier,
96
+ selectionMetrics: pickRes.trace.selectionMetrics,
97
+ evaluations: pickRes.trace.evaluations,
98
+ status: 'pending',
99
+ }
100
+ decisionTrace.attempts.push(currentAttempt)
101
+ decisionTrace.chosenRef = account.ref || account.id
102
+
52
103
  tried.add(account.ref || account.id)
53
104
 
105
+ const queueKey = account.ref || account.id || 'default'
106
+ const queueConfig = {
107
+ signal: options && options.signal,
108
+ timeoutMs: options && (options.queueTimeoutMs !== undefined ? options.queueTimeoutMs : (options.config && options.config.queueTimeoutMs)),
109
+ maxConcurrency: options && (options.accountMaxConcurrency !== undefined ? options.accountMaxConcurrency : (options.config && options.config.accountMaxConcurrency)),
110
+ maxQueueSize: options && (options.maxQueueSize !== undefined ? options.maxQueueSize : (options.config && options.config.maxQueueSize)),
111
+ enabled: options && (options.enableQueue !== undefined ? options.enableQueue : (options.config && options.config.enableQueue !== undefined ? options.config.enableQueue : true)),
112
+ }
113
+
114
+ let releaseSlot = null
115
+ try {
116
+ releaseSlot = await concurrencyQueue.acquire(queueKey, queueConfig)
117
+ } catch (queueErr) {
118
+ if (queueErr && (queueErr.code === 'ABORT_ERR' || queueErr.name === 'AbortError')) {
119
+ currentAttempt.status = 'aborted'
120
+ currentAttempt.error = queueErr.message
121
+ syncTrace('aborted')
122
+ throw queueErr
123
+ }
124
+ currentAttempt.status = queueErr && queueErr.code === 'QUEUE_OVERFLOW' ? 'queue_overflow' : 'queue_timeout'
125
+ currentAttempt.error = queueErr ? queueErr.message : String(queueErr)
126
+ lastError = queueErr
127
+ continue
128
+ }
129
+
54
130
  let firstChunkDelivered = false
55
131
  let regionRetries = 0
56
132
 
57
- while (true) {
133
+ try {
134
+ while (true) {
58
135
  try {
59
136
  const runawayState = options && options._runawayState ? options._runawayState : { tripped: false, reason: null }
60
137
  const runawayOpts = {
@@ -72,6 +149,8 @@ export async function* streamWithRotation({
72
149
  firstChunkDelivered = true
73
150
  yield chunk
74
151
  }
152
+ currentAttempt.status = 'success'
153
+ syncTrace('success', { chosenRef: account.ref || account.id })
75
154
  return
76
155
  } catch (err) {
77
156
  lastError = err
@@ -82,12 +161,21 @@ export async function* streamWithRotation({
82
161
  // Transient region 400 error: retry same account up to 2 times before rotating (#391 / GH #9)
83
162
  if (isRegionError(err) && regionRetries < 2 && (!options || !options.signal || !options.signal.aborted)) {
84
163
  regionRetries++
164
+ currentAttempt.regionRetries = regionRetries
85
165
  await new Promise((r) => setTimeout(r, 400 * regionRetries))
86
166
  if (options && options.signal && options.signal.aborted) throw err
87
167
  continue
88
168
  }
89
169
 
90
- if (!isSwitchableError(err)) throw err
170
+ if (!isSwitchableError(err)) {
171
+ currentAttempt.status = 'fatal_error'
172
+ currentAttempt.error = err ? (err.message || String(err)) : 'unknown'
173
+ syncTrace('fatal_error')
174
+ throw err
175
+ }
176
+
177
+ currentAttempt.status = 'switched'
178
+ currentAttempt.switchError = err ? (err.code || err.status || err.message) : 'switchable'
91
179
 
92
180
  // Move slot to cooldown and quarantine (#172)
93
181
  const cooled = markCooldown(account, nowMs(), cooldownMs, modelFamily(options && options.provider, options && options.model))
@@ -114,6 +202,9 @@ export async function* streamWithRotation({
114
202
  account.quarantineAttempts = (account.quarantineAttempts || 0) + 1
115
203
  }
116
204
 
205
+ currentAttempt.quarantineReason = account.quarantineReason || reason
206
+ currentAttempt.quarantineUntil = account.quarantineUntil
207
+
117
208
  try {
118
209
  notifyQuarantineEntered({
119
210
  ref: account.ref,
@@ -144,5 +235,10 @@ export async function* streamWithRotation({
144
235
  break
145
236
  }
146
237
  }
238
+ } finally {
239
+ if (typeof releaseSlot === 'function') {
240
+ releaseSlot()
241
+ }
242
+ }
147
243
  }
148
244
  }
@@ -1,5 +1,5 @@
1
1
  import { isProvider } from "./refs.js"
2
- import { pickAccount, markCooldown, isSwitchableError, modelFamily } from "./rotate.js"
2
+ import { pickAccountWithTrace, markCooldown, isSwitchableError, modelFamily } from "./rotate.js"
3
3
  import { quotaSnapshot } from "./ratelimit.js"
4
4
  import { pickFetch } from "./proxy.js"
5
5
  import { fetchWithTimeout } from "./http.js"
@@ -118,14 +118,16 @@ export function createSubscriptionsService(deps) {
118
118
  // copy pool for rotation
119
119
  const pool = list.map((a) => ({ ...a }))
120
120
  const tried = new Set()
121
+ const attempts = []
121
122
  let lastError = null
122
123
  while (true) {
123
- const account = pickAccount(pool, Date.now(), {
124
+ const pickRes = pickAccountWithTrace(pool, Date.now(), {
124
125
  switchAtRemaining: thr,
125
126
  family: modelFamily(provider, body && body.model),
126
127
  drainBeforeReset: typeof deps.drainBeforeReset === 'function' ? deps.drainBeforeReset() : deps.drainBeforeReset,
127
128
  drainSafetyPercent: typeof deps.drainSafetyPercent === 'function' ? deps.drainSafetyPercent() : deps.drainSafetyPercent,
128
129
  })
130
+ const account = pickRes.account
129
131
  if (!account) {
130
132
  if (lastError) throw lastError
131
133
  const err = new Error("no usable subscription account for this provider")
@@ -139,6 +141,16 @@ export function createSubscriptionsService(deps) {
139
141
  throw err
140
142
  }
141
143
  tried.add(account.ref)
144
+ const currentAttempt = {
145
+ attempt: attempts.length + 1,
146
+ ref: account.ref,
147
+ decisionReason: pickRes.trace.decisionReason,
148
+ tier: pickRes.trace.tier,
149
+ selectionMetrics: pickRes.trace.selectionMetrics,
150
+ evaluations: pickRes.trace.evaluations,
151
+ status: 'pending',
152
+ }
153
+ attempts.push(currentAttempt)
142
154
  try {
143
155
  const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
144
156
  const cfg = deps.vendorConfig(provider)
@@ -235,7 +247,8 @@ export function createSubscriptionsService(deps) {
235
247
  else err.code = "VENDOR"
236
248
  throw err
237
249
  }
238
- // #65: record in the request and cost history.
250
+ currentAttempt.status = 'success'
251
+ // #65: record in the request and cost history with decision trace (#517)
239
252
  if (typeof deps.recordHistory === "function") {
240
253
  try {
241
254
  deps.recordHistory({
@@ -247,11 +260,18 @@ export function createSubscriptionsService(deps) {
247
260
  status: res.status || 200,
248
261
  ms: Date.now() - t0,
249
262
  kind: "request",
263
+ decisionTrace: {
264
+ attempts,
265
+ finalOutcome: 'success',
266
+ chosenRef: account.ref,
267
+ },
250
268
  })
251
269
  } catch { /* ignore subscriptions error */ }
252
270
  }
253
271
  return res
254
272
  } catch (err) {
273
+ currentAttempt.status = 'failed'
274
+ currentAttempt.switchError = err ? (err.code || err.status || err.message) : 'switchable'
255
275
  lastError = err
256
276
  if (!isSwitchableError(err)) throw err
257
277
  const cooled = markCooldown(account, Date.now(), cooldownMs, modelFamily(provider, body && body.model))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.60",
3
+ "version": "0.6.62",
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",