@goodandready/dsh-subscriptions 0.6.72 → 0.6.83

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 (49) hide show
  1. package/README.md +8 -0
  2. package/README.ru.md +8 -0
  3. package/README.zh.md +8 -0
  4. package/lib/accounts-view.js +97 -5
  5. package/lib/accounts.js +137 -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 +22 -0
  11. package/lib/capabilities.js +208 -0
  12. package/lib/client.js +344 -17
  13. package/lib/config-schema.js +50 -4
  14. package/lib/diagnostic-wizard.js +2 -5
  15. package/lib/diagnostics.js +2 -2
  16. package/lib/error-buffer.js +2 -2
  17. package/lib/forecast.js +56 -0
  18. package/lib/headless-auth.js +392 -0
  19. package/lib/history.js +36 -0
  20. package/lib/http.js +67 -4
  21. package/lib/import-auth.js +3 -6
  22. package/lib/index.js +99 -11
  23. package/lib/jwt.js +84 -17
  24. package/lib/loopback.js +3 -2
  25. package/lib/mask.js +16 -0
  26. package/lib/oauth-pending.js +12 -0
  27. package/lib/pricing.js +89 -0
  28. package/lib/refs.js +2 -0
  29. package/lib/reset-clock.js +55 -0
  30. package/lib/rotate.js +18 -4
  31. package/lib/routes/accounts.js +142 -7
  32. package/lib/routes/oauth.js +51 -2
  33. package/lib/routes/proxy.js +14 -0
  34. package/lib/scope-inspector.js +51 -0
  35. package/lib/stream-rotate.js +31 -10
  36. package/lib/subscriptions.js +24 -1
  37. package/lib/updater.js +21 -0
  38. package/lib/vault-sync.js +414 -0
  39. package/lib/vault.js +1 -1
  40. package/lib/vendor-factory.js +6 -2
  41. package/lib/vendors/antigravity.js +13 -4
  42. package/lib/vendors/claude.js +56 -8
  43. package/lib/vendors/codex.js +34 -6
  44. package/lib/vendors/copilot-cli.js +36 -5
  45. package/lib/vendors/ernie.js +9 -2
  46. package/lib/vendors/grok.js +66 -6
  47. package/lib/vendors/jetbrains.js +136 -13
  48. package/lib/vendors/perplexity.js +151 -17
  49. package/package.json +1 -1
package/README.md CHANGED
@@ -305,6 +305,14 @@ dsh-subscriptions:
305
305
 
306
306
  ---
307
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
+ - Antigravity CLI imports must configure `antigravityClientSecret`; the embedded default was removed.
313
+ - The error buffer and diagnostics reports redact bearer tokens, API keys, JWTs and `key=value` credentials before storing or returning them.
314
+
315
+
308
316
  ## 🧠 Claude Adaptive Thinking & Effort (Added in v0.6.7)
309
317
 
310
318
  Supports Anthropic's adaptive thinking (`thinking: { type: "adaptive" }`) and reasoning effort steering (`output_config: { effort }`):
package/README.ru.md CHANGED
@@ -278,6 +278,14 @@ dsh-subscriptions:
278
278
 
279
279
  ---
280
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
+ - Импорт Antigravity CLI требует явного `antigravityClientSecret`; встроенный default удалён.
286
+ - Error buffer и диагностические отчёты редактируют bearer-токены, API-ключи, JWT и `key=value`-credentials до сохранения и выдачи.
287
+
288
+
281
289
  ## 🧠 Адаптивное мышление Claude и уровни усилия (Добавлено в v0.6.7)
282
290
 
283
291
  Поддержка адаптивного мышления Anthropic (`thinking: { type: "adaptive" }`) и управления глубиной рассуждений (`output_config: { effort }`):
package/README.zh.md CHANGED
@@ -184,6 +184,14 @@ dsh plugin --profile web add @goodandready/dsh-subscriptions
184
184
 
185
185
  ---
186
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
+ - Antigravity CLI 导入必须配置 `antigravityClientSecret`;内置默认值已移除。
192
+ - 错误缓冲区和诊断报告会在存储或返回前脱敏 bearer 令牌、API 密钥、JWT 和 `key=value` 凭据。
193
+
194
+
187
195
  ## 🧠 Claude 自适应思考与推理深度控制 (v0.6.7 新增)
188
196
 
189
197
  支持 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,6 +152,9 @@ 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 || '',
@@ -82,6 +165,15 @@ export async function createAccountsView({ live, store, fetch, pmL }) {
82
165
  latencyGrade: info.latencyGrade ?? null,
83
166
  lastDoctorVerdict: info.lastDoctorVerdict || null,
84
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,
85
177
  })
86
178
  }
87
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
@@ -122,15 +124,74 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
122
124
  const doctorVerdicts = new Map()
123
125
  const dynamicModels = new Map()
124
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
+
125
147
  // #88: per-account proxy fetch for token refresh / usage (falls back to doFetch).
126
148
  const rawFetchFor = (ref) => (typeof fetchForRef === 'function' && fetchForRef(ref)) || doFetch
127
- 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
+ }
128
177
 
129
178
  async function resolveRaw(ref) {
130
179
  try {
131
180
  const resolved = await credentials.resolve(credentialRef(ref))
132
- return resolved && resolved.value ? String(resolved.value) : ''
133
- } 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
134
195
  return ''
135
196
  }
