@goodandready/dsh-subscriptions 0.6.60 → 0.6.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/README.ru.md +6 -0
- package/README.zh.md +6 -0
- package/lib/adapter-manager.js +14 -0
- package/lib/adapter.js +18 -6
- package/lib/analytics.js +338 -0
- package/lib/client.js +267 -8
- package/lib/concurrency-queue.js +222 -0
- package/lib/config-schema.js +8 -0
- package/lib/diagnostics.js +17 -0
- package/lib/history.js +22 -0
- package/lib/models-catalog.js +240 -0
- package/lib/rotate.js +115 -14
- package/lib/routes/status.js +74 -1
- package/lib/session-pin.js +4 -0
- package/lib/stream-rotate.js +102 -6
- package/lib/subscriptions.js +23 -3
- package/package.json +1 -1
package/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)
|
package/lib/adapter-manager.js
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
78
|
-
models = models.map((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
|
}
|
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
|
+
}
|