@goodandready/dsh-subscriptions 0.6.60 → 0.6.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -98,6 +98,8 @@ graph LR
98
98
  * **Automatic 429 Failover**: When an account encounters a rate limit (`HTTP 429`, `RATE_LIMIT`, `QUOTA_EXCEEDED`), traffic instantly fails over to the next healthy account in the pool.
99
99
  * **Preemptive Quota Switching (`switchAtRemaining`)**: Automatically rotates to the next account before hitting zero if the rate-limit window reset is imminent.
100
100
  * **Dynamic Cooldown Calculation**: Parses upstream headers (`Retry-After`, `x-ratelimit-reset`, ISO dates, epoch timestamps) and auto-restores cooled-down accounts when their window resets.
101
+ * **Synchronous Decision Tracing & Routing Explanation (#517)**: Real-time decision traces capture every account selection, rotation step, failure reason, and cascade fallback without post-hoc reconstruction, exposed in WebUI telemetry and diagnostics.
102
+ * **Dynamic Model Catalog & Live Quota Visualizer (#507)**: Dynamic model catalog with pool-level health calculation (individual quarantine degrades rather than disables the pool), context window badges, capability tags (`vision`, `tools`, `reasoning`, `coding`, `fast`), and live reset countdowns.
101
103
 
102
104
  ---
103
105
 
@@ -242,6 +244,10 @@ dsh-subscriptions:
242
244
  codexFastMode: false # v0.4.17: service_tier priority (1.5x speed tier)
243
245
  composerQuota: 'off' # v0.4.18: composer indicator: off | percent | bar | forecast
244
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
245
251
  # Per-slot fields (v0.4.9): expiresAt (ms epoch), proxyUrl (http/https/socks5://)
246
252
  accounts:
247
253
  codex:
package/README.ru.md CHANGED
@@ -98,6 +98,8 @@ graph LR
98
98
  * **Автоматическое переключение при 429**: при превышении лимита запросов трафик мгновенно переключается на следующий свободный аккаунт.
99
99
  * **Упреждающее переключение квот (`switchAtRemaining`)**: смена аккаунта до падения в ошибку, если окно сброса близко.
100
100
  * **Динамический расчёт Cooldown**: парсинг заголовков `Retry-After`, `x-ratelimit-reset`, дат ISO и миллисекундных меток с автоматическим возвратом остывших аккаунтов в строй.
101
+ * **Трассировка решений ротации и объяснение маршрутизации (#517)**: Синхронная фиксация причин выбора аккаунтов, отказов и каскадных переходов в реальном времени с кодами причин без ретроспективной реконструкции; визуализация в телеметрии настроек и диагностике.
102
+ * **Динамический каталог моделей с пуловой доступностью и квотами (#507)**: Обогащенная витрина моделей с расчетом здоровья пула (карантин одного аккаунта не блокирует модель при наличии живых), тегами возможностей (`vision`, `tools`, `reasoning`, `coding`, `fast`), размером контекста и таймером сброса квот.
101
103
 
102
104
  ---
103
105
 
@@ -215,6 +217,10 @@ dsh-subscriptions:
215
217
  codexFastMode: false # v0.4.17: service_tier priority (скоростной тир 1.5x)
216
218
  composerQuota: 'off' # v0.4.18: индикатор в строке ввода: off | percent | bar | forecast
217
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: таймаут ожидания слота в очереди в мс
218
224
  # Поля слота (v0.4.9): expiresAt (ms), proxyUrl (http/https/socks5://)
219
225
  accounts:
220
226
  codex:
package/README.zh.md CHANGED
@@ -91,6 +91,8 @@ graph LR
91
91
  ### 多账号轮换与限流保护
92
92
  * 每个供应商可挂多个账号(如 `CODEX_OAUTH_1`、`CODEX_OAUTH_2`)。
93
93
  * 遇到 429/配额耗尽自动切换到下一个健康账号;支持按剩余量提前切换和动态冷却恢复。
94
+ * **轮换决策追踪与路由解释 (#517)**:实时同步记录账号选择、轮换切换、失败原因与级联回退的完整决策链路(无需事后推导),并在 WebUI 遥测与诊断报告中可视化展示。
95
+ * **动态模型目录与配额可用性 (#507)**:基于账号池计算模型综合可用性(单个账号隔离不影响具备可用账号的模型),展示上下文窗口、能力标签(视觉、工具、推理、编码、快速)及动态配额倒计时。
94
96
 
95
97
  ### 安全与无头登录
96
98
  * OAuth 令牌绝不通过 HTTP API 返回,也不在 Web 界面渲染;仅存放于宿主加密凭据存储。
@@ -124,6 +126,10 @@ graph LR
124
126
  * **手动隔离管理(暂停与预热探测)**:支持在设置面板的账户卡片中一键暂停隔离活跃账户(`POST /dsh-subscriptions/quarantine/park`),或立即触发真实上游预热微探测(`probeWarmup`)并在验证成功后即时解除隔离(`POST /dsh-subscriptions/quarantine/probe`)。
125
127
  * **动态额度重置倒计时**:在顶部会话栏 `SubsPill` 和账户卡片中秒级动态更新,精准展示滚动配额窗口(`five_hour`、`seven_day`、`primary_window`)的剩余解锁时间。
126
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: 队列最大等待超时时间(毫秒)
127
133
  * **自动化 Webhook 告警集成**:通过 `notifyQuarantineEntered` 与 `notifyQuarantineReleased` 自动向外部通知系统派发隔离生命周期事件。
128
134
 
129
135
  ### Antigravity 客户端密钥架构与无感 CLI 令牌刷新 (v0.6.60)
@@ -92,6 +92,20 @@ export function createAdapterManager({
92
92
  method: 'POST',
93
93
  status: 200,
94
94
  kind: 'cascade',
95
+ decisionTrace: {
96
+ attempts: [
97
+ {
98
+ attempt: 1,
99
+ kind: 'cascade_fallback',
100
+ fromProvider: provider,
101
+ toProvider: nextVendor,
102
+ model: targetModel,
103
+ reason: 'primary_pool_exhausted',
104
+ },
105
+ ],
106
+ finalOutcome: 'cascade_fallback',
107
+ chosenRef: String(nextVendor).toUpperCase() + '_CASCADE_FALLBACK',
108
+ },
95
109
  })
96
110
  } catch { /* ignore cascade history recording error */ }
97
111
  }
package/lib/adapter.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { inferModelCapabilities, isDeprecatedId } from './models-catalog.js'
1
2
  import { fetchWithTimeout } from "./http.js"
2
3
  import { LlmAdapter, LlmError, attributionHeaders } from '@deepseek-ai/dsh-llm'
3
4
  import { displayName } from './refs.js'
@@ -10,10 +11,7 @@ import { quotaSnapshot, parseBody } from './ratelimit.js'
10
11
  import { toTokenUsage } from './wire.js'
11
12
  import { isSwitchableError } from './rotate.js'
12
13
 
13
- // #94: heuristic for test/preview/beta/legacy model ids.
14
- function isDeprecatedId(id) {
15
- return /(^|[-_:])(test|preview|dev|alpha|beta|snapshot|experimental|legacy|deprecated)([-_:]|$)/i.test(id)
16
- }
14
+ // #94: isDeprecatedId imported from ./models-catalog.js
17
15
 
18
16
  function asLlmError(err) {
19
17
  if (err instanceof LlmError) return err
@@ -74,8 +72,19 @@ export class SubscriptionAdapter extends LlmAdapter {
74
72
  if (typeof this.deps.hideDeprecatedModels === 'function' && this.deps.hideDeprecatedModels()) {
75
73
  models = models.filter((m) => !isDeprecatedId(String((m && m.id) || '')))
76
74
  }
77
- // Defensive: ensure every model has the provider field set
78
- models = models.map((m) => (m ? { ...m, provider: (!m.provider || m.provider === "custom") ? provider : m.provider } : m))
75
+ // Enrich models with provider and confirmed capability tags (#507)
76
+ models = models.map((m) => {
77
+ if (!m) return m
78
+ const prov = (!m.provider || m.provider === "custom") ? provider : m.provider
79
+ const caps = inferModelCapabilities(prov, m.id)
80
+ return {
81
+ ...m,
82
+ provider: prov,
83
+ contextWindow: m.contextWindow || caps.contextWindow,
84
+ contextLabel: caps.contextLabel,
85
+ capabilities: Array.isArray(m.capabilities) && m.capabilities.length ? m.capabilities : caps.capabilities,
86
+ }
87
+ })
79
88
  return models
80
89
  }
81
90
 
@@ -127,8 +136,10 @@ export class SubscriptionAdapter extends LlmAdapter {
127
136
  const effectiveDrainSafetyPercent = options.drainSafetyPercent !== undefined
128
137
  ? options.drainSafetyPercent
129
138
  : (typeof deps.drainSafetyPercent === 'function' ? deps.drainSafetyPercent() : (deps.drainSafetyPercent ?? 5))
139
+ const decisionTrace = {}
130
140
  const streamOptions = {
131
141
  ...options,
142
+ _decisionTrace: decisionTrace,
132
143
  autoPacing: effectiveAutoPacing,
133
144
  drainBeforeReset: effectiveDrainBeforeReset,
134
145
  drainSafetyPercent: effectiveDrainSafetyPercent,
@@ -218,6 +229,7 @@ export class SubscriptionAdapter extends LlmAdapter {
218
229
  kind: 'stream',
219
230
  ...(outcome === 'error' && errorCaught ? { error: errorCaught.message || String(errorCaught) } : {}),
220
231
  ...(outcome === 'runaway' ? { reason: finishReason || (opts && opts._runawayState && opts._runawayState.reason) || 'runaway_loop_detected' } : {}),
232
+ ...(decisionTrace && decisionTrace.attempts && decisionTrace.attempts.length ? { decisionTrace } : {}),
221
233
  })
222
234
  } catch { /* ignore quota capture failure */ }
223
235
  }
@@ -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
+ }