@goodandready/dsh-subscriptions 0.6.61 → 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 +4 -0
- package/README.ru.md +4 -0
- package/README.zh.md +4 -0
- package/lib/analytics.js +338 -0
- package/lib/client.js +169 -0
- package/lib/concurrency-queue.js +222 -0
- package/lib/config-schema.js +8 -0
- package/lib/diagnostics.js +4 -0
- package/lib/history.js +5 -0
- package/lib/routes/status.js +42 -0
- package/lib/stream-rotate.js +34 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -244,6 +244,10 @@ dsh-subscriptions:
|
|
|
244
244
|
codexFastMode: false # v0.4.17: service_tier priority (1.5x speed tier)
|
|
245
245
|
composerQuota: 'off' # v0.4.18: composer indicator: off | percent | bar | forecast
|
|
246
246
|
enableToasts: true # v0.6.59: real-time in-app toast alerts on quarantine and quota events
|
|
247
|
+
enableQueue: true # v0.6.62: bounded concurrency queue to prevent bursts of 429 errors
|
|
248
|
+
accountMaxConcurrency: 2 # v0.6.62: max concurrent active streams per account
|
|
249
|
+
maxQueueSize: 20 # v0.6.62: max queue depth before load shedding / reject
|
|
250
|
+
queueTimeoutMs: 30000 # v0.6.62: max queue wait duration in ms before timeout
|
|
247
251
|
# Per-slot fields (v0.4.9): expiresAt (ms epoch), proxyUrl (http/https/socks5://)
|
|
248
252
|
accounts:
|
|
249
253
|
codex:
|
package/README.ru.md
CHANGED
|
@@ -217,6 +217,10 @@ dsh-subscriptions:
|
|
|
217
217
|
codexFastMode: false # v0.4.17: service_tier priority (скоростной тир 1.5x)
|
|
218
218
|
composerQuota: 'off' # v0.4.18: индикатор в строке ввода: off | percent | bar | forecast
|
|
219
219
|
enableToasts: true # v0.6.59: всплывающие уведомления при событиях карантина и квот
|
|
220
|
+
enableQueue: true # v0.6.62: ограниченная очередь параллельных запросов к аккаунту
|
|
221
|
+
accountMaxConcurrency: 2 # v0.6.62: макс. число параллельных стримов на аккаунт
|
|
222
|
+
maxQueueSize: 20 # v0.6.62: макс. глубина очереди до сброса нагрузки
|
|
223
|
+
queueTimeoutMs: 30000 # v0.6.62: таймаут ожидания слота в очереди в мс
|
|
220
224
|
# Поля слота (v0.4.9): expiresAt (ms), proxyUrl (http/https/socks5://)
|
|
221
225
|
accounts:
|
|
222
226
|
codex:
|
package/README.zh.md
CHANGED
|
@@ -126,6 +126,10 @@ graph LR
|
|
|
126
126
|
* **手动隔离管理(暂停与预热探测)**:支持在设置面板的账户卡片中一键暂停隔离活跃账户(`POST /dsh-subscriptions/quarantine/park`),或立即触发真实上游预热微探测(`probeWarmup`)并在验证成功后即时解除隔离(`POST /dsh-subscriptions/quarantine/probe`)。
|
|
127
127
|
* **动态额度重置倒计时**:在顶部会话栏 `SubsPill` 和账户卡片中秒级动态更新,精准展示滚动配额窗口(`five_hour`、`seven_day`、`primary_window`)的剩余解锁时间。
|
|
128
128
|
* **应用内即时弹窗提醒(Toast)**:当账户进入隔离、健康恢复解除隔离或额度达到预警线时,在聊天界面弹出轻量级浮动提醒。通过 `enableToasts: true` 控制。
|
|
129
|
+
enableQueue: true # v0.6.62: 账户并发请求排队与限流
|
|
130
|
+
accountMaxConcurrency: 2 # v0.6.62: 单账户最大并行活跃流
|
|
131
|
+
maxQueueSize: 20 # v0.6.62: 队列最大等待深度
|
|
132
|
+
queueTimeoutMs: 30000 # v0.6.62: 队列最大等待超时时间(毫秒)
|
|
129
133
|
* **自动化 Webhook 告警集成**:通过 `notifyQuarantineEntered` 与 `notifyQuarantineReleased` 自动向外部通知系统派发隔离生命周期事件。
|
|
130
134
|
|
|
131
135
|
### Antigravity 客户端密钥架构与无感 CLI 令牌刷新 (v0.6.60)
|
package/lib/analytics.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Subscription and cache analytics, performance aggregates, and quota forecasting.
|
|
2
|
+
// Enforces 0 Cyrillic characters.
|
|
3
|
+
|
|
4
|
+
export function calculatePercentile(values, percentile) {
|
|
5
|
+
if (!Array.isArray(values) || values.length === 0) return null
|
|
6
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
7
|
+
const index = Math.min(
|
|
8
|
+
sorted.length - 1,
|
|
9
|
+
Math.max(0, Math.ceil((percentile / 100) * sorted.length) - 1)
|
|
10
|
+
)
|
|
11
|
+
return sorted[index]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function calculateCacheMetrics(entries = []) {
|
|
15
|
+
let promptTokens = 0
|
|
16
|
+
let completionTokens = 0
|
|
17
|
+
let cacheReadTokens = 0
|
|
18
|
+
let cacheCreationTokens = 0
|
|
19
|
+
let requestsWithCacheRead = 0
|
|
20
|
+
|
|
21
|
+
for (const r of entries) {
|
|
22
|
+
if (!r) continue
|
|
23
|
+
const prompt = typeof r.inputTokens === 'number'
|
|
24
|
+
? r.inputTokens
|
|
25
|
+
: (r.tokens && typeof r.tokens.prompt === 'number' ? r.tokens.prompt : 0)
|
|
26
|
+
const completion = typeof r.outputTokens === 'number'
|
|
27
|
+
? r.outputTokens
|
|
28
|
+
: (r.tokens && typeof r.tokens.completion === 'number' ? r.tokens.completion : 0)
|
|
29
|
+
const read = typeof r.cacheReadTokens === 'number'
|
|
30
|
+
? r.cacheReadTokens
|
|
31
|
+
: (r.tokens && typeof r.tokens.cacheRead === 'number' ? r.tokens.cacheRead : 0)
|
|
32
|
+
const creation = typeof r.cacheCreationTokens === 'number'
|
|
33
|
+
? r.cacheCreationTokens
|
|
34
|
+
: (r.tokens && typeof r.tokens.cacheCreation === 'number' ? r.tokens.cacheCreation : 0)
|
|
35
|
+
|
|
36
|
+
promptTokens += prompt
|
|
37
|
+
completionTokens += completion
|
|
38
|
+
cacheReadTokens += read
|
|
39
|
+
cacheCreationTokens += creation
|
|
40
|
+
if (read > 0) requestsWithCacheRead++
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const totalTokens = promptTokens + completionTokens
|
|
44
|
+
const cacheTotal = promptTokens + cacheReadTokens
|
|
45
|
+
const cacheHitRate = cacheTotal > 0
|
|
46
|
+
? Math.round((cacheReadTokens / cacheTotal) * 1000) / 10
|
|
47
|
+
: 0
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
promptTokens,
|
|
51
|
+
completionTokens,
|
|
52
|
+
totalTokens,
|
|
53
|
+
cacheReadTokens,
|
|
54
|
+
cacheCreationTokens,
|
|
55
|
+
cacheHitRate,
|
|
56
|
+
requestsWithCacheRead,
|
|
57
|
+
cacheHitCount: requestsWithCacheRead,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function calculatePerformanceMetrics(entries = []) {
|
|
62
|
+
const ttftList = []
|
|
63
|
+
const tpsList = []
|
|
64
|
+
const latencyList = []
|
|
65
|
+
|
|
66
|
+
let ttftSum = 0
|
|
67
|
+
let tpsSum = 0
|
|
68
|
+
let latencySum = 0
|
|
69
|
+
|
|
70
|
+
for (const r of entries) {
|
|
71
|
+
if (!r) continue
|
|
72
|
+
if (typeof r.ttftMs === 'number' && r.ttftMs >= 0) {
|
|
73
|
+
ttftList.push(r.ttftMs)
|
|
74
|
+
ttftSum += r.ttftMs
|
|
75
|
+
}
|
|
76
|
+
if (typeof r.tps === 'number' && r.tps > 0) {
|
|
77
|
+
tpsList.push(r.tps)
|
|
78
|
+
tpsSum += r.tps
|
|
79
|
+
}
|
|
80
|
+
if (typeof r.ms === 'number' && r.ms > 0) {
|
|
81
|
+
latencyList.push(r.ms)
|
|
82
|
+
latencySum += r.ms
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const count = entries.length
|
|
87
|
+
return {
|
|
88
|
+
ttft: {
|
|
89
|
+
count: ttftList.length,
|
|
90
|
+
avgMs: ttftList.length > 0 ? Math.round(ttftSum / ttftList.length) : null,
|
|
91
|
+
p95Ms: calculatePercentile(ttftList, 95),
|
|
92
|
+
minMs: ttftList.length > 0 ? Math.min(...ttftList) : null,
|
|
93
|
+
maxMs: ttftList.length > 0 ? Math.max(...ttftList) : null,
|
|
94
|
+
},
|
|
95
|
+
tps: {
|
|
96
|
+
count: tpsList.length,
|
|
97
|
+
avg: tpsList.length > 0 ? Math.round((tpsSum / tpsList.length) * 10) / 10 : null,
|
|
98
|
+
p95: calculatePercentile(tpsList, 95),
|
|
99
|
+
min: tpsList.length > 0 ? Math.min(...tpsList) : null,
|
|
100
|
+
max: tpsList.length > 0 ? Math.max(...tpsList) : null,
|
|
101
|
+
},
|
|
102
|
+
latency: {
|
|
103
|
+
count: latencyList.length,
|
|
104
|
+
avgMs: latencyList.length > 0 ? Math.round(latencySum / latencyList.length) : 0,
|
|
105
|
+
p95Ms: calculatePercentile(latencyList, 95),
|
|
106
|
+
minMs: latencyList.length > 0 ? Math.min(...latencyList) : 0,
|
|
107
|
+
maxMs: latencyList.length > 0 ? Math.max(...latencyList) : 0,
|
|
108
|
+
totalRequests: count,
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function calculateHourlyBuckets(entries = [], nowMs = Date.now()) {
|
|
114
|
+
const buckets = []
|
|
115
|
+
const ONE_HOUR = 3600000
|
|
116
|
+
|
|
117
|
+
for (let i = 23; i >= 0; i--) {
|
|
118
|
+
const end = nowMs - (i * ONE_HOUR)
|
|
119
|
+
const start = end - ONE_HOUR
|
|
120
|
+
const dateObj = new Date(start)
|
|
121
|
+
const hourNum = dateObj.getHours()
|
|
122
|
+
const hourStr = String(hourNum).padStart(2, '0') + ':00'
|
|
123
|
+
|
|
124
|
+
buckets.push({
|
|
125
|
+
hour: hourNum,
|
|
126
|
+
label: hourStr,
|
|
127
|
+
start,
|
|
128
|
+
end,
|
|
129
|
+
requests: 0,
|
|
130
|
+
successCount: 0,
|
|
131
|
+
errorCount: 0,
|
|
132
|
+
promptTokens: 0,
|
|
133
|
+
completionTokens: 0,
|
|
134
|
+
cacheReadTokens: 0,
|
|
135
|
+
totalTokens: 0,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const cutoff = nowMs - (24 * ONE_HOUR)
|
|
140
|
+
for (const r of entries) {
|
|
141
|
+
if (!r || !r.ts || r.ts < cutoff || r.ts > nowMs) continue
|
|
142
|
+
const age = nowMs - r.ts
|
|
143
|
+
const bucketIndex = 23 - Math.floor(age / ONE_HOUR)
|
|
144
|
+
if (bucketIndex >= 0 && bucketIndex < 24) {
|
|
145
|
+
const b = buckets[bucketIndex]
|
|
146
|
+
b.requests++
|
|
147
|
+
const st = Number(r.status || 0)
|
|
148
|
+
if (st >= 200 && st < 400) b.successCount++
|
|
149
|
+
else if (st >= 400) b.errorCount++
|
|
150
|
+
|
|
151
|
+
const prompt = Number(r.inputTokens || (r.tokens && r.tokens.prompt) || 0)
|
|
152
|
+
const completion = Number(r.outputTokens || (r.tokens && r.tokens.completion) || 0)
|
|
153
|
+
const cacheRead = Number(r.cacheReadTokens || (r.tokens && r.tokens.cacheRead) || 0)
|
|
154
|
+
|
|
155
|
+
b.promptTokens += prompt
|
|
156
|
+
b.completionTokens += completion
|
|
157
|
+
b.cacheReadTokens += cacheRead
|
|
158
|
+
b.totalTokens += (prompt + completion)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return buckets
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function calculateAccountBreakdown(entries = [], accounts = []) {
|
|
166
|
+
const map = {}
|
|
167
|
+
const labels = {}
|
|
168
|
+
for (const a of accounts) {
|
|
169
|
+
if (a && a.ref) {
|
|
170
|
+
labels[a.ref] = a.label || a.ref
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const r of entries) {
|
|
175
|
+
if (!r || !r.ref) continue
|
|
176
|
+
const ref = r.ref
|
|
177
|
+
if (!map[ref]) {
|
|
178
|
+
map[ref] = {
|
|
179
|
+
ref,
|
|
180
|
+
provider: r.provider || 'unknown',
|
|
181
|
+
label: labels[ref] || r.accountLabel || ref,
|
|
182
|
+
totalRequests: 0,
|
|
183
|
+
successRequests: 0,
|
|
184
|
+
errorRequests: 0,
|
|
185
|
+
promptTokens: 0,
|
|
186
|
+
completionTokens: 0,
|
|
187
|
+
cacheReadTokens: 0,
|
|
188
|
+
cacheCreationTokens: 0,
|
|
189
|
+
ttftSum: 0,
|
|
190
|
+
ttftCount: 0,
|
|
191
|
+
tpsSum: 0,
|
|
192
|
+
tpsCount: 0,
|
|
193
|
+
latencySum: 0,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const acc = map[ref]
|
|
197
|
+
acc.totalRequests++
|
|
198
|
+
const st = Number(r.status || 0)
|
|
199
|
+
if (st >= 200 && st < 400) acc.successRequests++
|
|
200
|
+
else if (st >= 400) acc.errorRequests++
|
|
201
|
+
|
|
202
|
+
const prompt = Number(r.inputTokens || (r.tokens && r.tokens.prompt) || 0)
|
|
203
|
+
const completion = Number(r.outputTokens || (r.tokens && r.tokens.completion) || 0)
|
|
204
|
+
const cacheRead = Number(r.cacheReadTokens || (r.tokens && r.tokens.cacheRead) || 0)
|
|
205
|
+
const cacheCreation = Number(r.cacheCreationTokens || (r.tokens && r.tokens.cacheCreation) || 0)
|
|
206
|
+
|
|
207
|
+
acc.promptTokens += prompt
|
|
208
|
+
acc.completionTokens += completion
|
|
209
|
+
acc.cacheReadTokens += cacheRead
|
|
210
|
+
acc.cacheCreationTokens += cacheCreation
|
|
211
|
+
|
|
212
|
+
if (typeof r.ttftMs === 'number' && r.ttftMs >= 0) {
|
|
213
|
+
acc.ttftSum += r.ttftMs
|
|
214
|
+
acc.ttftCount++
|
|
215
|
+
}
|
|
216
|
+
if (typeof r.tps === 'number' && r.tps > 0) {
|
|
217
|
+
acc.tpsSum += r.tps
|
|
218
|
+
acc.tpsCount++
|
|
219
|
+
}
|
|
220
|
+
if (typeof r.ms === 'number' && r.ms > 0) {
|
|
221
|
+
acc.latencySum += r.ms
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const result = {}
|
|
226
|
+
for (const [ref, acc] of Object.entries(map)) {
|
|
227
|
+
const totalCache = acc.promptTokens + acc.cacheReadTokens
|
|
228
|
+
result[ref] = {
|
|
229
|
+
ref,
|
|
230
|
+
provider: acc.provider,
|
|
231
|
+
label: acc.label,
|
|
232
|
+
totalRequests: acc.totalRequests,
|
|
233
|
+
successRequests: acc.successRequests,
|
|
234
|
+
errorRequests: acc.errorRequests,
|
|
235
|
+
promptTokens: acc.promptTokens,
|
|
236
|
+
completionTokens: acc.completionTokens,
|
|
237
|
+
cacheReadTokens: acc.cacheReadTokens,
|
|
238
|
+
cacheCreationTokens: acc.cacheCreationTokens,
|
|
239
|
+
cacheHitRate: totalCache > 0 ? Math.round((acc.cacheReadTokens / totalCache) * 1000) / 10 : 0,
|
|
240
|
+
avgTtftMs: acc.ttftCount > 0 ? Math.round(acc.ttftSum / acc.ttftCount) : null,
|
|
241
|
+
avgTps: acc.tpsCount > 0 ? Math.round((acc.tpsSum / acc.tpsCount) * 10) / 10 : null,
|
|
242
|
+
avgLatencyMs: acc.totalRequests > 0 ? Math.round(acc.latencySum / acc.totalRequests) : 0,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return result
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function calculateQuotaForecast(accounts = [], entries = [], nowMs = Date.now()) {
|
|
249
|
+
const ONE_HOUR = 3600000
|
|
250
|
+
const ONE_DAY = 24 * ONE_HOUR
|
|
251
|
+
const forecast = []
|
|
252
|
+
|
|
253
|
+
for (const acc of accounts) {
|
|
254
|
+
if (!acc || !acc.ref) continue
|
|
255
|
+
const quota = acc.quota
|
|
256
|
+
const ref = acc.ref
|
|
257
|
+
|
|
258
|
+
// Calculate recent usage over the past 24 hours for this account
|
|
259
|
+
let tokens24h = 0
|
|
260
|
+
let requests24h = 0
|
|
261
|
+
let tokens1h = 0
|
|
262
|
+
let requests1h = 0
|
|
263
|
+
|
|
264
|
+
for (const r of entries) {
|
|
265
|
+
if (!r || r.ref !== ref || !r.ts) continue
|
|
266
|
+
const age = nowMs - r.ts
|
|
267
|
+
if (age <= ONE_DAY) {
|
|
268
|
+
const total = Number(r.inputTokens || (r.tokens && r.tokens.prompt) || 0) +
|
|
269
|
+
Number(r.outputTokens || (r.tokens && r.tokens.completion) || 0)
|
|
270
|
+
tokens24h += total
|
|
271
|
+
requests24h++
|
|
272
|
+
if (age <= ONE_HOUR) {
|
|
273
|
+
tokens1h += total
|
|
274
|
+
requests1h++
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const burnRateTokensPerHour = requests1h > 0 ? tokens1h : Math.round(tokens24h / 24)
|
|
280
|
+
|
|
281
|
+
let willExhaustBeforeReset = false
|
|
282
|
+
let projectedExhaustionAt = null
|
|
283
|
+
let hoursToExhaustion = null
|
|
284
|
+
let remainingMs = null
|
|
285
|
+
|
|
286
|
+
if (quota && typeof quota.percentage === 'number') {
|
|
287
|
+
const remainingPercent = Math.max(0, Math.min(100, quota.percentage))
|
|
288
|
+
const resetAt = typeof quota.resetAt === 'number' ? quota.resetAt : null
|
|
289
|
+
|
|
290
|
+
if (resetAt && resetAt > nowMs) {
|
|
291
|
+
remainingMs = resetAt - nowMs
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// If quota is low and active burn rate exists
|
|
295
|
+
if (burnRateTokensPerHour > 0 && remainingPercent > 0) {
|
|
296
|
+
// Approximate burn rate percentage per hour based on 24h consumption
|
|
297
|
+
// If 24h consumed tokens24h and quota dropped, estimate burn % per hour
|
|
298
|
+
const estimatedPercentPerHour = tokens24h > 0 ? Math.max(0.5, (100 - remainingPercent) / 24) : 0
|
|
299
|
+
if (estimatedPercentPerHour > 0) {
|
|
300
|
+
hoursToExhaustion = Math.round((remainingPercent / estimatedPercentPerHour) * 10) / 10
|
|
301
|
+
const msToExhaustion = hoursToExhaustion * ONE_HOUR
|
|
302
|
+
if (remainingMs !== null && msToExhaustion < remainingMs) {
|
|
303
|
+
willExhaustBeforeReset = true
|
|
304
|
+
projectedExhaustionAt = nowMs + msToExhaustion
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
forecast.push({
|
|
311
|
+
ref,
|
|
312
|
+
provider: acc.provider,
|
|
313
|
+
label: acc.label || ref,
|
|
314
|
+
quotaPercent: quota && typeof quota.percentage === 'number' ? quota.percentage : null,
|
|
315
|
+
resetAt: quota && typeof quota.resetAt === 'number' ? quota.resetAt : null,
|
|
316
|
+
burnRateTokensPerHour,
|
|
317
|
+
requests24h,
|
|
318
|
+
tokens24h,
|
|
319
|
+
hoursToExhaustion,
|
|
320
|
+
willExhaustBeforeReset,
|
|
321
|
+
projectedExhaustionAt,
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return forecast
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function calculateAnalytics(entries = [], accounts = [], nowMs = Date.now()) {
|
|
329
|
+
return {
|
|
330
|
+
generatedAt: new Date(nowMs).toISOString(),
|
|
331
|
+
totalRequests: entries.length,
|
|
332
|
+
cache: calculateCacheMetrics(entries),
|
|
333
|
+
performance: calculatePerformanceMetrics(entries),
|
|
334
|
+
hourly: calculateHourlyBuckets(entries, nowMs),
|
|
335
|
+
accounts: calculateAccountBreakdown(entries, accounts),
|
|
336
|
+
forecast: calculateQuotaForecast(accounts, entries, nowMs),
|
|
337
|
+
}
|
|
338
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -122,6 +122,10 @@ window.__ModuleLoader__.load({
|
|
|
122
122
|
'.dsub-stat-box{padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:3px}' +
|
|
123
123
|
'.dsub-stat-val{font-size:16.5px;font-weight:700;color:var(--dsw-alias-label-primary)}' +
|
|
124
124
|
'.dsub-stat-lbl{font-size:11.5px;color:var(--dsw-alias-label-secondary)}' +
|
|
125
|
+
'.dsub-chart-wrap{display:flex;align-items:flex-end;gap:3px;height:52px;padding:6px 0;margin-top:8px;border-bottom:1px solid var(--dsw-alias-border-l3)}' +
|
|
126
|
+
'.dsub-chart-bar{flex:1;background:var(--dsw-alias-brand-primary);border-radius:2px 2px 0 0;min-height:3px;transition:height .2s ease;opacity:0.85;cursor:pointer}' +
|
|
127
|
+
'.dsub-chart-bar:hover{opacity:1;background:var(--dsw-alias-state-success-primary)}' +
|
|
128
|
+
'.dsub-chart-labels{display:flex;justify-content:space-between;font-size:10px;color:var(--dsw-alias-label-secondary);margin-top:3px}' +
|
|
125
129
|
'.dsub-alert-ok{padding:8px 12px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent);color:var(--dsw-alias-state-success-primary);font-size:12.5px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-success-primary) 25%,transparent)}' +
|
|
126
130
|
'.dsub-alert-bad{padding:8px 12px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary);font-size:12.5px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 25%,transparent)}' +
|
|
127
131
|
'.dsub-btn-primary{background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;border-color:transparent !important;font-weight:600}' +
|
|
@@ -569,6 +573,27 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
569
573
|
"toastReleased": "Account active and healthy",
|
|
570
574
|
"toastQuotaWarning": "Quota threshold exceeded",
|
|
571
575
|
"enableToasts": "In-app toast alerts",
|
|
576
|
+
"groupQueue": "Concurrency & Request Queue",
|
|
577
|
+
"enableQueue": "Enable request queue",
|
|
578
|
+
"enableQueueHint": "Queue excess requests when account hits concurrency limit instead of failing with 429",
|
|
579
|
+
"accountMaxConcurrency": "Max concurrent streams per account",
|
|
580
|
+
"accountMaxConcurrencyHint": "Maximum simultaneous streaming requests allowed per account (default 2)",
|
|
581
|
+
"maxQueueSize": "Max queue depth",
|
|
582
|
+
"maxQueueSizeHint": "Maximum queued requests per account before rejecting with overflow (default 20)",
|
|
583
|
+
"queueTimeoutSec": "Queue wait timeout (seconds)",
|
|
584
|
+
"queueTimeoutSecHint": "Maximum wait time in queue before timeout rejection (default 30s)",
|
|
585
|
+
"analyticsTitle": "Subscription & Cache Analytics",
|
|
586
|
+
"analyticsDesc": "Cache hit rate, token caching efficiency, TTFT latency, generation speed, and 24h activity",
|
|
587
|
+
"ttftMetric": "TTFT Latency",
|
|
588
|
+
"speedMetric": "Speed",
|
|
589
|
+
"queueActive": "Active Streams",
|
|
590
|
+
"queueWaiting": "Queued",
|
|
591
|
+
"queueRejected": "Rejected",
|
|
592
|
+
"activity24h": "24-Hour Activity Distribution",
|
|
593
|
+
"forecastTitle": "Quota Exhaustion Forecast",
|
|
594
|
+
"forecastRunway": "Estimated runway",
|
|
595
|
+
"forecastBurnRate": "Burn rate",
|
|
596
|
+
"forecastExhaustWarning": "Quota projected to exhaust before reset window",
|
|
572
597
|
"field.enableToasts": "Show popup toast notifications on quarantine or quota events"
|
|
573
598
|
};
|
|
574
599
|
|
|
@@ -791,6 +816,27 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
791
816
|
"toastReleased": "账户已解除隔离恢复使用",
|
|
792
817
|
"toastQuotaWarning": "额度预警",
|
|
793
818
|
"enableToasts": "应用内弹窗提醒",
|
|
819
|
+
"groupQueue": "并发限制与请求队列",
|
|
820
|
+
"enableQueue": "启用并发请求排队",
|
|
821
|
+
"enableQueueHint": "当账户达到并发上限时将多余请求排队等待,避免直接触发 429 报错",
|
|
822
|
+
"accountMaxConcurrency": "单账户最大并发流数",
|
|
823
|
+
"accountMaxConcurrencyHint": "每个账户允许同时执行的最大流式请求数(默认 2)",
|
|
824
|
+
"maxQueueSize": "队列最大等待深度",
|
|
825
|
+
"maxQueueSizeHint": "每个账户允许排队的最大请求数,超限后拒绝执行(默认 20)",
|
|
826
|
+
"queueTimeoutSec": "排队超时时间(秒)",
|
|
827
|
+
"queueTimeoutSecHint": "在队列中等待槽位的最长时间,超时后自动放弃并回退(默认 30 秒)",
|
|
828
|
+
"analyticsTitle": "订阅与缓存深度分析",
|
|
829
|
+
"analyticsDesc": "缓存命中率、Token 缓存效率、首字响应延迟 (TTFT)、生成速度及 24 小时负载分布",
|
|
830
|
+
"ttftMetric": "首字响应延迟 (TTFT)",
|
|
831
|
+
"speedMetric": "生成速度",
|
|
832
|
+
"queueActive": "活跃流",
|
|
833
|
+
"queueWaiting": "排队中",
|
|
834
|
+
"queueRejected": "已拒绝",
|
|
835
|
+
"activity24h": "24 小时活动分布",
|
|
836
|
+
"forecastTitle": "额度消耗预测与预警",
|
|
837
|
+
"forecastRunway": "预计可用时间",
|
|
838
|
+
"forecastBurnRate": "消耗速率",
|
|
839
|
+
"forecastExhaustWarning": "预计在额度重置前耗尽",
|
|
794
840
|
"field.enableToasts": "当账户发生隔离或额度达到警戒线时显示即时弹窗提醒"
|
|
795
841
|
};
|
|
796
842
|
// #51: live countdown to the quota window reset (account.quota.resetAt).
|
|
@@ -1015,6 +1061,8 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1015
1061
|
const [checkRes, setCheckRes] = React.useState({})
|
|
1016
1062
|
const [hostPing, setHostPing] = React.useState({ ok: true, latencyMs: null })
|
|
1017
1063
|
const [telemetry, setTelemetry] = React.useState(null)
|
|
1064
|
+
const [analytics, setAnalytics] = React.useState(null)
|
|
1065
|
+
const [queueStats, setQueueStats] = React.useState(null)
|
|
1018
1066
|
const [smoke, setSmoke] = React.useState({ testing: false, result: null })
|
|
1019
1067
|
const [updater, setUpdater] = React.useState({ checking: false, updating: false, status: null, message: '' })
|
|
1020
1068
|
const [backupModal, setBackupModal] = React.useState(null)
|
|
@@ -1117,6 +1165,14 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1117
1165
|
.then((r) => r.json())
|
|
1118
1166
|
.then((d) => { if (d && d.ok && d.telemetry) setTelemetry(d.telemetry) })
|
|
1119
1167
|
.catch(() => {})
|
|
1168
|
+
fetch('/dsh-subscriptions/analytics', { cache: 'no-store' })
|
|
1169
|
+
.then((r) => r.json())
|
|
1170
|
+
.then((d) => { if (d && d.ok && d.analytics) setAnalytics(d.analytics) })
|
|
1171
|
+
.catch(() => {})
|
|
1172
|
+
fetch('/dsh-subscriptions/queue', { cache: 'no-store' })
|
|
1173
|
+
.then((r) => r.json())
|
|
1174
|
+
.then((d) => { if (d && d.ok && d.queue) setQueueStats(d.queue) })
|
|
1175
|
+
.catch(() => {})
|
|
1120
1176
|
}
|
|
1121
1177
|
|
|
1122
1178
|
const runSmokeTest = async (provider, index) => {
|
|
@@ -1506,6 +1562,68 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1506
1562
|
React.createElement('div', { className: 'dsub-stat-lbl' }, t('statsLastActivity')),
|
|
1507
1563
|
),
|
|
1508
1564
|
) : null,
|
|
1565
|
+
(analytics && (analytics.totalRequests > 0 || (analytics.hourly && analytics.hourly.some(function(b) { return b.requests > 0 })))) || (queueStats && (queueStats.totalActive > 0 || queueStats.totalQueued > 0)) ? React.createElement('details', { className: 'dsub-group', open: true, style: { marginBottom: '14px' } },
|
|
1566
|
+
React.createElement('summary', { className: 'dsub-groupHead' }, '📊 ' + t('analyticsTitle')),
|
|
1567
|
+
React.createElement('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('analyticsDesc')),
|
|
1568
|
+
React.createElement('div', { className: 'dsub-grid-2', style: { marginTop: '10px' } },
|
|
1569
|
+
React.createElement('div', { className: 'dsub-stat-box' },
|
|
1570
|
+
React.createElement('div', { className: 'dsub-stat-val' }, ((analytics && analytics.cache && analytics.cache.cacheHitRate) || 0) + '%'),
|
|
1571
|
+
React.createElement('div', { className: 'dsub-stat-lbl' }, t('cacheHitRate') + (analytics && analytics.cache ? ' (' + (analytics.cache.cacheReadTokens || 0).toLocaleString() + ' tok)' : '')),
|
|
1572
|
+
),
|
|
1573
|
+
React.createElement('div', { className: 'dsub-stat-box' },
|
|
1574
|
+
React.createElement('div', { className: 'dsub-stat-val' }, (analytics && analytics.performance && analytics.performance.ttft && analytics.performance.ttft.avgMs != null ? analytics.performance.ttft.avgMs + ' ms' : '—')),
|
|
1575
|
+
React.createElement('div', { className: 'dsub-stat-lbl' }, t('ttftMetric') + (analytics && analytics.performance && analytics.performance.ttft && analytics.performance.ttft.p95Ms != null ? ' · p95 ' + analytics.performance.ttft.p95Ms + ' ms' : '')),
|
|
1576
|
+
),
|
|
1577
|
+
React.createElement('div', { className: 'dsub-stat-box' },
|
|
1578
|
+
React.createElement('div', { className: 'dsub-stat-val' }, (analytics && analytics.performance && analytics.performance.tps && analytics.performance.tps.avg != null ? analytics.performance.tps.avg + ' tok/s' : '—')),
|
|
1579
|
+
React.createElement('div', { className: 'dsub-stat-lbl' }, t('speedMetric') + (analytics && analytics.performance && analytics.performance.tps && analytics.performance.tps.p95 != null ? ' · p95 ' + analytics.performance.tps.p95 : '')),
|
|
1580
|
+
),
|
|
1581
|
+
React.createElement('div', { className: 'dsub-stat-box' },
|
|
1582
|
+
React.createElement('div', { className: 'dsub-stat-val' }, (queueStats ? queueStats.totalActive : 0) + ' / ' + (queueStats ? queueStats.totalQueued : 0)),
|
|
1583
|
+
React.createElement('div', { className: 'dsub-stat-lbl' }, t('queueActive') + ' / ' + t('queueWaiting') + (queueStats && queueStats.metrics && queueStats.metrics.totalOverflowed ? ' · ' + queueStats.metrics.totalOverflowed + ' ' + t('queueRejected') : '')),
|
|
1584
|
+
),
|
|
1585
|
+
),
|
|
1586
|
+
analytics && Array.isArray(analytics.hourly) && analytics.hourly.length ? (function() {
|
|
1587
|
+
var maxReq = Math.max.apply(Math, [1].concat(analytics.hourly.map(function(b) { return b.requests || 0 })))
|
|
1588
|
+
return React.createElement('div', { style: { marginTop: '12px' } },
|
|
1589
|
+
React.createElement('div', { style: { fontSize: '11.5px', fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('activity24h')),
|
|
1590
|
+
React.createElement('div', { className: 'dsub-chart-wrap' },
|
|
1591
|
+
analytics.hourly.map(function(b, idx) {
|
|
1592
|
+
var pct = Math.max(4, Math.round(((b.requests || 0) / maxReq) * 100))
|
|
1593
|
+
var title = b.label + ': ' + b.requests + ' req (' + b.cacheReadTokens + ' cached tok, ' + b.errorCount + ' err)'
|
|
1594
|
+
return React.createElement('div', {
|
|
1595
|
+
key: idx,
|
|
1596
|
+
className: 'dsub-chart-bar',
|
|
1597
|
+
style: {
|
|
1598
|
+
height: pct + '%',
|
|
1599
|
+
backgroundColor: b.errorCount > 0 && b.requests === b.errorCount
|
|
1600
|
+
? 'var(--dsw-alias-state-error-primary)'
|
|
1601
|
+
: (b.cacheReadTokens > 0 ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-state-success-primary)'),
|
|
1602
|
+
},
|
|
1603
|
+
title: title,
|
|
1604
|
+
})
|
|
1605
|
+
})
|
|
1606
|
+
),
|
|
1607
|
+
React.createElement('div', { className: 'dsub-chart-labels' },
|
|
1608
|
+
React.createElement('span', null, '-24h'),
|
|
1609
|
+
React.createElement('span', null, '-18h'),
|
|
1610
|
+
React.createElement('span', null, '-12h'),
|
|
1611
|
+
React.createElement('span', null, '-6h'),
|
|
1612
|
+
React.createElement('span', null, 'Now'),
|
|
1613
|
+
),
|
|
1614
|
+
)
|
|
1615
|
+
})() : null,
|
|
1616
|
+
analytics && Array.isArray(analytics.forecast) && analytics.forecast.some(function(f) { return f.willExhaustBeforeReset || (f.burnRateTokensPerHour > 0 && f.hoursToExhaustion != null && f.hoursToExhaustion < 72) }) ? React.createElement('div', { style: { marginTop: '12px' } },
|
|
1617
|
+
React.createElement('div', { style: { fontSize: '11.5px', fontWeight: 600, color: 'var(--dsw-alias-state-warn-primary)', marginBottom: '6px' } }, '⚠️ ' + t('forecastTitle')),
|
|
1618
|
+
analytics.forecast.filter(function(f) { return f.willExhaustBeforeReset || (f.burnRateTokensPerHour > 0 && f.hoursToExhaustion != null && f.hoursToExhaustion < 72) }).map(function(f, idx) {
|
|
1619
|
+
return React.createElement('div', { key: idx, className: 'dsub-alert-bad', style: { marginBottom: '6px' } },
|
|
1620
|
+
(f.provider ? f.provider.toUpperCase() : 'LLM') + ' (' + (f.label || f.ref) + '): ' +
|
|
1621
|
+
(f.willExhaustBeforeReset ? t('forecastExhaustWarning') : (t('forecastRunway') + ': ~' + f.hoursToExhaustion + 'h')) +
|
|
1622
|
+
' · ' + t('forecastBurnRate') + ': ' + f.burnRateTokensPerHour.toLocaleString() + ' tok/h'
|
|
1623
|
+
)
|
|
1624
|
+
})
|
|
1625
|
+
) : null,
|
|
1626
|
+
) : null,
|
|
1509
1627
|
telemetry && Array.isArray(telemetry.recentDecisions) && telemetry.recentDecisions.length ? React.createElement('details', { className: 'dsub-group', style: { marginBottom: '14px' } },
|
|
1510
1628
|
React.createElement('summary', { className: 'dsub-groupHead' }, '🧭 ' + t('rotationDecisionsTitle')),
|
|
1511
1629
|
React.createElement('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('rotationDecisionsDesc')),
|
|
@@ -1640,6 +1758,57 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1640
1758
|
React.createElement('div', { className: 'dsub-sub' },
|
|
1641
1759
|
t('expiryNotifyDaysHint')),
|
|
1642
1760
|
),
|
|
1761
|
+
React.createElement('details', { className: 'dsub-group', open: true },
|
|
1762
|
+
React.createElement('summary', { className: 'dsub-groupHead' }, '🚦 ' + t('groupQueue')),
|
|
1763
|
+
React.createElement('label', { className: 'dsub-row' },
|
|
1764
|
+
React.createElement('input', {
|
|
1765
|
+
type: 'checkbox',
|
|
1766
|
+
checked: draft.enableQueue !== false,
|
|
1767
|
+
onChange: function(e) { setDraft(function(d) { return Object.assign({}, d, { enableQueue: e.target.checked }) }) },
|
|
1768
|
+
}),
|
|
1769
|
+
React.createElement('span', null, t('enableQueue')),
|
|
1770
|
+
),
|
|
1771
|
+
React.createElement('div', { className: 'dsub-sub' }, t('enableQueueHint')),
|
|
1772
|
+
React.createElement('div', { className: 'dsub-row' },
|
|
1773
|
+
React.createElement('span', { className: 'dsub-h' }, t('accountMaxConcurrency')),
|
|
1774
|
+
React.createElement('input', {
|
|
1775
|
+
type: 'number',
|
|
1776
|
+
className: 'dsub-inp',
|
|
1777
|
+
style: { width: '80px', minWidth: '80px' },
|
|
1778
|
+
min: 1,
|
|
1779
|
+
max: 20,
|
|
1780
|
+
value: draft.accountMaxConcurrency != null ? draft.accountMaxConcurrency : 2,
|
|
1781
|
+
onChange: function(e) { setDraft(function(d) { return Object.assign({}, d, { accountMaxConcurrency: Math.max(1, Number(e.target.value) || 2) }) }) },
|
|
1782
|
+
}),
|
|
1783
|
+
),
|
|
1784
|
+
React.createElement('div', { className: 'dsub-sub' }, t('accountMaxConcurrencyHint')),
|
|
1785
|
+
React.createElement('div', { className: 'dsub-row' },
|
|
1786
|
+
React.createElement('span', { className: 'dsub-h' }, t('maxQueueSize')),
|
|
1787
|
+
React.createElement('input', {
|
|
1788
|
+
type: 'number',
|
|
1789
|
+
className: 'dsub-inp',
|
|
1790
|
+
style: { width: '80px', minWidth: '80px' },
|
|
1791
|
+
min: 1,
|
|
1792
|
+
max: 100,
|
|
1793
|
+
value: draft.maxQueueSize != null ? draft.maxQueueSize : 20,
|
|
1794
|
+
onChange: function(e) { setDraft(function(d) { return Object.assign({}, d, { maxQueueSize: Math.max(1, Number(e.target.value) || 20) }) }) },
|
|
1795
|
+
}),
|
|
1796
|
+
),
|
|
1797
|
+
React.createElement('div', { className: 'dsub-sub' }, t('maxQueueSizeHint')),
|
|
1798
|
+
React.createElement('div', { className: 'dsub-row' },
|
|
1799
|
+
React.createElement('span', { className: 'dsub-h' }, t('queueTimeoutSec')),
|
|
1800
|
+
React.createElement('input', {
|
|
1801
|
+
type: 'number',
|
|
1802
|
+
className: 'dsub-inp',
|
|
1803
|
+
style: { width: '80px', minWidth: '80px' },
|
|
1804
|
+
min: 1,
|
|
1805
|
+
max: 120,
|
|
1806
|
+
value: Math.round((draft.queueTimeoutMs != null ? draft.queueTimeoutMs : 30000) / 1000),
|
|
1807
|
+
onChange: function(e) { setDraft(function(d) { return Object.assign({}, d, { queueTimeoutMs: Math.max(1, Number(e.target.value) || 30) * 1000 }) }) },
|
|
1808
|
+
}),
|
|
1809
|
+
),
|
|
1810
|
+
React.createElement('div', { className: 'dsub-sub' }, t('queueTimeoutSecHint')),
|
|
1811
|
+
),
|
|
1643
1812
|
React.createElement('details', { className: 'dsub-group', open: true },
|
|
1644
1813
|
React.createElement('summary', { className: 'dsub-groupHead' }, t('groupProvider')),
|
|
1645
1814
|
React.createElement('label', { className: 'dsub-row' },
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Concurrency limiting, bounded queue, and cooperative cancellation per account/provider.
|
|
2
|
+
// Standardized error codes: QUEUE_OVERFLOW, QUEUE_TIMEOUT, ABORT_ERR, QUEUE_DISPOSED.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} message
|
|
6
|
+
* @param {string} code
|
|
7
|
+
* @param {number} [status]
|
|
8
|
+
* @returns {Error & { code: string, status?: number }}
|
|
9
|
+
*/
|
|
10
|
+
function createQueueError(message, code, status) {
|
|
11
|
+
const err = /** @type {Error & { code: string, status?: number }} */ (new Error(message))
|
|
12
|
+
err.code = code
|
|
13
|
+
if (status !== undefined) err.status = status
|
|
14
|
+
return err
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ConcurrencyQueue {
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.maxConcurrency = options.maxConcurrency !== undefined ? Number(options.maxConcurrency) : 2
|
|
20
|
+
this.maxQueueSize = options.maxQueueSize !== undefined ? Number(options.maxQueueSize) : 20
|
|
21
|
+
this.timeoutMs = options.timeoutMs !== undefined ? Number(options.timeoutMs) : 30000
|
|
22
|
+
this.enabled = options.enabled !== undefined ? Boolean(options.enabled) : true
|
|
23
|
+
|
|
24
|
+
/** @type {Map<string, number>} */
|
|
25
|
+
this._active = new Map()
|
|
26
|
+
/** @type {Map<string, Array<{ id: string, resolve: Function, reject: Function, timer: any, signal: any, abortHandler: Function | null, enqueuedAt: number }>>} */
|
|
27
|
+
this._waiting = new Map()
|
|
28
|
+
|
|
29
|
+
this._stats = {
|
|
30
|
+
totalAcquired: 0,
|
|
31
|
+
totalReleased: 0,
|
|
32
|
+
totalQueued: 0,
|
|
33
|
+
totalTimedOut: 0,
|
|
34
|
+
totalOverflowed: 0,
|
|
35
|
+
totalAborted: 0,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
configure(options = {}) {
|
|
40
|
+
if (options.maxConcurrency !== undefined) this.maxConcurrency = Number(options.maxConcurrency)
|
|
41
|
+
if (options.maxQueueSize !== undefined) this.maxQueueSize = Number(options.maxQueueSize)
|
|
42
|
+
if (options.timeoutMs !== undefined) this.timeoutMs = Number(options.timeoutMs)
|
|
43
|
+
if (options.enabled !== undefined) this.enabled = Boolean(options.enabled)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async acquire(key = 'default', options = {}) {
|
|
47
|
+
if (!this.enabled) {
|
|
48
|
+
return () => {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const signal = options.signal
|
|
52
|
+
if (signal && signal.aborted) {
|
|
53
|
+
const err = createQueueError('Request aborted before acquiring queue slot', 'ABORT_ERR')
|
|
54
|
+
err.name = 'AbortError'
|
|
55
|
+
this._stats.totalAborted++
|
|
56
|
+
throw err
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const k = String(key || 'default')
|
|
60
|
+
const active = this._active.get(k) || 0
|
|
61
|
+
const limit = options.maxConcurrency !== undefined ? Number(options.maxConcurrency) : this.maxConcurrency
|
|
62
|
+
|
|
63
|
+
if (active < limit) {
|
|
64
|
+
this._active.set(k, active + 1)
|
|
65
|
+
this._stats.totalAcquired++
|
|
66
|
+
return this._createRelease(k)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Queue is required
|
|
70
|
+
const queueList = this._waiting.get(k) || []
|
|
71
|
+
const maxQ = options.maxQueueSize !== undefined ? Number(options.maxQueueSize) : this.maxQueueSize
|
|
72
|
+
|
|
73
|
+
if (queueList.length >= maxQ) {
|
|
74
|
+
this._stats.totalOverflowed++
|
|
75
|
+
const err = createQueueError(`Concurrency queue overflow for ${k} (max queue size: ${maxQ})`, 'QUEUE_OVERFLOW', 429)
|
|
76
|
+
throw err
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const timeoutDuration = options.timeoutMs !== undefined ? Number(options.timeoutMs) : this.timeoutMs
|
|
80
|
+
this._stats.totalQueued++
|
|
81
|
+
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
let timer = null
|
|
84
|
+
let abortHandler = null
|
|
85
|
+
const id = Math.random().toString(36).slice(2)
|
|
86
|
+
|
|
87
|
+
const cleanup = () => {
|
|
88
|
+
if (timer) clearTimeout(timer)
|
|
89
|
+
if (signal && abortHandler && typeof signal.removeEventListener === 'function') {
|
|
90
|
+
signal.removeEventListener('abort', abortHandler)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (timeoutDuration > 0) {
|
|
95
|
+
timer = setTimeout(() => {
|
|
96
|
+
cleanup()
|
|
97
|
+
this._removeFromWaiting(k, id)
|
|
98
|
+
this._stats.totalTimedOut++
|
|
99
|
+
const err = createQueueError(`Concurrency queue wait timeout for ${k} after ${timeoutDuration}ms`, 'QUEUE_TIMEOUT', 504)
|
|
100
|
+
reject(err)
|
|
101
|
+
}, timeoutDuration)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (signal) {
|
|
105
|
+
abortHandler = () => {
|
|
106
|
+
cleanup()
|
|
107
|
+
this._removeFromWaiting(k, id)
|
|
108
|
+
this._stats.totalAborted++
|
|
109
|
+
const err = createQueueError('Request aborted while waiting in concurrency queue', 'ABORT_ERR')
|
|
110
|
+
err.name = 'AbortError'
|
|
111
|
+
reject(err)
|
|
112
|
+
}
|
|
113
|
+
signal.addEventListener('abort', abortHandler, { once: true })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const item = {
|
|
117
|
+
id,
|
|
118
|
+
resolve: () => {
|
|
119
|
+
cleanup()
|
|
120
|
+
this._stats.totalAcquired++
|
|
121
|
+
resolve(this._createRelease(k))
|
|
122
|
+
},
|
|
123
|
+
reject: (err) => {
|
|
124
|
+
cleanup()
|
|
125
|
+
reject(err)
|
|
126
|
+
},
|
|
127
|
+
timer,
|
|
128
|
+
signal,
|
|
129
|
+
abortHandler,
|
|
130
|
+
enqueuedAt: Date.now(),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!this._waiting.has(k)) {
|
|
134
|
+
this._waiting.set(k, [])
|
|
135
|
+
}
|
|
136
|
+
this._waiting.get(k).push(item)
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_createRelease(k) {
|
|
141
|
+
let released = false
|
|
142
|
+
return () => {
|
|
143
|
+
if (released) return
|
|
144
|
+
released = true
|
|
145
|
+
this._release(k)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_release(k) {
|
|
150
|
+
this._stats.totalReleased++
|
|
151
|
+
const waiting = this._waiting.get(k)
|
|
152
|
+
if (waiting && waiting.length > 0) {
|
|
153
|
+
const next = waiting.shift()
|
|
154
|
+
if (waiting.length === 0) this._waiting.delete(k)
|
|
155
|
+
// Hand over slot directly to next queued consumer
|
|
156
|
+
next.resolve()
|
|
157
|
+
} else {
|
|
158
|
+
const active = this._active.get(k) || 0
|
|
159
|
+
if (active <= 1) {
|
|
160
|
+
this._active.delete(k)
|
|
161
|
+
} else {
|
|
162
|
+
this._active.set(k, active - 1)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_removeFromWaiting(k, id) {
|
|
168
|
+
const list = this._waiting.get(k)
|
|
169
|
+
if (!list) return
|
|
170
|
+
const idx = list.findIndex((item) => item.id === id)
|
|
171
|
+
if (idx !== -1) {
|
|
172
|
+
list.splice(idx, 1)
|
|
173
|
+
if (list.length === 0) this._waiting.delete(k)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
stats() {
|
|
178
|
+
let totalActive = 0
|
|
179
|
+
let totalQueued = 0
|
|
180
|
+
const byKey = {}
|
|
181
|
+
|
|
182
|
+
for (const [k, count] of this._active) {
|
|
183
|
+
totalActive += count
|
|
184
|
+
if (!byKey[k]) byKey[k] = { active: 0, queued: 0 }
|
|
185
|
+
byKey[k].active = count
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const [k, list] of this._waiting) {
|
|
189
|
+
totalQueued += list.length
|
|
190
|
+
if (!byKey[k]) byKey[k] = { active: 0, queued: 0 }
|
|
191
|
+
byKey[k].queued = list.length
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
enabled: this.enabled,
|
|
196
|
+
maxConcurrency: this.maxConcurrency,
|
|
197
|
+
maxQueueSize: this.maxQueueSize,
|
|
198
|
+
timeoutMs: this.timeoutMs,
|
|
199
|
+
totalActive,
|
|
200
|
+
totalQueued,
|
|
201
|
+
byKey,
|
|
202
|
+
metrics: { ...this._stats },
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
dispose() {
|
|
207
|
+
for (const [, list] of this._waiting) {
|
|
208
|
+
for (const item of list) {
|
|
209
|
+
if (item.timer) clearTimeout(item.timer)
|
|
210
|
+
if (item.signal && item.abortHandler && typeof item.signal.removeEventListener === 'function') {
|
|
211
|
+
item.signal.removeEventListener('abort', item.abortHandler)
|
|
212
|
+
}
|
|
213
|
+
const err = createQueueError('Concurrency queue was disposed', 'QUEUE_DISPOSED')
|
|
214
|
+
item.reject(err)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
this._waiting.clear()
|
|
218
|
+
this._active.clear()
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const defaultConcurrencyQueue = new ConcurrencyQueue()
|
package/lib/config-schema.js
CHANGED
|
@@ -118,6 +118,14 @@ export const Config = z.object({
|
|
|
118
118
|
.description('#485 Prioritize accounts with closest reset window to burn expiring quota first.'),
|
|
119
119
|
drainSafetyPercent: z.number().volatile().default(5)
|
|
120
120
|
.description('#485 Safety margin percent: avoid draining accounts below this threshold when alternatives exist.'),
|
|
121
|
+
enableQueue: z.boolean().volatile().default(true)
|
|
122
|
+
.description('#518 Bounded concurrency queue to prevent bursts of 429 errors.'),
|
|
123
|
+
accountMaxConcurrency: z.number().volatile().default(2)
|
|
124
|
+
.description('#518 Max concurrent requests per account before queuing.'),
|
|
125
|
+
maxQueueSize: z.number().volatile().default(20)
|
|
126
|
+
.description('#518 Max requests waiting in queue before reject.'),
|
|
127
|
+
queueTimeoutMs: z.number().volatile().default(30000)
|
|
128
|
+
.description('#518 Max queue wait time in ms before timeout.'),
|
|
121
129
|
customVendors: z.array(z.any()).volatile().default([])
|
|
122
130
|
.description('Declarative OpenAI-Responses-compatible providers. See README.'),
|
|
123
131
|
})
|
package/lib/diagnostics.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { defaultConcurrencyQueue } from './concurrency-queue.js'
|
|
2
|
+
import { calculateCacheMetrics } from './analytics.js'
|
|
1
3
|
import { maskText } from './mask.js'
|
|
2
4
|
import { normalizeSlots } from './accounts.js'
|
|
3
5
|
import { PROVIDERS } from './refs.js'
|
|
@@ -46,6 +48,8 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
|
|
|
46
48
|
runtime: { node: process.version, platform: process.platform, arch: process.arch, uptimeSec: Math.round(process.uptime()) },
|
|
47
49
|
providers,
|
|
48
50
|
requests: { total: rows.length, byStatus, lastErrors },
|
|
51
|
+
cache: calculateCacheMetrics(rows),
|
|
52
|
+
queue: defaultConcurrencyQueue.stats(),
|
|
49
53
|
decisions: {
|
|
50
54
|
recent: rows.filter((r) => r && r.decisionTrace).slice(0, 10).map((r) => ({
|
|
51
55
|
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/routes/status.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { defaultConcurrencyQueue } from '../concurrency-queue.js'
|
|
2
|
+
import { calculateAnalytics } from '../analytics.js'
|
|
1
3
|
import { buildEnrichedCatalog } from '../models-catalog.js'
|
|
2
4
|
// #374: Sensitive GET routes protected by same-origin check
|
|
3
5
|
import { readFileSync } from 'node:fs'
|
|
@@ -49,6 +51,7 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
49
51
|
liveConfig: live(),
|
|
50
52
|
hideDeprecated: !!live().hideDeprecatedModels,
|
|
51
53
|
}),
|
|
54
|
+
queue: defaultConcurrencyQueue.stats(),
|
|
52
55
|
}
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -538,4 +541,43 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
538
541
|
}),
|
|
539
542
|
}), 'dsh-subscriptions: /alerts')
|
|
540
543
|
|
|
544
|
+
|
|
545
|
+
// #511: Detailed cache and subscription analytics endpoint
|
|
546
|
+
ctx.effect(() => ctx.webServer.register({
|
|
547
|
+
kind: 'exact',
|
|
548
|
+
path: '/dsh-subscriptions/analytics',
|
|
549
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
550
|
+
if (req.method !== 'GET') {
|
|
551
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
555
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
const accounts = await accountsView()
|
|
559
|
+
const analytics = typeof history.getAnalytics === 'function'
|
|
560
|
+
? history.getAnalytics(accounts)
|
|
561
|
+
: calculateAnalytics(history.all(), accounts)
|
|
562
|
+
writeJson(res, 200, { ok: true, analytics })
|
|
563
|
+
}),
|
|
564
|
+
}), 'dsh-subscriptions: /analytics')
|
|
565
|
+
|
|
566
|
+
// #518: Concurrency and request queue status
|
|
567
|
+
ctx.effect(() => ctx.webServer.register({
|
|
568
|
+
kind: 'exact',
|
|
569
|
+
path: '/dsh-subscriptions/queue',
|
|
570
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
571
|
+
if (req.method !== 'GET') {
|
|
572
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
576
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
const queueStats = defaultConcurrencyQueue.stats()
|
|
580
|
+
writeJson(res, 200, { ok: true, queue: queueStats })
|
|
581
|
+
}),
|
|
582
|
+
}), 'dsh-subscriptions: /queue')
|
|
541
583
|
}
|
package/lib/stream-rotate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultConcurrencyQueue } from './concurrency-queue.js'
|
|
1
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'
|
|
@@ -16,6 +17,7 @@ export async function* streamWithRotation({
|
|
|
16
17
|
offlineFallback, // #174: optional fallback generator if all accounts exhausted
|
|
17
18
|
webhookUrl,
|
|
18
19
|
fetchImpl = fetch,
|
|
20
|
+
concurrencyQueue = defaultConcurrencyQueue,
|
|
19
21
|
}) {
|
|
20
22
|
const pool = (accounts || []).map((account) => ({ ...account }))
|
|
21
23
|
let lastError = null
|
|
@@ -100,10 +102,36 @@ export async function* streamWithRotation({
|
|
|
100
102
|
|
|
101
103
|
tried.add(account.ref || account.id)
|
|
102
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
|
+
|
|
103
130
|
let firstChunkDelivered = false
|
|
104
131
|
let regionRetries = 0
|
|
105
132
|
|
|
106
|
-
|
|
133
|
+
try {
|
|
134
|
+
while (true) {
|
|
107
135
|
try {
|
|
108
136
|
const runawayState = options && options._runawayState ? options._runawayState : { tripped: false, reason: null }
|
|
109
137
|
const runawayOpts = {
|
|
@@ -207,5 +235,10 @@ export async function* streamWithRotation({
|
|
|
207
235
|
break
|
|
208
236
|
}
|
|
209
237
|
}
|
|
238
|
+
} finally {
|
|
239
|
+
if (typeof releaseSlot === 'function') {
|
|
240
|
+
releaseSlot()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
210
243
|
}
|
|
211
244
|
}
|
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",
|