@goodandready/dsh-subscriptions 0.6.61 → 0.6.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/README.ru.md +6 -0
- package/README.zh.md +4 -0
- package/lib/analytics.js +338 -0
- package/lib/client.js +298 -2
- package/lib/concurrency-queue.js +222 -0
- package/lib/config-schema.js +8 -0
- package/lib/diagnostic-wizard.js +408 -0
- package/lib/diagnostics.js +6 -0
- package/lib/history.js +5 -0
- package/lib/rotate.js +23 -8
- package/lib/routes/status.js +76 -0
- package/lib/session-pin.js +216 -10
- package/lib/stream-rotate.js +43 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -100,6 +100,8 @@ graph LR
|
|
|
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
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
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.
|
|
103
|
+
* **Cache-Aware Session Pinning (#520)**: Retains session affinity for prompt-caching providers (Claude, Codex, Antigravity, Grok) to maximize token cache reuse while strictly releasing pins when an account enters cooldown, quarantine, or quota exhaustion.
|
|
104
|
+
* **Guided Diagnostic Wizard (#519)**: Interactive step-by-step checklist verifying credentials, network/proxy connectivity, token freshness, and model catalog reachability without paid inference and with full secret redaction.
|
|
103
105
|
|
|
104
106
|
---
|
|
105
107
|
|
|
@@ -244,6 +246,10 @@ dsh-subscriptions:
|
|
|
244
246
|
codexFastMode: false # v0.4.17: service_tier priority (1.5x speed tier)
|
|
245
247
|
composerQuota: 'off' # v0.4.18: composer indicator: off | percent | bar | forecast
|
|
246
248
|
enableToasts: true # v0.6.59: real-time in-app toast alerts on quarantine and quota events
|
|
249
|
+
enableQueue: true # v0.6.62: bounded concurrency queue to prevent bursts of 429 errors
|
|
250
|
+
accountMaxConcurrency: 2 # v0.6.62: max concurrent active streams per account
|
|
251
|
+
maxQueueSize: 20 # v0.6.62: max queue depth before load shedding / reject
|
|
252
|
+
queueTimeoutMs: 30000 # v0.6.62: max queue wait duration in ms before timeout
|
|
247
253
|
# Per-slot fields (v0.4.9): expiresAt (ms epoch), proxyUrl (http/https/socks5://)
|
|
248
254
|
accounts:
|
|
249
255
|
codex:
|
package/README.ru.md
CHANGED
|
@@ -100,6 +100,8 @@ graph LR
|
|
|
100
100
|
* **Динамический расчёт Cooldown**: парсинг заголовков `Retry-After`, `x-ratelimit-reset`, дат ISO и миллисекундных меток с автоматическим возвратом остывших аккаунтов в строй.
|
|
101
101
|
* **Трассировка решений ротации и объяснение маршрутизации (#517)**: Синхронная фиксация причин выбора аккаунтов, отказов и каскадных переходов в реальном времени с кодами причин без ретроспективной реконструкции; визуализация в телеметрии настроек и диагностике.
|
|
102
102
|
* **Динамический каталог моделей с пуловой доступностью и квотами (#507)**: Обогащенная витрина моделей с расчетом здоровья пула (карантин одного аккаунта не блокирует модель при наличии живых), тегами возможностей (`vision`, `tools`, `reasoning`, `coding`, `fast`), размером контекста и таймером сброса квот.
|
|
103
|
+
* **Cache-Aware Session Pinning (#520)**: Сохранение привязки сессии к подходящему аккаунту для провайдеров с поддержкой кэширования промптов (Claude, Codex, Antigravity, Grok) с гарантированным немедленным освобождением при кулдауне, карантине или исчерпании квоты.
|
|
104
|
+
* **Мастер интерактивной диагностики (#519)**: Пошаговый чек-лист состояния аккаунтов (валидность ключей, прокси, токен, доступность каталога) без платного инференса и с автоматическим маскированием секретов.
|
|
103
105
|
|
|
104
106
|
---
|
|
105
107
|
|
|
@@ -217,6 +219,10 @@ dsh-subscriptions:
|
|
|
217
219
|
codexFastMode: false # v0.4.17: service_tier priority (скоростной тир 1.5x)
|
|
218
220
|
composerQuota: 'off' # v0.4.18: индикатор в строке ввода: off | percent | bar | forecast
|
|
219
221
|
enableToasts: true # v0.6.59: всплывающие уведомления при событиях карантина и квот
|
|
222
|
+
enableQueue: true # v0.6.62: ограниченная очередь параллельных запросов к аккаунту
|
|
223
|
+
accountMaxConcurrency: 2 # v0.6.62: макс. число параллельных стримов на аккаунт
|
|
224
|
+
maxQueueSize: 20 # v0.6.62: макс. глубина очереди до сброса нагрузки
|
|
225
|
+
queueTimeoutMs: 30000 # v0.6.62: таймаут ожидания слота в очереди в мс
|
|
220
226
|
# Поля слота (v0.4.9): expiresAt (ms), proxyUrl (http/https/socks5://)
|
|
221
227
|
accounts:
|
|
222
228
|
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
|
+
}
|