136
197
  }
@@ -158,11 +219,16 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
158
219
  return
159
220
  }
160
221
  try {
161
- 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)
162
226
  if (clearedRefs.has(ref)) {
163
227
  try { await credentials.unset(credentialRef(ref)) } catch { /* best-effort */ }
164
228
  } else if (credentialVersions.get(ref) > version && latestBlobs.has(ref)) {
165
- 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 */ }
166
232
  }
167
233
  } catch (err) {
168
234
  if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
@@ -204,7 +270,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
204
270
  windows.delete(ref)
205
271
  }
206
272
 
207
- async function describeRef(ref) {
273
+ async function describeRef(ref, cfg = null) {
208
274
  const base = { ref, configured: false, writable: true, label: '', email: '' }
209
275
  try {
210
276
  if (typeof credentials.describe === 'function') {
@@ -217,16 +283,34 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
217
283
  } catch {
218
284
  return base
219
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
+
220
297
  if (base.configured) {
221
298
  try {
222
299
  const blob = parseBlob(await resolveRaw(ref))
223
300
  base.label = blob.label || blob.email || ''
224
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 || ''
225
306
  if (Array.isArray(blob.usage)) { base.usage = blob.usage; base.usageAt = blob.usageAt || 0 }
226
307
  if (blob.validationUrl) base.validationUrl = blob.validationUrl
227
308
  if (blob.validationMessage) base.validationMessage = blob.validationMessage
228
309
  if (blob.accountNotice) base.accountNotice = blob.accountNotice
229
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 : []
230
314
  } catch { /* ignore parse */ }
231
315
  }
232
316
  return {
@@ -245,6 +329,24 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
245
329
  usagePercent: usage.has(ref) ? usage.get(ref) : null,
246
330
  refreshError: refreshFailures.get(ref)?.error || '',
247
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' : '',
248
350
  validationUrl: base.validationUrl || '',
249
351
  validationMessage: base.validationMessage || '',
250
352
  accountNotice: base.accountNotice || '',
@@ -261,7 +363,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
261
363
  const slots = normalizeSlots(curConfig.slots).filter((s) => s.provider === provider)
262
364
  const out = []
263
365
  for (const slot of slots) {
264
- const info = await describeRef(slot.ref)
366
+ const info = await describeRef(slot.ref, curConfig)
265
367
  const q = quarantines.get(slot.ref)
266
368
  out.push({
267
369
  ref: slot.ref,
@@ -276,6 +378,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
276
378
  quarantineReason: q ? q.reason : null,
277
379
  label: slot.label || info.label,
278
380
  pacePerHour: info.pacePerHour || 0,
381
+ maxConcurrency: slot.maxConcurrency !== undefined ? slot.maxConcurrency : (curConfig.accountMaxConcurrency || 1),
279
382
  })
280
383
  }
281
384
  return out
@@ -286,7 +389,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
286
389
  const slots = normalizeSlots(curConfig.slots)
287
390
  const found = new Set()
288
391
  for (const slot of slots) {
289
- const info = await describeRef(slot.ref)
392
+ const info = await describeRef(slot.ref, curConfig)
290
393
  if (info.configured) found.add(slot.provider)
291
394
  }
292
395
  return [...found]
@@ -296,15 +399,34 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
296
399
  if (!blob || typeof blob !== 'object') return blob
297
400
  if (blob.apiKeyOnly || (blob.apiKey && (!blob.refreshToken || blob.refreshToken === blob.apiKey))) return blob
298
401
  if (!blob.refreshToken) return blob
299
- 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
+
300
423
  const lockKey = ref || (provider + ":" + (blob.accountId || blob.email || (blob.refreshToken && blob.refreshToken.slice(-16)) || "anon"))
301
424
  if (refreshLocks.has(lockKey)) return refreshLocks.get(lockKey)
302
425
  const promise = (async () => {
303
426
  try {
304
- const curConfig = getConfig()
305
427
  const cfg = vendorConfig(provider, curConfig)
306
428
  const next = await executeWithRetry(
307
- () => getVendor(provider).refresh(cfg, blob, fetchFor(ref, 30000)),
429
+ () => getVendor(provider).refresh(cfg, blob, fetchFor(ref, 30000, provider)),
308
430
  {
309
431
  maxRetries: 2,
310
432
  initialDelayMs: 300,
@@ -446,6 +568,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
446
568
  resolveRaw,
447
569
  ensureFresh,
448
570
  refreshUsage,
571
+ getClockSkew,
572
+ recordClockSkew,
449
573
  // #87: family-scoped cooldowns. families=null blocks the whole account
450
574
  // (legacy behavior); a list blocks only those model families.
451
575
  rememberCooldown(ref, until, families) {
@@ -486,6 +610,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
486
610
  quotaSamples.delete(ref)
487
611
  }
488
612
  },
613
+ clearRefreshFailure(ref) { if (ref) refreshFailures.delete(ref) },
614
+ clearCooldown(ref) { if (ref) cooldowns.delete(ref) },
489
615
  shouldSkipRefresh(ref, now, retryMs) { const f = refreshFailures.get(ref); if (!f) return false; return (Number(f.at) + Number(retryMs)) > Number(now) },
490
616
  getQuota(ref) { return quotas.get(ref) || null },
491
617
  rememberUsage(ref, percent) { usage.set(ref, percent) },
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
  }