@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.
- package/README.md +6 -0
- package/README.ru.md +6 -0
- package/README.zh.md +6 -0
- package/lib/adapter-manager.js +14 -0
- package/lib/adapter.js +18 -6
- package/lib/analytics.js +338 -0
- package/lib/client.js +267 -8
- package/lib/concurrency-queue.js +222 -0
- package/lib/config-schema.js +8 -0
- package/lib/diagnostics.js +17 -0
- package/lib/history.js +22 -0
- package/lib/models-catalog.js +240 -0
- package/lib/rotate.js +115 -14
- package/lib/routes/status.js +74 -1
- package/lib/session-pin.js +4 -0
- package/lib/stream-rotate.js +102 -6
- package/lib/subscriptions.js +23 -3
- package/package.json +1 -1
package/lib/stream-rotate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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))
|
|
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
|
}
|
package/lib/subscriptions.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isProvider } from "./refs.js"
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|