@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.
Files changed (50) hide show
  1. package/README.md +13 -0
  2. package/README.ru.md +13 -0
  3. package/README.zh.md +13 -0
  4. package/lib/accounts-view.js +101 -5
  5. package/lib/accounts.js +150 -11
  6. package/lib/adapter.js +3 -0
  7. package/lib/alerts.js +35 -0
  8. package/lib/analytics.js +31 -2
  9. package/lib/billing.js +99 -0
  10. package/lib/blob.js +18 -0
  11. package/lib/capabilities.js +208 -0
  12. package/lib/client.js +944 -828
  13. package/lib/config-schema.js +46 -4
  14. package/lib/diagnostic-wizard.js +2 -5
  15. package/lib/diagnostics.js +2 -2
  16. package/lib/doctor.js +163 -0
  17. package/lib/error-buffer.js +2 -2
  18. package/lib/forecast.js +56 -0
  19. package/lib/headless-auth.js +379 -0
  20. package/lib/history.js +36 -0
  21. package/lib/http.js +26 -4
  22. package/lib/index.js +98 -11
  23. package/lib/instructions.js +138 -0
  24. package/lib/jwt.js +84 -17
  25. package/lib/loopback.js +3 -2
  26. package/lib/mask.js +16 -0
  27. package/lib/oauth-pending.js +12 -0
  28. package/lib/pricing.js +89 -0
  29. package/lib/refs.js +2 -0
  30. package/lib/reset-clock.js +55 -0
  31. package/lib/rotate.js +18 -4
  32. package/lib/routes/accounts.js +203 -0
  33. package/lib/routes/oauth.js +44 -2
  34. package/lib/routes/proxy.js +14 -0
  35. package/lib/routes/status.js +99 -5
  36. package/lib/scope-inspector.js +51 -0
  37. package/lib/stream-rotate.js +31 -10
  38. package/lib/updater.js +21 -0
  39. package/lib/vault-sync.js +412 -0
  40. package/lib/vault.js +1 -1
  41. package/lib/vendor-factory.js +6 -2
  42. package/lib/vendors/antigravity.js +11 -2
  43. package/lib/vendors/claude.js +56 -8
  44. package/lib/vendors/codex.js +34 -6
  45. package/lib/vendors/copilot-cli.js +8 -1
  46. package/lib/vendors/ernie.js +9 -2
  47. package/lib/vendors/grok.js +66 -6
  48. package/lib/vendors/jetbrains.js +133 -13
  49. package/lib/vendors/perplexity.js +148 -17
  50. 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 }`):
@@ -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 { ...info, accountNotice: next.accountNotice, paidTierName: next.paidTierName || '' }
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 { ...info, paidTierName: next.paidTierName || info.paidTierName || '' }
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 info = await enrichAntigravityAccount(slot, await store.describeRef(slot.ref), store, fetch)
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
- label: pmL(slot.label || info.label),
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) => (url, init, opts) => fetchWithTimeout(rawFetchFor(ref), url, init, { timeoutMs: defaultTimeoutMs, ...(opts || {}) })
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
- return resolved && resolved.value ? String(resolved.value) : ''
130
- } catch {
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
- await credentials.set(credentialRef(ref), serializeBlob(blob))
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
- try { await credentials.set(credentialRef(ref), serializeBlob(latestBlobs.get(ref))) } catch { /* best-effort */ }
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
- if (blob.expiresAt && blob.expiresAt - SKEW_MS > Date.now()) return blob
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: quota && typeof quota.percentage === 'number' ? quota.percentage : null,
315
- resetAt: quota && typeof quota.resetAt === 'number' ? quota.resetAt : null,
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
  }