@goodandready/dsh-subscriptions 0.6.71 → 0.6.82
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 +13 -0
- package/README.ru.md +13 -0
- package/README.zh.md +13 -0
- package/lib/accounts-view.js +101 -5
- package/lib/accounts.js +150 -11
- package/lib/adapter.js +3 -0
- package/lib/alerts.js +35 -0
- package/lib/analytics.js +31 -2
- package/lib/billing.js +99 -0
- package/lib/blob.js +18 -0
- package/lib/capabilities.js +208 -0
- package/lib/client.js +944 -828
- package/lib/config-schema.js +46 -4
- package/lib/diagnostic-wizard.js +2 -5
- package/lib/diagnostics.js +2 -2
- package/lib/doctor.js +163 -0
- package/lib/error-buffer.js +2 -2
- package/lib/forecast.js +56 -0
- package/lib/headless-auth.js +379 -0
- package/lib/history.js +36 -0
- package/lib/http.js +26 -4
- package/lib/index.js +98 -11
- package/lib/instructions.js +138 -0
- package/lib/jwt.js +84 -17
- package/lib/loopback.js +3 -2
- package/lib/mask.js +16 -0
- package/lib/oauth-pending.js +12 -0
- package/lib/pricing.js +89 -0
- package/lib/refs.js +2 -0
- package/lib/reset-clock.js +55 -0
- package/lib/rotate.js +18 -4
- package/lib/routes/accounts.js +203 -0
- package/lib/routes/oauth.js +44 -2
- package/lib/routes/proxy.js +14 -0
- package/lib/routes/status.js +99 -5
- package/lib/scope-inspector.js +51 -0
- package/lib/stream-rotate.js +31 -10
- package/lib/updater.js +21 -0
- package/lib/vault-sync.js +412 -0
- package/lib/vault.js +1 -1
- package/lib/vendor-factory.js +6 -2
- package/lib/vendors/antigravity.js +11 -2
- package/lib/vendors/claude.js +56 -8
- package/lib/vendors/codex.js +34 -6
- package/lib/vendors/copilot-cli.js +8 -1
- package/lib/vendors/ernie.js +9 -2
- package/lib/vendors/grok.js +66 -6
- package/lib/vendors/jetbrains.js +133 -13
- package/lib/vendors/perplexity.js +148 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -207,6 +207,12 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
207
207
|
* **In-App Toast Alerts (`enableToasts`)**: Lightweight floating toast alerts in the DSH chat interface notifying you when accounts enter quarantine, recover from cooldowns, or exceed quota safety thresholds.
|
|
208
208
|
* **Automated Webhook Integration**: Instant event notifications dispatched via `notifyQuarantineEntered` and `notifyQuarantineReleased`.
|
|
209
209
|
|
|
210
|
+
### 25. 🩺 Vendor Auth Doctor, Latency RTT Indicator, Dynamic Model Sync & Client Bundle Relief (v0.6.72)
|
|
211
|
+
* **Vendor Auth Doctor (#543)**: Intelligent classification and explanation of provider authentication and connection errors (Cloudflare 403 / Geoblock, expired tokens, revoked sessions, missing model scopes, exhausted quotas). Displays diagnostic verdict with actionable recommendations in the account card.
|
|
212
|
+
* **Latency RTT Indicator & Fast Ping Probe (#542)**: Live round-trip time measurement to vendor APIs with color-coded badges (🟢 <250ms, 🟡 <750ms, 🔴 >750ms). Dedicated ping endpoint (POST /dsh-subscriptions/ping) and latency metrics in connection tests.
|
|
213
|
+
* **Dynamic Model Catalog Discovery & Sync (#541)**: One-click "Sync Models" button on account cards querying live vendor /v1/models endpoints to discover newly released and subscription-specific models dynamically without waiting for plugin updates.
|
|
214
|
+
* **Client Bundle Modularization & Store Limit Relief (#540)**: Reduced lib/client.js bundle size from 217.3 KiB to < 199 KiB (>57 KiB buffer below the 256 KiB DSH Store ceiling) via createElement aliasing, CSS minification, and dynamic instruction loading.
|
|
215
|
+
|
|
210
216
|
### 24. 🛡️ Resilience, Diagnostics & Fallback Enhancements (v0.6.71)
|
|
211
217
|
* **Cross-Vendor Cascading Fallback & Provider Priority Ordering (#536)**: Configure fallback ordering across subscription providers with interactive reordering in WebUI.
|
|
212
218
|
* **1-Click Connection Smoke Test & Latency Ping (#536)**: Test individual account connections on the spot with real-time latency badges.
|
|
@@ -299,6 +305,13 @@ dsh-subscriptions:
|
|
|
299
305
|
|
|
300
306
|
---
|
|
301
307
|
|
|
308
|
+
### Custom Providers and Host Trust
|
|
309
|
+
|
|
310
|
+
- `customVendors.authUrl`, `tokenUrl` and `baseUrl` must use `https://`. Plain `http://` is accepted only for loopback hosts (`localhost`, `127.0.0.1`, `::1`).
|
|
311
|
+
- Grok `baseUrl` overrides must point to `api.x.ai`, `grok.com` / `*.grok.com`, or a loopback host; other hosts are rejected with `GROK_UNTRUSTED_HOST` so bearer tokens never leave trusted xAI infrastructure.
|
|
312
|
+
- The error buffer and diagnostics reports redact bearer tokens, API keys, JWTs and `key=value` credentials before storing or returning them.
|
|
313
|
+
|
|
314
|
+
|
|
302
315
|
## 🧠 Claude Adaptive Thinking & Effort (Added in v0.6.7)
|
|
303
316
|
|
|
304
317
|
Supports Anthropic's adaptive thinking (`thinking: { type: "adaptive" }`) and reasoning effort steering (`output_config: { effort }`):
|
package/README.ru.md
CHANGED
|
@@ -183,6 +183,12 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
183
183
|
* **Всплывающие уведомления (Toast Alerts)**: Легковесные всплывающие тосты в веб-интерфейсе DSH при событиях карантина (с отображением причины), восстановлении доступа и критическом расходе квоты. Включение управляется параметром `enableToasts` (по умолчанию `true`).
|
|
184
184
|
* **Интеграция с вебхуками**: Автоматическая отправка уведомлений через `notifyQuarantineEntered` и `notifyQuarantineReleased`.
|
|
185
185
|
|
|
186
|
+
### 21. 🩺 Доктор авторизации вендоров, индикатор задержки RTT, синхронизация моделей и оптимизация бандла (v0.6.72)
|
|
187
|
+
* **Доктор ошибок авторизации (#543)**: Интеллектуальный анализ и расшифровка ошибок подключения и аутентификации (Cloudflare 403 / геоблокировка, истекшие токены, отозванные сессии, нехватка разрешений на модель, исчерпанные квоты) с выводом рекомендаций в карточке аккаунта.
|
|
188
|
+
* **Индикатор RTT и быстрый пинг (#542)**: Замер времени отклика до API провайдеров в реальном времени с цветными бейджами задержки (зеленый < 250ms, желтый < 750ms, красный > 750ms) и отдельным эндпоинтом POST /dsh-subscriptions/ping.
|
|
189
|
+
* **Динамическая синхронизация моделей (#541)**: Кнопка «Синхронизировать модели» в карточке аккаунта, запрашивающая актуальный каталог моделей напрямую из API вендора (/v1/models) без ожидания новых релизов плагина.
|
|
190
|
+
* **Оптимизация клиентского бандла (#540)**: Снижение размера lib/client.js с 217.3 KiB до < 199 KiB (запас более 57 KiB до жесткого лимита DSH Store в 256 KiB) за счет сжатия CSS, алиасинга createElement и динамической загрузки инструкций.
|
|
191
|
+
|
|
186
192
|
### 20. 🛡️ Отказоустойчивость, диагностика и порядок фолбэка (v0.6.71)
|
|
187
193
|
* **Межвендорный каскадный фолбэк с настройкой очередности (#536)**: Настройка порядка переключения провайдеров при исчерпании квот с интерактивным изменением приоритета (▲/▼) в WebUI.
|
|
188
194
|
* **1-Click проверка подключения и пинг задержки (#536)**: Проверка конкретного аккаунта в один клик с отображением задержки (`✓ 240ms`).
|
|
@@ -272,6 +278,13 @@ dsh-subscriptions:
|
|
|
272
278
|
|
|
273
279
|
---
|
|
274
280
|
|
|
281
|
+
### Кастомные провайдеры и доверие хостам
|
|
282
|
+
|
|
283
|
+
- `customVendors.authUrl`, `tokenUrl` и `baseUrl` обязаны использовать `https://`. Открытый `http://` допускается только для loopback-хостов (`localhost`, `127.0.0.1`, `::1`).
|
|
284
|
+
- Переопределение Grok `baseUrl` должно указывать на `api.x.ai`, `grok.com` / `*.grok.com` или loopback-хост; остальные хосты отклоняются с `GROK_UNTRUSTED_HOST`, чтобы bearer-токен не покидал доверенную инфраструктуру xAI.
|
|
285
|
+
- Error buffer и диагностические отчёты редактируют bearer-токены, API-ключи, JWT и `key=value`-credentials до сохранения и выдачи.
|
|
286
|
+
|
|
287
|
+
|
|
275
288
|
## 🧠 Адаптивное мышление Claude и уровни усилия (Добавлено в v0.6.7)
|
|
276
289
|
|
|
277
290
|
Поддержка адаптивного мышления Anthropic (`thinking: { type: "adaptive" }`) и управления глубиной рассуждений (`output_config: { effort }`):
|
package/README.zh.md
CHANGED
|
@@ -132,6 +132,12 @@ graph LR
|
|
|
132
132
|
queueTimeoutMs: 30000 # v0.6.62: 队列最大等待超时时间(毫秒)
|
|
133
133
|
* **自动化 Webhook 告警集成**:通过 `notifyQuarantineEntered` 与 `notifyQuarantineReleased` 自动向外部通知系统派发隔离生命周期事件。
|
|
134
134
|
|
|
135
|
+
### 🩺 服务商鉴权诊断、网络延迟 RTT 指标、动态模型同步与客户端轻量化 (v0.6.72)
|
|
136
|
+
* **服务商错误诊断系统 (#543)**: 智能分析与解读服务商连接和鉴权失败原因(Cloudflare 403 / 地区限制、令牌过期、会话失效、模型权限不足、用量配额耗尽),并在卡片中直接展示修复建议。
|
|
137
|
+
* **延迟 RTT 指标与快速 Ping 测试 (#542)**: 实时测量至服务商 API 的网络往返时间,支持色彩标记状态徽章(🟢 <250ms, 🟡 <750ms, 🔴 >750ms)及独立 POST /dsh-subscriptions/ping 接口。
|
|
138
|
+
* **动态模型列表同步 (#541)**: 账户卡片一键「同步模型」按钮,直接从服务商 /v1/models 接口获取最新及账号专享模型,无需等待插件版本更新。
|
|
139
|
+
* **客户端包体积优化 (#540)**: 将 lib/client.js 体积从 217.3 KiB 缩减至 199 KiB 以下(距离 256 KiB DSH Store 门禁阈值预留超 57 KiB 安全冗余空间)。
|
|
140
|
+
|
|
135
141
|
### 🛡️ 弹性容灾、故障诊断与跨服务商回退 (v0.6.71)
|
|
136
142
|
* **跨服务商级联故障转移与优先级排序 (#536)**: 支持自定义跨服务商回退顺序,可在 WebUI 中通过 ▲/▼ 交互调整优先级。
|
|
137
143
|
* **一键链路测试与延迟检测 (#536)**: 在账户列表或状态弹窗中一键发起探活并显示往返延迟。
|
|
@@ -178,6 +184,13 @@ dsh plugin --profile web add @goodandready/dsh-subscriptions
|
|
|
178
184
|
|
|
179
185
|
---
|
|
180
186
|
|
|
187
|
+
### 自定义供应商与主机信任
|
|
188
|
+
|
|
189
|
+
- `customVendors.authUrl`、`tokenUrl` 和 `baseUrl` 必须使用 `https://`。明文 `http://` 仅允许用于回环主机(`localhost`、`127.0.0.1`、`::1`)。
|
|
190
|
+
- Grok `baseUrl` 覆盖只能指向 `api.x.ai`、`grok.com` / `*.grok.com` 或回环主机;其他主机会以 `GROK_UNTRUSTED_HOST` 拒绝,避免 bearer 令牌离开可信的 xAI 基础设施。
|
|
191
|
+
- 错误缓冲区和诊断报告会在存储或返回前脱敏 bearer 令牌、API 密钥、JWT 和 `key=value` 凭据。
|
|
192
|
+
|
|
193
|
+
|
|
181
194
|
## 🧠 Claude 自适应思考与推理深度控制 (v0.6.7 新增)
|
|
182
195
|
|
|
183
196
|
支持 Anthropic 自适应思考 (`thinking: { type: "adaptive" }`) 及推理深度调节 (`output_config: { effort }`):
|
package/lib/accounts-view.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
+
import { inspectAccountCapabilities } from './capabilities.js'
|
|
2
|
+
import { extractBillingInfo } from './billing.js'
|
|
3
|
+
import { calculatePacingRunway } from './forecast.js'
|
|
4
|
+
import { calculateValueDelivered } from './pricing.js'
|
|
5
|
+
import { notifyBillingAlert } from './alerts.js'
|
|
6
|
+
import { defaultConcurrencyQueue } from './concurrency-queue.js'
|
|
1
7
|
import { normalizeSlots } from './accounts.js'
|
|
2
8
|
import {
|
|
3
9
|
inspectGoogleAccount,
|
|
4
10
|
antigravityMetadata,
|
|
5
11
|
antigravityIdentityHeaders,
|
|
6
12
|
} from './code-assist.js'
|
|
13
|
+
import {
|
|
14
|
+
computeEffectiveResetAt,
|
|
15
|
+
formatRollingCountdown,
|
|
16
|
+
VENDOR_WINDOW_MS,
|
|
17
|
+
} from './reset-clock.js'
|
|
7
18
|
|
|
8
19
|
async function enrichAntigravityAccount(slot, info, store, fetch) {
|
|
9
20
|
if (!info.configured || slot.provider !== 'antigravity') return info
|
|
@@ -15,6 +26,7 @@ async function enrichAntigravityAccount(slot, info, store, fetch) {
|
|
|
15
26
|
validationUrl: blob.validationUrl,
|
|
16
27
|
validationMessage: blob.validationMessage || info.validationMessage || '',
|
|
17
28
|
paidTierName: blob.paidTierName || info.paidTierName || '',
|
|
29
|
+
plan: blob.plan || info.plan || blob.paidTierName || info.paidTierName || '',
|
|
18
30
|
}
|
|
19
31
|
}
|
|
20
32
|
try {
|
|
@@ -37,28 +49,96 @@ async function enrichAntigravityAccount(slot, info, store, fetch) {
|
|
|
37
49
|
validationUrl: next.validationUrl,
|
|
38
50
|
validationMessage: next.validationMessage,
|
|
39
51
|
paidTierName: next.paidTierName || '',
|
|
52
|
+
plan: info.plan || next.paidTierName || '',
|
|
40
53
|
}
|
|
41
54
|
}
|
|
42
55
|
if (probe.notice?.message) {
|
|
43
56
|
next.accountNotice = probe.notice.message
|
|
44
57
|
await store.saveBlob(slot.ref, next)
|
|
45
|
-
return {
|
|
58
|
+
return {
|
|
59
|
+
...info,
|
|
60
|
+
accountNotice: next.accountNotice,
|
|
61
|
+
paidTierName: next.paidTierName || '',
|
|
62
|
+
plan: info.plan || next.paidTierName || '',
|
|
63
|
+
}
|
|
46
64
|
}
|
|
47
65
|
if (probe.projectId || probe.paidTierId) await store.saveBlob(slot.ref, next)
|
|
48
|
-
return {
|
|
66
|
+
return {
|
|
67
|
+
...info,
|
|
68
|
+
paidTierName: next.paidTierName || info.paidTierName || '',
|
|
69
|
+
plan: info.plan || next.paidTierName || '',
|
|
70
|
+
}
|
|
49
71
|
} catch { /* keep settings responsive */ }
|
|
50
72
|
return info
|
|
51
73
|
}
|
|
52
74
|
|
|
53
|
-
export async function createAccountsView({ live, store, fetch, pmL }) {
|
|
75
|
+
export async function createAccountsView({ live, store, fetch, pmL, pmE, concurrencyQueue = defaultConcurrencyQueue, history = null }) {
|
|
54
76
|
const out = []
|
|
77
|
+
const qStats = (concurrencyQueue && typeof concurrencyQueue.stats === 'function') ? concurrencyQueue.stats() : null
|
|
78
|
+
const now = Date.now()
|
|
55
79
|
for (const slot of normalizeSlots(live().slots)) {
|
|
56
|
-
const
|
|
80
|
+
const rawInfo = await store.describeRef(slot.ref)
|
|
81
|
+
const info = await enrichAntigravityAccount(slot, rawInfo, store, fetch)
|
|
82
|
+
const effectiveResetAt = computeEffectiveResetAt(info, slot.provider)
|
|
83
|
+
const resetCountdown = effectiveResetAt ? formatRollingCountdown(effectiveResetAt, 'en') : ''
|
|
84
|
+
const slotQ = (qStats && qStats.byKey && qStats.byKey[slot.ref]) || { active: 0, queued: 0 }
|
|
85
|
+
|
|
86
|
+
let blob = null
|
|
87
|
+
try { blob = await store.loadBlob(slot.ref) } catch { /* best effort */ }
|
|
88
|
+
|
|
89
|
+
// #556: Capability matrix
|
|
90
|
+
const capabilities = inspectAccountCapabilities(slot.provider, blob, info)
|
|
91
|
+
|
|
92
|
+
// #558: Billing cycle & payment status
|
|
93
|
+
const billing = extractBillingInfo(blob, slot, now)
|
|
94
|
+
if (billing.paymentFailed || billing.isOverdue) {
|
|
95
|
+
try {
|
|
96
|
+
notifyBillingAlert({
|
|
97
|
+
ref: slot.ref,
|
|
98
|
+
provider: slot.provider,
|
|
99
|
+
status: billing.status,
|
|
100
|
+
renewsAt: billing.renewsAt,
|
|
101
|
+
webhookUrl: live()?.webhookUrl,
|
|
102
|
+
fetchImpl: fetch,
|
|
103
|
+
nowMs: now,
|
|
104
|
+
})
|
|
105
|
+
} catch { /* best-effort alert */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// #559: Quota pacing runway
|
|
109
|
+
const remPct = info.quota?.remainingPercent != null
|
|
110
|
+
? Number(info.quota.remainingPercent)
|
|
111
|
+
: (info.usagePercent != null ? Math.max(0, 100 - Number(info.usagePercent)) : null)
|
|
112
|
+
const runway = calculatePacingRunway(remPct, info.pacePerHour, effectiveResetAt, now)
|
|
113
|
+
|
|
114
|
+
// #561: Value delivered & equivalent API cost
|
|
115
|
+
const accTelem = history && typeof history.accountTelemetry === 'function' ? history.accountTelemetry(slot.ref) : null
|
|
116
|
+
const valueDelivered = calculateValueDelivered({
|
|
117
|
+
promptTokens: accTelem?.totalPromptTokens || 0,
|
|
118
|
+
completionTokens: accTelem?.totalOutputTokens || 0,
|
|
119
|
+
cacheReadTokens: accTelem?.totalCacheReadTokens || 0,
|
|
120
|
+
subscriptionCost: slot.monthlyCost || 20.0,
|
|
121
|
+
})
|
|
122
|
+
|
|
57
123
|
out.push({
|
|
58
124
|
provider: slot.provider,
|
|
59
125
|
index: slot.index,
|
|
60
126
|
ref: slot.ref,
|
|
61
|
-
|
|
127
|
+
activeStreams: slotQ.active || 0,
|
|
128
|
+
queuedStreams: slotQ.queued || 0,
|
|
129
|
+
maxConcurrency: slot.maxConcurrency !== undefined ? slot.maxConcurrency : ((live() && live().accountMaxConcurrency) || 1),
|
|
130
|
+
label: pmL ? pmL(slot.label || info.label) : (slot.label || info.label),
|
|
131
|
+
email: typeof pmE === 'function' ? pmE(info.email || '') : String(info.email || ''),
|
|
132
|
+
plan: info.plan || info.paidTierName || '',
|
|
133
|
+
avatarUrl: info.avatarUrl || '',
|
|
134
|
+
scopes: Array.isArray(info.scopes) ? info.scopes : [],
|
|
135
|
+
scopeWarning: info.scopeWarning || '',
|
|
136
|
+
selectedOrgId: info.selectedOrgId || '',
|
|
137
|
+
selectedOrgName: info.selectedOrgName || '',
|
|
138
|
+
organizations: Array.isArray(info.organizations) ? info.organizations : [],
|
|
139
|
+
isEncryptedAtRest: Boolean(info.isEncryptedAtRest),
|
|
140
|
+
isRevoked: Boolean(info.isRevoked),
|
|
141
|
+
authWarning: info.authWarning || '',
|
|
62
142
|
configured: info.configured,
|
|
63
143
|
writable: info.writable,
|
|
64
144
|
cooldownUntil: info.cooldownUntil,
|
|
@@ -72,12 +152,28 @@ export async function createAccountsView({ live, store, fetch, pmL }) {
|
|
|
72
152
|
usagePercent: info.usagePercent,
|
|
73
153
|
quota: info.quota || null,
|
|
74
154
|
usage: info.usage || null,
|
|
155
|
+
resetAt: effectiveResetAt || (info.quota && info.quota.resetAt) || null,
|
|
156
|
+
resetCountdown: resetCountdown || '',
|
|
157
|
+
resetWindowMs: VENDOR_WINDOW_MS[slot.provider] || 0,
|
|
75
158
|
requests: store.getRequestCount(slot.ref) || null,
|
|
76
159
|
refreshError: info.refreshError || '',
|
|
77
160
|
validationUrl: info.validationUrl || '',
|
|
78
161
|
validationMessage: info.validationMessage || '',
|
|
79
162
|
accountNotice: info.accountNotice || '',
|
|
80
163
|
paidTierName: info.paidTierName || '',
|
|
164
|
+
lastPingMs: info.lastPingMs ?? null,
|
|
165
|
+
latencyGrade: info.latencyGrade ?? null,
|
|
166
|
+
lastDoctorVerdict: info.lastDoctorVerdict || null,
|
|
167
|
+
availableModels: info.availableModels || null,
|
|
168
|
+
capabilities,
|
|
169
|
+
billing,
|
|
170
|
+
renewsAt: billing.renewsAt,
|
|
171
|
+
renewalCountdown: billing.renewalCountdown,
|
|
172
|
+
billingWarning: billing.warning,
|
|
173
|
+
runwayMs: runway.runwayMs,
|
|
174
|
+
runwayText: runway.runwayText,
|
|
175
|
+
pacingStatus: runway.pacingStatus,
|
|
176
|
+
valueDelivered,
|
|
81
177
|
})
|
|
82
178
|
}
|
|
83
179
|
return out
|
package/lib/accounts.js
CHANGED
|
@@ -45,6 +45,7 @@ import { fetchWithTimeout, isTimeoutError } from "./http.js"
|
|
|
45
45
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
46
46
|
import { oauthRef, isProvider } from './refs.js'
|
|
47
47
|
import { parseBlob, serializeBlob } from './blob.js'
|
|
48
|
+
import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
|
|
48
49
|
import { recordSwitch, recordExhaust, recordBroken, computeHealthScore, healthBadge } from './health.js'
|
|
49
50
|
import { executeWithRetry } from './backoff.js'
|
|
50
51
|
import { getVendor } from './vendors/index.js'
|
|
@@ -71,6 +72,7 @@ export function normalizeSlots(slots) {
|
|
|
71
72
|
ref,
|
|
72
73
|
...(slot.proxyUrl ? { proxyUrl: String(slot.proxyUrl) } : {}),
|
|
73
74
|
...(slot.expiresAt ? { expiresAt: Number(slot.expiresAt) } : {}),
|
|
75
|
+
...(slot.maxConcurrency !== undefined ? { maxConcurrency: Number(slot.maxConcurrency) } : {}),
|
|
74
76
|
})
|
|
75
77
|
}
|
|
76
78
|
return out
|
|
@@ -118,16 +120,78 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
118
120
|
const requestCounts = new Map()
|
|
119
121
|
const windows = new Map()
|
|
120
122
|
const usageFetched = new Map()
|
|
123
|
+
const pings = new Map()
|
|
124
|
+
const doctorVerdicts = new Map()
|
|
125
|
+
const dynamicModels = new Map()
|
|
121
126
|
const doFetch = fetchImpl || fetch
|
|
127
|
+
const clockDrifts = new Map()
|
|
128
|
+
|
|
129
|
+
function getClockSkew(provider) {
|
|
130
|
+
if (!provider) return 0
|
|
131
|
+
return clockDrifts.get(String(provider).toLowerCase()) || 0
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function recordClockSkew(provider, vendorDateMsOrHeader) {
|
|
135
|
+
if (!provider) return
|
|
136
|
+
const key = String(provider).toLowerCase()
|
|
137
|
+
if (typeof vendorDateMsOrHeader === 'number') {
|
|
138
|
+
clockDrifts.set(key, vendorDateMsOrHeader - Date.now())
|
|
139
|
+
} else if (typeof vendorDateMsOrHeader === 'string') {
|
|
140
|
+
const parsed = Date.parse(vendorDateMsOrHeader)
|
|
141
|
+
if (!isNaN(parsed)) {
|
|
142
|
+
clockDrifts.set(key, parsed - Date.now())
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
122
147
|
// #88: per-account proxy fetch for token refresh / usage (falls back to doFetch).
|
|
123
148
|
const rawFetchFor = (ref) => (typeof fetchForRef === 'function' && fetchForRef(ref)) || doFetch
|
|
124
|
-
const fetchFor = (ref, defaultTimeoutMs = 25000
|
|
149
|
+
const fetchFor = (ref, defaultTimeoutMs = 25000, providerHint = null) => async (url, init, opts) => {
|
|
150
|
+
const res = await fetchWithTimeout(rawFetchFor(ref), url, init, { timeoutMs: defaultTimeoutMs, ...(opts || {}) })
|
|
151
|
+
try {
|
|
152
|
+
const dateHeader = res && res.headers && typeof res.headers.get === 'function' ? res.headers.get('date') : null
|
|
153
|
+
if (dateHeader) {
|
|
154
|
+
const prov = providerHint || (ref ? String(ref).split('_')[0].toLowerCase() : null)
|
|
155
|
+
if (prov) recordClockSkew(prov, dateHeader)
|
|
156
|
+
}
|
|
157
|
+
} catch { /* best-effort drift extraction */ }
|
|
158
|
+
return res
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resolveMasterKey(passedCfg = null) {
|
|
162
|
+
const envKey = process.env.DSH_SUBSCRIPTIONS_MASTER_KEY
|
|
163
|
+
if (typeof envKey === 'string' && envKey.trim()) return envKey.trim()
|
|
164
|
+
if (passedCfg && typeof passedCfg.masterKey === 'string' && passedCfg.masterKey.trim()) {
|
|
165
|
+
return passedCfg.masterKey.trim()
|
|
166
|
+
}
|
|
167
|
+
return ''
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function currentMasterKey() {
|
|
171
|
+
let cfg = null
|
|
172
|
+
try {
|
|
173
|
+
cfg = typeof getConfig === 'function' ? getConfig() : null
|
|
174
|
+
} catch { /* keep env-only fallback */ }
|
|
175
|
+
return resolveMasterKey(cfg)
|
|
176
|
+
}
|
|
125
177
|
|
|
126
178
|
async function resolveRaw(ref) {
|
|
127
179
|
try {
|
|
128
180
|
const resolved = await credentials.resolve(credentialRef(ref))
|
|
129
|
-
|
|
130
|
-
|
|
181
|
+
const raw = resolved && resolved.value ? String(resolved.value) : ''
|
|
182
|
+
if (!raw) return ''
|
|
183
|
+
if (raw.startsWith('DSHE1:') || raw.startsWith('DSHE2:') || raw.startsWith('DSHE3:')) {
|
|
184
|
+
const masterKey = currentMasterKey()
|
|
185
|
+
if (!masterKey) {
|
|
186
|
+
const err = /** @type {Error & { code?: string }} */ (new Error(`Credentials for ${ref} are encrypted at rest. Configure masterKey in the plugin settings or DSH_SUBSCRIPTIONS_MASTER_KEY to decrypt.`))
|
|
187
|
+
err.code = 'ENCRYPTED_AT_REST'
|
|
188
|
+
throw err
|
|
189
|
+
}
|
|
190
|
+
return decryptWithPassphrase(raw, masterKey)
|
|
191
|
+
}
|
|
192
|
+
return raw
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (err && err.code === 'ENCRYPTED_AT_REST') throw err
|
|
131
195
|
return ''
|
|
132
196
|
}
|
|
133
197
|
}
|
|
@@ -155,11 +219,16 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
155
219
|
return
|
|
156
220
|
}
|
|
157
221
|
try {
|
|
158
|
-
|
|
222
|
+
const serialized = serializeBlob(blob)
|
|
223
|
+
const masterKey = currentMasterKey()
|
|
224
|
+
const toStore = masterKey ? encryptWithPassphrase(serialized, masterKey) : serialized
|
|
225
|
+
await credentials.set(credentialRef(ref), toStore)
|
|
159
226
|
if (clearedRefs.has(ref)) {
|
|
160
227
|
try { await credentials.unset(credentialRef(ref)) } catch { /* best-effort */ }
|
|
161
228
|
} else if (credentialVersions.get(ref) > version && latestBlobs.has(ref)) {
|
|
162
|
-
|
|
229
|
+
const nextSerialized = serializeBlob(latestBlobs.get(ref))
|
|
230
|
+
const nextStore = masterKey ? encryptWithPassphrase(nextSerialized, masterKey) : nextSerialized
|
|
231
|
+
try { await credentials.set(credentialRef(ref), nextStore) } catch { /* best-effort */ }
|
|
163
232
|
}
|
|
164
233
|
} catch (err) {
|
|
165
234
|
if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
|
|
@@ -201,7 +270,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
201
270
|
windows.delete(ref)
|
|
202
271
|
}
|
|
203
272
|
|
|
204
|
-
async function describeRef(ref) {
|
|
273
|
+
async function describeRef(ref, cfg = null) {
|
|
205
274
|
const base = { ref, configured: false, writable: true, label: '', email: '' }
|
|
206
275
|
try {
|
|
207
276
|
if (typeof credentials.describe === 'function') {
|
|
@@ -214,16 +283,34 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
214
283
|
} catch {
|
|
215
284
|
return base
|
|
216
285
|
}
|
|
286
|
+
let isEncryptedAtRest = false
|
|
287
|
+
try {
|
|
288
|
+
const resolved = await credentials.resolve(credentialRef(ref))
|
|
289
|
+
const raw = resolved && resolved.value ? String(resolved.value) : ''
|
|
290
|
+
if (raw.startsWith('DSHE1:') || raw.startsWith('DSHE2:') || raw.startsWith('DSHE3:')) {
|
|
291
|
+
isEncryptedAtRest = true
|
|
292
|
+
} else if (Boolean(resolveMasterKey(cfg))) {
|
|
293
|
+
isEncryptedAtRest = true
|
|
294
|
+
}
|
|
295
|
+
} catch { /* best-effort */ }
|
|
296
|
+
|
|
217
297
|
if (base.configured) {
|
|
218
298
|
try {
|
|
219
299
|
const blob = parseBlob(await resolveRaw(ref))
|
|
220
300
|
base.label = blob.label || blob.email || ''
|
|
221
301
|
base.email = blob.email || ''
|
|
302
|
+
base.plan = blob.plan || blob.paidTierName || ''
|
|
303
|
+
base.avatarUrl = blob.avatarUrl || ''
|
|
304
|
+
base.scopes = Array.isArray(blob.scopes) ? blob.scopes : []
|
|
305
|
+
base.scopeWarning = blob.scopeWarning || ''
|
|
222
306
|
if (Array.isArray(blob.usage)) { base.usage = blob.usage; base.usageAt = blob.usageAt || 0 }
|
|
223
307
|
if (blob.validationUrl) base.validationUrl = blob.validationUrl
|
|
224
308
|
if (blob.validationMessage) base.validationMessage = blob.validationMessage
|
|
225
309
|
if (blob.accountNotice) base.accountNotice = blob.accountNotice
|
|
226
310
|
if (blob.paidTierName) base.paidTierName = blob.paidTierName
|
|
311
|
+
base.selectedOrgId = blob.selectedOrgId || blob.accountId || blob.projectId || ''
|
|
312
|
+
base.selectedOrgName = blob.selectedOrgName || base.selectedOrgId || ''
|
|
313
|
+
base.organizations = Array.isArray(blob.organizations) ? blob.organizations : []
|
|
227
314
|
} catch { /* ignore parse */ }
|
|
228
315
|
}
|
|
229
316
|
return {
|
|
@@ -242,10 +329,32 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
242
329
|
usagePercent: usage.has(ref) ? usage.get(ref) : null,
|
|
243
330
|
refreshError: refreshFailures.get(ref)?.error || '',
|
|
244
331
|
isTimeout: !!refreshFailures.get(ref)?.isTimeout,
|
|
332
|
+
plan: base.plan || '',
|
|
333
|
+
avatarUrl: base.avatarUrl || '',
|
|
334
|
+
scopes: base.scopes || [],
|
|
335
|
+
scopeWarning: base.scopeWarning || '',
|
|
336
|
+
selectedOrgId: base.selectedOrgId || '',
|
|
337
|
+
selectedOrgName: base.selectedOrgName || '',
|
|
338
|
+
organizations: base.organizations || [],
|
|
339
|
+
isEncryptedAtRest,
|
|
340
|
+
isRevoked: Boolean(
|
|
341
|
+
(quarantines.get(ref)?.reason === 'REVOKED' || quarantines.get(ref)?.reason === 'SESSION_REVOKED') ||
|
|
342
|
+
(doctorVerdicts.get(ref)?.code === 'TOKEN_EXPIRED' || doctorVerdicts.get(ref)?.code === 'TOKEN_REVOKED') ||
|
|
343
|
+
(/invalid_grant|token_revoked|revoked|session expired/i.test(refreshFailures.get(ref)?.error || ''))
|
|
344
|
+
),
|
|
345
|
+
authWarning: (
|
|
346
|
+
(quarantines.get(ref)?.reason === 'REVOKED' || quarantines.get(ref)?.reason === 'SESSION_REVOKED') ||
|
|
347
|
+
(doctorVerdicts.get(ref)?.code === 'TOKEN_EXPIRED' || doctorVerdicts.get(ref)?.code === 'TOKEN_REVOKED') ||
|
|
348
|
+
(/invalid_grant|token_revoked|revoked|session expired/i.test(refreshFailures.get(ref)?.error || ''))
|
|
349
|
+
) ? 'Session expired / revoked — re-authorization required' : '',
|
|
245
350
|
validationUrl: base.validationUrl || '',
|
|
246
351
|
validationMessage: base.validationMessage || '',
|
|
247
352
|
accountNotice: base.accountNotice || '',
|
|
248
353
|
paidTierName: base.paidTierName || '',
|
|
354
|
+
lastPingMs: pings.get(ref)?.latencyMs ?? null,
|
|
355
|
+
latencyGrade: pings.get(ref)?.latencyGrade ?? null,
|
|
356
|
+
lastDoctorVerdict: doctorVerdicts.get(ref) || null,
|
|
357
|
+
availableModels: dynamicModels.get(ref)?.models || null,
|
|
249
358
|
}
|
|
250
359
|
}
|
|
251
360
|
|
|
@@ -254,7 +363,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
254
363
|
const slots = normalizeSlots(curConfig.slots).filter((s) => s.provider === provider)
|
|
255
364
|
const out = []
|
|
256
365
|
for (const slot of slots) {
|
|
257
|
-
const info = await describeRef(slot.ref)
|
|
366
|
+
const info = await describeRef(slot.ref, curConfig)
|
|
258
367
|
const q = quarantines.get(slot.ref)
|
|
259
368
|
out.push({
|
|
260
369
|
ref: slot.ref,
|
|
@@ -269,6 +378,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
269
378
|
quarantineReason: q ? q.reason : null,
|
|
270
379
|
label: slot.label || info.label,
|
|
271
380
|
pacePerHour: info.pacePerHour || 0,
|
|
381
|
+
maxConcurrency: slot.maxConcurrency !== undefined ? slot.maxConcurrency : (curConfig.accountMaxConcurrency || 1),
|
|
272
382
|
})
|
|
273
383
|
}
|
|
274
384
|
return out
|
|
@@ -279,7 +389,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
279
389
|
const slots = normalizeSlots(curConfig.slots)
|
|
280
390
|
const found = new Set()
|
|
281
391
|
for (const slot of slots) {
|
|
282
|
-
const info = await describeRef(slot.ref)
|
|
392
|
+
const info = await describeRef(slot.ref, curConfig)
|
|
283
393
|
if (info.configured) found.add(slot.provider)
|
|
284
394
|
}
|
|
285
395
|
return [...found]
|
|
@@ -289,15 +399,34 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
289
399
|
if (!blob || typeof blob !== 'object') return blob
|
|
290
400
|
if (blob.apiKeyOnly || (blob.apiKey && (!blob.refreshToken || blob.refreshToken === blob.apiKey))) return blob
|
|
291
401
|
if (!blob.refreshToken) return blob
|
|
292
|
-
|
|
402
|
+
|
|
403
|
+
const curConfig = getConfig()
|
|
404
|
+
const safetyMargin = Math.max(SKEW_MS, Number(curConfig?.refreshAheadMs) || (5 * 60 * 1000))
|
|
405
|
+
const clockSkew = getClockSkew(provider)
|
|
406
|
+
const adjustedNow = Date.now() + clockSkew
|
|
407
|
+
|
|
408
|
+
if (blob.expiresAt && blob.expiresAt - safetyMargin > adjustedNow) return blob
|
|
409
|
+
|
|
410
|
+
if (ref && refreshFailures.has(ref)) {
|
|
411
|
+
const failInfo = refreshFailures.get(ref)
|
|
412
|
+
const retryMs = Math.min(Number(curConfig?.refreshRetryMs) || 10000, 60000)
|
|
413
|
+
if (Date.now() - (failInfo.at || 0) < retryMs) {
|
|
414
|
+
if (blob.expiresAt && blob.expiresAt > adjustedNow && blob.accessToken) {
|
|
415
|
+
return blob
|
|
416
|
+
}
|
|
417
|
+
const err = /** @type {Error & { code?: string }} */ (new Error(`Token refresh in backoff window after failure: ${failInfo.error || 'transient failure'}`))
|
|
418
|
+
err.code = 'TOKEN'
|
|
419
|
+
throw err
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
293
423
|
const lockKey = ref || (provider + ":" + (blob.accountId || blob.email || (blob.refreshToken && blob.refreshToken.slice(-16)) || "anon"))
|
|
294
424
|
if (refreshLocks.has(lockKey)) return refreshLocks.get(lockKey)
|
|
295
425
|
const promise = (async () => {
|
|
296
426
|
try {
|
|
297
|
-
const curConfig = getConfig()
|
|
298
427
|
const cfg = vendorConfig(provider, curConfig)
|
|
299
428
|
const next = await executeWithRetry(
|
|
300
|
-
() => getVendor(provider).refresh(cfg, blob, fetchFor(ref, 30000)),
|
|
429
|
+
() => getVendor(provider).refresh(cfg, blob, fetchFor(ref, 30000, provider)),
|
|
301
430
|
{
|
|
302
431
|
maxRetries: 2,
|
|
303
432
|
initialDelayMs: 300,
|
|
@@ -439,6 +568,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
439
568
|
resolveRaw,
|
|
440
569
|
ensureFresh,
|
|
441
570
|
refreshUsage,
|
|
571
|
+
getClockSkew,
|
|
572
|
+
recordClockSkew,
|
|
442
573
|
// #87: family-scoped cooldowns. families=null blocks the whole account
|
|
443
574
|
// (legacy behavior); a list blocks only those model families.
|
|
444
575
|
rememberCooldown(ref, until, families) {
|
|
@@ -479,6 +610,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
479
610
|
quotaSamples.delete(ref)
|
|
480
611
|
}
|
|
481
612
|
},
|
|
613
|
+
clearRefreshFailure(ref) { if (ref) refreshFailures.delete(ref) },
|
|
614
|
+
clearCooldown(ref) { if (ref) cooldowns.delete(ref) },
|
|
482
615
|
shouldSkipRefresh(ref, now, retryMs) { const f = refreshFailures.get(ref); if (!f) return false; return (Number(f.at) + Number(retryMs)) > Number(now) },
|
|
483
616
|
getQuota(ref) { return quotas.get(ref) || null },
|
|
484
617
|
rememberUsage(ref, percent) { usage.set(ref, percent) },
|
|
@@ -550,5 +683,11 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
550
683
|
},
|
|
551
684
|
rememberRequest(ref) { requestCounts.set(ref, (requestCounts.get(ref) || 0) + 1) },
|
|
552
685
|
getRequestCount(ref) { return requestCounts.get(ref) || 0 },
|
|
686
|
+
rememberPing(ref, { latencyMs, latencyGrade }) { pings.set(ref, { latencyMs, latencyGrade, at: Date.now() }) },
|
|
687
|
+
getPing(ref) { return pings.get(ref) || null },
|
|
688
|
+
rememberDoctorVerdict(ref, verdict) { if (verdict) doctorVerdicts.set(ref, { ...verdict, at: Date.now() }); else doctorVerdicts.delete(ref); },
|
|
689
|
+
getDoctorVerdict(ref) { return doctorVerdicts.get(ref) || null },
|
|
690
|
+
rememberDynamicModels(ref, models) { if (Array.isArray(models)) dynamicModels.set(ref, { models, at: Date.now() }) },
|
|
691
|
+
getDynamicModels(ref) { return dynamicModels.get(ref)?.models || null },
|
|
553
692
|
}
|
|
554
693
|
}
|
package/lib/adapter.js
CHANGED
|
@@ -180,6 +180,9 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
180
180
|
deps.rememberQuarantine(account.ref, account.quarantineReason || 'RATE_LIMIT', account.quarantineUntil || account.cooldownUntil, account.quarantineAttempts || 1)
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
+
if (account.quarantineReason === 'REVOKED' || account.isRevoked) {
|
|
184
|
+
if (typeof deps.recordBroken === 'function') deps.recordBroken(account.ref)
|
|
185
|
+
}
|
|
183
186
|
if (typeof deps.recordSwitch === 'function') deps.recordSwitch(account.ref)
|
|
184
187
|
},
|
|
185
188
|
streamOnce: async function* (account, opts) {
|
package/lib/alerts.js
CHANGED
|
@@ -209,3 +209,38 @@ export function notifyQuarantineReleased({
|
|
|
209
209
|
}
|
|
210
210
|
return alert
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* #558: Fires an alert when a subscription experiences a billing failure or overdue renewal.
|
|
215
|
+
*/
|
|
216
|
+
export function notifyBillingAlert({
|
|
217
|
+
ref,
|
|
218
|
+
provider,
|
|
219
|
+
status,
|
|
220
|
+
renewsAt,
|
|
221
|
+
message,
|
|
222
|
+
webhookUrl = '',
|
|
223
|
+
fetchImpl = fetch,
|
|
224
|
+
nowMs = Date.now(),
|
|
225
|
+
}) {
|
|
226
|
+
const isFailed = status === 'past_due' || status === 'unpaid' || status === 'payment_failed'
|
|
227
|
+
const severity = isFailed ? 'error' : 'warn'
|
|
228
|
+
const defaultMsg = isFailed
|
|
229
|
+
? `Billing alert: ${provider || 'Account'} (${ref || 'slot'}) has payment issue (${status}).`
|
|
230
|
+
: `Subscription renewal alert: ${provider || 'Account'} (${ref || 'slot'}) renewal is overdue.`
|
|
231
|
+
|
|
232
|
+
const alert = recordAlert({
|
|
233
|
+
type: 'billing_status',
|
|
234
|
+
ref,
|
|
235
|
+
provider,
|
|
236
|
+
message: message || defaultMsg,
|
|
237
|
+
severity,
|
|
238
|
+
details: { status, renewsAt },
|
|
239
|
+
nowMs,
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
if (alert && webhookUrl) {
|
|
243
|
+
dispatchWebhookAlert(alert, webhookUrl, fetchImpl).catch(() => {})
|
|
244
|
+
}
|
|
245
|
+
return alert
|
|
246
|
+
}
|
package/lib/analytics.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { calculateValueDelivered } from './pricing.js'
|
|
2
|
+
import { calculatePacingRunway } from './forecast.js'
|
|
1
3
|
// Subscription and cache analytics, performance aggregates, and quota forecasting.
|
|
2
4
|
// Enforces 0 Cyrillic characters.
|
|
3
5
|
|
|
@@ -240,6 +242,11 @@ export function calculateAccountBreakdown(entries = [], accounts = []) {
|
|
|
240
242
|
avgTtftMs: acc.ttftCount > 0 ? Math.round(acc.ttftSum / acc.ttftCount) : null,
|
|
241
243
|
avgTps: acc.tpsCount > 0 ? Math.round((acc.tpsSum / acc.tpsCount) * 10) / 10 : null,
|
|
242
244
|
avgLatencyMs: acc.totalRequests > 0 ? Math.round(acc.latencySum / acc.totalRequests) : 0,
|
|
245
|
+
valueDelivered: calculateValueDelivered({
|
|
246
|
+
promptTokens: acc.promptTokens,
|
|
247
|
+
completionTokens: acc.completionTokens,
|
|
248
|
+
cacheReadTokens: acc.cacheReadTokens,
|
|
249
|
+
}),
|
|
243
250
|
}
|
|
244
251
|
}
|
|
245
252
|
return result
|
|
@@ -307,18 +314,28 @@ export function calculateQuotaForecast(accounts = [], entries = [], nowMs = Date
|
|
|
307
314
|
}
|
|
308
315
|
}
|
|
309
316
|
|
|
317
|
+
const qPct = quota && typeof quota.percentage === 'number'
|
|
318
|
+
? quota.percentage
|
|
319
|
+
: (acc.usagePercent != null ? Math.max(0, 100 - Number(acc.usagePercent)) : null)
|
|
320
|
+
const qReset = quota && typeof quota.resetAt === 'number' ? quota.resetAt : (acc.resetAt || null)
|
|
321
|
+
const runway = calculatePacingRunway(qPct, acc.pacePerHour || 0, qReset, nowMs)
|
|
322
|
+
|
|
310
323
|
forecast.push({
|
|
311
324
|
ref,
|
|
312
325
|
provider: acc.provider,
|
|
313
326
|
label: acc.label || ref,
|
|
314
|
-
quotaPercent:
|
|
315
|
-
resetAt:
|
|
327
|
+
quotaPercent: qPct,
|
|
328
|
+
resetAt: qReset,
|
|
316
329
|
burnRateTokensPerHour,
|
|
317
330
|
requests24h,
|
|
318
331
|
tokens24h,
|
|
319
332
|
hoursToExhaustion,
|
|
320
333
|
willExhaustBeforeReset,
|
|
321
334
|
projectedExhaustionAt,
|
|
335
|
+
runwayHours: runway.runwayHours,
|
|
336
|
+
runwayMs: runway.runwayMs,
|
|
337
|
+
runwayText: runway.runwayText,
|
|
338
|
+
pacingStatus: runway.pacingStatus,
|
|
322
339
|
})
|
|
323
340
|
}
|
|
324
341
|
|
|
@@ -326,6 +343,17 @@ export function calculateQuotaForecast(accounts = [], entries = [], nowMs = Date
|
|
|
326
343
|
}
|
|
327
344
|
|
|
328
345
|
export function calculateAnalytics(entries = [], accounts = [], nowMs = Date.now()) {
|
|
346
|
+
let totalPrompt = 0, totalCompletion = 0, totalCacheRead = 0
|
|
347
|
+
for (const r of entries) {
|
|
348
|
+
totalPrompt += Number(r.inputTokens || (r.tokens && r.tokens.prompt) || 0)
|
|
349
|
+
totalCompletion += Number(r.outputTokens || (r.tokens && r.tokens.completion) || 0)
|
|
350
|
+
totalCacheRead += Number(r.cacheReadTokens || (r.tokens && r.tokens.cacheRead) || 0)
|
|
351
|
+
}
|
|
352
|
+
const valueDelivered = calculateValueDelivered({
|
|
353
|
+
promptTokens: totalPrompt,
|
|
354
|
+
completionTokens: totalCompletion,
|
|
355
|
+
cacheReadTokens: totalCacheRead,
|
|
356
|
+
})
|
|
329
357
|
return {
|
|
330
358
|
generatedAt: new Date(nowMs).toISOString(),
|
|
331
359
|
totalRequests: entries.length,
|
|
@@ -334,5 +362,6 @@ export function calculateAnalytics(entries = [], accounts = [], nowMs = Date.now
|
|
|
334
362
|
hourly: calculateHourlyBuckets(entries, nowMs),
|
|
335
363
|
accounts: calculateAccountBreakdown(entries, accounts),
|
|
336
364
|
forecast: calculateQuotaForecast(accounts, entries, nowMs),
|
|
365
|
+
valueDelivered,
|
|
337
366
|
}
|
|
338
367
|
}
|