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