@goodandready/dsh-subscriptions 0.6.72 → 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 (47) hide show
  1. package/README.md +7 -0
  2. package/README.ru.md +7 -0
  3. package/README.zh.md +7 -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 +18 -0
  11. package/lib/capabilities.js +208 -0
  12. package/lib/client.js +344 -17
  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/error-buffer.js +2 -2
  17. package/lib/forecast.js +56 -0
  18. package/lib/headless-auth.js +379 -0
  19. package/lib/history.js +36 -0
  20. package/lib/http.js +26 -4
  21. package/lib/index.js +98 -11
  22. package/lib/jwt.js +84 -17
  23. package/lib/loopback.js +3 -2
  24. package/lib/mask.js +16 -0
  25. package/lib/oauth-pending.js +12 -0
  26. package/lib/pricing.js +89 -0
  27. package/lib/refs.js +2 -0
  28. package/lib/reset-clock.js +55 -0
  29. package/lib/rotate.js +18 -4
  30. package/lib/routes/accounts.js +131 -0
  31. package/lib/routes/oauth.js +44 -2
  32. package/lib/routes/proxy.js +14 -0
  33. package/lib/scope-inspector.js +51 -0
  34. package/lib/stream-rotate.js +31 -10
  35. package/lib/updater.js +21 -0
  36. package/lib/vault-sync.js +412 -0
  37. package/lib/vault.js +1 -1
  38. package/lib/vendor-factory.js +6 -2
  39. package/lib/vendors/antigravity.js +11 -2
  40. package/lib/vendors/claude.js +56 -8
  41. package/lib/vendors/codex.js +34 -6
  42. package/lib/vendors/copilot-cli.js +8 -1
  43. package/lib/vendors/ernie.js +9 -2
  44. package/lib/vendors/grok.js +66 -6
  45. package/lib/vendors/jetbrains.js +133 -13
  46. package/lib/vendors/perplexity.js +148 -17
  47. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -2,6 +2,7 @@ import { plainConfig } from './config-schema.js'
2
2
  export { plainConfig } from './config-schema.js'
3
3
  import { readFileSync } from 'node:fs'
4
4
  import { bestEffort } from './utils.js'
5
+ import { sweepExpiredPending } from './oauth-pending.js'
5
6
  export { Config, publicConfig, Slot, defaultSlots } from './config-schema.js'
6
7
  import { Config } from './config-schema.js'
7
8
  import { registerRoutes } from './routes.js'
@@ -21,8 +22,10 @@ import { proxyFetch } from './proxy.js'
21
22
  import { HistoryStore, resolveHistoryDir } from './history.js'
22
23
  import { createDiagnosticsReport } from './diagnostics.js'
23
24
  import { createAccountsView } from './accounts-view.js'
25
+ import { inspectScopes } from './scope-inspector.js'
24
26
  import { createAdapterManager } from './adapter-manager.js'
25
- import { pickAccount } from './rotate.js'
27
+ import { pickAccount, isAuthError } from './rotate.js'
28
+ import { notifySessionExpired } from './alerts.js'
26
29
 
27
30
  export { observeForecast, estimateForecast } from './forecast.js'
28
31
  export { formatRelativeReset } from './relative-time.js'
@@ -31,6 +34,9 @@ export { exportVault, importVault } from './vault.js'
31
34
  export { recordAlert, getRecentAlerts, checkQuotaThresholds, notifySessionExpired } from './alerts.js'
32
35
  export { calculateBurnRatePerHour, computePacingRisk } from './forecast.js'
33
36
  export { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure } from './quarantine.js'
37
+ export { encodeQrMatrix, renderAsciiQr, renderSvgQr, renderSvgDataUrl, generateSshTunnelCommand, getHeadlessAuthInfo, handleHeadlessRedirect } from './headless-auth.js'
38
+ export { pushToWebdav, pullFromWebdav, pushToGist, pullFromGist, buildS3SigV4Headers, pushToS3, pullFromS3, syncVaultPush, syncVaultPull, getVaultSyncStatus, resetVaultSyncStatus } from './vault-sync.js'
39
+ import { syncVaultPush, syncVaultPull, getVaultSyncStatus } from './vault-sync.js'
34
40
 
35
41
  export const name = '@goodandready/dsh-subscriptions'
36
42
  export const inject = ['llm', 'credentials', 'webServer', 'settings']
@@ -380,11 +386,15 @@ export function apply(ctx, rawConfig) {
380
386
  const pending = new Map()
381
387
 
382
388
  function sweepPending(now) {
383
- for (const [stateKey, row] of pending) {
384
- if (now - row.createdAt > PENDING_TTL_MS) pending.delete(stateKey)
385
- }
389
+ sweepExpiredPending(pending, now, PENDING_TTL_MS)
386
390
  }
387
391
 
392
+ ctx.effect(() => {
393
+ const id = setInterval(() => sweepPending(Date.now()), 60 * 1000)
394
+ if (id && typeof id.unref === 'function') id.unref()
395
+ return () => clearInterval(id)
396
+ }, 'dsh-subscriptions: pending oauth sweep')
397
+
388
398
  async function completeOAuth({ provider, index, code, state }) {
389
399
  if (!state) throw new Error('missing oauth state')
390
400
  sweepPending(Date.now())
@@ -401,14 +411,26 @@ export function apply(ctx, rawConfig) {
401
411
  challenge: row.challenge,
402
412
  state: row.state,
403
413
  }, code, fetchForRef(ref) || fetch)
414
+ const inspection = inspectScopes(actualProvider, blob.scopes)
415
+ blob.scopes = inspection.scopes
416
+ blob.scopeWarning = inspection.scopeWarning || ""
404
417
  const slots = normalizeSlots(live().slots)
405
418
  const slot = slots.find((s) => s.ref === ref)
406
419
  if (slot && slot.label) blob.label = slot.label
407
- await store.saveBlob(ref, blob)
420
+ let existing = {}
421
+ try { existing = await store.loadBlob(ref) } catch { /* no existing blob on fresh auth */ }
422
+ const merged = { ...existing, ...blob }
423
+ await store.saveBlob(ref, merged)
424
+ store.recordSuccess(ref)
425
+ store.clearRefreshFailure(ref)
426
+ store.clearCooldown(ref)
408
427
  pending.delete(row.state)
409
428
  await syncAdapter()
410
429
  refreshModels().catch(() => {})
411
- return { ref, label: pmL(blob.label || blob.email) || displayName(actualProvider) }
430
+ if (live().vaultSyncEnabled && live().vaultSyncAutoPush) {
431
+ syncVaultPush({ live: live(), store }).catch(() => {})
432
+ }
433
+ return { ref, label: pmL(merged.label || merged.email) || displayName(actualProvider) }
412
434
  }
413
435
 
414
436
  // Privacy masking
@@ -449,7 +471,7 @@ export function apply(ctx, rawConfig) {
449
471
  }
450
472
 
451
473
  const diagnosticsReport = () => createDiagnosticsReport({ live, store, history, NS, pkgVersion, heartbeatStats })
452
- const accountsView = () => createAccountsView({ live, store, fetch, pmL })
474
+ const accountsView = () => createAccountsView({ live, store, fetch, pmL, pmE, history })
453
475
 
454
476
  // Ponytail: background refresh ahead of expiry, single timer
455
477
  ctx.effect(() => {
@@ -473,15 +495,51 @@ export function apply(ctx, rawConfig) {
473
495
  if (!raw) continue
474
496
  const blob = await store.loadBlob(ref).catch(() => null)
475
497
  if (!blob || !blob.refreshToken) continue
476
- const fresh = await store.ensureFresh(slot.provider, blob, ref).catch(() => null)
498
+ let fresh
499
+ try {
500
+ fresh = await store.ensureFresh(slot.provider, blob, ref)
501
+ } catch (refreshErr) {
502
+ if (isAuthError(refreshErr)) {
503
+ store.recordBroken(ref)
504
+ store.rememberQuarantine(ref, "SESSION_REVOKED", Date.now() + 86400000, 1)
505
+ try {
506
+ notifySessionExpired({
507
+ ref,
508
+ provider: slot.provider,
509
+ message: refreshErr && refreshErr.message,
510
+ webhookUrl: cfg.webhookUrl,
511
+ fetchImpl: fetch,
512
+ nowMs: Date.now(),
513
+ })
514
+ } catch { /* alert best-effort */ }
515
+ }
516
+ continue
517
+ }
477
518
  if (!fresh) continue
478
519
  const vendor = getVendor(slot.provider)
479
520
  if (typeof vendor.check !== 'function') continue
480
521
  const cfg2 = vendorConfig(slot.provider, live())
481
522
  const probeFetch = async (u, i) => ((typeof fetchForRef === 'function' && fetchForRef(ref)) || fetch)(u, i)
482
- await vendor.check(fresh, cfg2, probeFetch).catch((e) => {
483
- if (logger && logger.warn) logger.warn('[dsh-subscriptions] probe ' + ref + ': ' + String(e && e.message || e).slice(0, 200))
484
- })
523
+ try {
524
+ await vendor.check(fresh, cfg2, probeFetch)
525
+ if (typeof store.rememberDoctorVerdict === 'function') store.rememberDoctorVerdict(ref, null)
526
+ } catch (checkErr) {
527
+ if (isAuthError(checkErr)) {
528
+ store.recordBroken(ref)
529
+ store.rememberQuarantine(ref, "SESSION_REVOKED", Date.now() + 86400000, 1)
530
+ try {
531
+ notifySessionExpired({
532
+ ref,
533
+ provider: slot.provider,
534
+ message: checkErr && checkErr.message,
535
+ webhookUrl: cfg.webhookUrl,
536
+ fetchImpl: fetch,
537
+ nowMs: Date.now(),
538
+ })
539
+ } catch { /* alert best-effort */ }
540
+ }
541
+ if (logger && logger.warn) logger.warn('[dsh-subscriptions] probe ' + ref + ': ' + String(checkErr && checkErr.message || checkErr).slice(0, 200))
542
+ }
485
543
  } catch { /* best-effort */ }
486
544
  }
487
545
  }
@@ -499,6 +557,35 @@ export function apply(ctx, rawConfig) {
499
557
  return () => clearInterval(timer)
500
558
  }, 'dsh-subscriptions: probe loop')
501
559
 
560
+ // #562: Background automated remote vault sync loop
561
+ ctx.effect(() => {
562
+ const checkSync = async () => {
563
+ const cfg = live()
564
+ if (!cfg.vaultSyncEnabled || !cfg.vaultSyncIntervalHours || cfg.vaultSyncIntervalHours <= 0) return
565
+ const status = getVaultSyncStatus()
566
+ const now = Date.now()
567
+ const intervalMs = cfg.vaultSyncIntervalHours * 3600 * 1000
568
+ const lastPull = status.lastPullAt ? new Date(status.lastPullAt).getTime() : 0
569
+ if (now - lastPull >= intervalMs) {
570
+ try {
571
+ await syncVaultPull({
572
+ live: cfg,
573
+ store,
574
+ settingsApi: getSettingsApi(),
575
+ syncAdapter,
576
+ refreshModels,
577
+ })
578
+ } catch { /* status captures error */ }
579
+ }
580
+ }
581
+ const intervalId = setInterval(() => { checkSync().catch(() => {}) }, 60 * 60 * 1000)
582
+ const initialTimer = setTimeout(() => { checkSync().catch(() => {}) }, 30 * 1000)
583
+ return () => {
584
+ clearInterval(intervalId)
585
+ clearTimeout(initialTimer)
586
+ }
587
+ }, 'dsh-subscriptions: vault-sync-loop')
588
+
502
589
  registerRoutes(ctx, {
503
590
  NS,
504
591
  live,
package/lib/jwt.js CHANGED
@@ -1,9 +1,9 @@
1
- function decodeJwtPayload(token) {
2
- const raw = String(token || '')
3
- const parts = raw.split('.')
1
+ export function decodeJwtPayload(token) {
2
+ const raw = String(token || "")
3
+ const parts = raw.split(".")
4
4
  if (parts.length < 2) return null
5
5
  try {
6
- const json = Buffer.from(parts[1], 'base64url').toString('utf8')
6
+ const json = Buffer.from(parts[1], "base64url").toString("utf8")
7
7
  return JSON.parse(json)
8
8
  } catch {
9
9
  return null
@@ -12,42 +12,84 @@ function decodeJwtPayload(token) {
12
12
 
13
13
  export function chatgptAccountId(token) {
14
14
  const payload = decodeJwtPayload(token)
15
- if (!payload || typeof payload !== 'object') return ''
15
+ if (!payload || typeof payload !== "object") return ""
16
16
  if (payload.chatgpt_account_id) return String(payload.chatgpt_account_id)
17
- const nested = payload['https://api.openai.com/auth']
17
+ const nested = payload["https://api.openai.com/auth"]
18
18
  if (nested && nested.chatgpt_account_id) return String(nested.chatgpt_account_id)
19
+ if (nested && nested.user_id) return String(nested.user_id)
19
20
  const orgs = payload.organizations
20
21
  if (Array.isArray(orgs) && orgs[0] && orgs[0].id) return String(orgs[0].id)
21
- return ''
22
+ if (payload.sub) return String(payload.sub)
23
+ return ""
22
24
  }
23
25
 
24
26
  export function emailFromToken(token) {
25
27
  const payload = decodeJwtPayload(token)
26
- if (!payload) return ''
27
- return String(payload.email || payload.preferred_username || '')
28
+ if (!payload || typeof payload !== "object") return ""
29
+ if (payload.email) return String(payload.email)
30
+ const openaiProf = payload["https://api.openai.com/profile"]
31
+ if (openaiProf && openaiProf.email) return String(openaiProf.email)
32
+ if (payload.preferred_username) return String(payload.preferred_username)
33
+ if (payload.upn) return String(payload.upn)
34
+ return ""
35
+ }
36
+
37
+ export function planFromToken(token) {
38
+ const payload = decodeJwtPayload(token)
39
+ if (!payload || typeof payload !== "object") return ""
40
+ const nested = payload["https://api.openai.com/auth"]
41
+ if (nested && nested.chatgpt_plan_type) return String(nested.chatgpt_plan_type)
42
+ const anthropic = payload["https://api.anthropic.com"]
43
+ if (anthropic && anthropic.plan) return String(anthropic.plan)
44
+ if (payload.plan_type) return String(payload.plan_type)
45
+ if (payload.plan) return typeof payload.plan === "string" ? payload.plan : (payload.plan.name || "")
46
+ if (payload.subscription && payload.subscription.tier) return String(payload.subscription.tier)
47
+ if (payload.subscription_tier) return String(payload.subscription_tier)
48
+ if (payload.paidTierName) return String(payload.paidTierName)
49
+ return ""
50
+ }
51
+
52
+ export function avatarFromToken(token) {
53
+ const payload = decodeJwtPayload(token)
54
+ if (!payload || typeof payload !== "object") return ""
55
+ if (payload.picture && typeof payload.picture === "string") return payload.picture
56
+ if (payload.avatar_url && typeof payload.avatar_url === "string") return payload.avatar_url
57
+ if (payload.avatar && typeof payload.avatar === "string") return payload.avatar
58
+ const openaiProf = payload["https://api.openai.com/profile"]
59
+ if (openaiProf && openaiProf.picture && typeof openaiProf.picture === "string") return openaiProf.picture
60
+ return ""
61
+ }
62
+
63
+ export function scopesFromToken(token) {
64
+ const payload = decodeJwtPayload(token)
65
+ if (!payload || typeof payload !== "object") return []
66
+ const raw = payload.scope || payload.scp || ""
67
+ if (Array.isArray(raw)) return raw.map(String).filter(Boolean)
68
+ if (typeof raw === "string") return raw.split(/[\s,]+/).filter(Boolean)
69
+ return []
28
70
  }
29
71
 
30
72
  export function isGoogleClientId(str) {
31
- if (typeof str !== 'string') return false
73
+ if (typeof str !== "string") return false
32
74
  const trimmed = str.trim()
33
75
  return /^[0-9]+-[a-zA-Z0-9_-]+\.apps\.googleusercontent\.com$/.test(trimmed)
34
76
  }
35
77
 
36
78
  export function extractGoogleClientIdFromJwt(token) {
37
79
  const payload = decodeJwtPayload(token)
38
- if (!payload || typeof payload !== 'object') return ''
80
+ if (!payload || typeof payload !== "object") return ""
39
81
 
40
- const azp = typeof payload.azp === 'string' ? payload.azp.trim() : ''
82
+ const azp = typeof payload.azp === "string" ? payload.azp.trim() : ""
41
83
  const rawAud = payload.aud
42
84
 
43
85
  const audiences = Array.isArray(rawAud)
44
- ? rawAud.filter(a => typeof a === 'string').map(a => a.trim())
45
- : (typeof rawAud === 'string' && rawAud.trim() ? [rawAud.trim()] : [])
86
+ ? rawAud.filter(a => typeof a === "string").map(a => a.trim())
87
+ : (typeof rawAud === "string" && rawAud.trim() ? [rawAud.trim()] : [])
46
88
 
47
89
  if (azp) {
48
- if (!isGoogleClientId(azp)) return ''
90
+ if (!isGoogleClientId(azp)) return ""
49
91
  if (audiences.length > 0 && !audiences.includes(azp)) {
50
- return ''
92
+ return ""
51
93
  }
52
94
  return azp
53
95
  }
@@ -57,5 +99,30 @@ export function extractGoogleClientIdFromJwt(token) {
57
99
  return validAuds[0]
58
100
  }
59
101
 
60
- return ''
102
+ return ""
103
+ }
104
+
105
+ export function organizationsFromToken(token) {
106
+ const payload = decodeJwtPayload(token)
107
+ if (!payload || typeof payload !== "object") return []
108
+ const raw = payload.organizations ||
109
+ (payload["https://api.openai.com/auth"] && payload["https://api.openai.com/auth"].organizations) ||
110
+ payload.orgs ||
111
+ (payload["https://api.anthropic.com/auth"] && payload["https://api.anthropic.com/auth"].organizations) ||
112
+ []
113
+ if (!Array.isArray(raw)) return []
114
+ const out = []
115
+ for (const item of raw) {
116
+ if (typeof item === "string" && item.trim()) {
117
+ out.push({ id: item.trim(), name: item.trim() })
118
+ } else if (item && typeof item === "object") {
119
+ const id = String(item.id || item.org_id || item.uuid || "").trim()
120
+ const name = String(item.title || item.name || item.org_name || id).trim()
121
+ const role = item.role ? String(item.role).trim() : undefined
122
+ if (id) {
123
+ out.push({ id, name: name || id, ...(role ? { role } : {}) })
124
+ }
125
+ }
126
+ }
127
+ return out
61
128
  }
package/lib/loopback.js CHANGED
@@ -30,8 +30,9 @@ export function startLoopback({ redirectUri, timeoutMs = 10 * 60 * 1000, onCode
30
30
  const promise = new Promise((resolve, reject) => {
31
31
  const server = http.createServer((req, res) => {
32
32
  const url = new URL(req.url || '/', `http://127.0.0.1:${port}`)
33
- // The provider may redirect to a suffixed path (e.g. /auth/callback/extra)
34
- if (!url.pathname.startsWith(path)) {
33
+ // The provider may redirect to a suffixed path (e.g. /auth/callback/extra) (#545)
34
+ const matchesPath = url.pathname === path || (path.endsWith("/") ? url.pathname.startsWith(path) : url.pathname.startsWith(path + "/"));
35
+ if (!matchesPath) {
35
36
  res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
36
37
  res.end(ERR_HTML)
37
38
  return
package/lib/mask.js CHANGED
@@ -20,6 +20,22 @@ export function maskText(value) {
20
20
  return String(value || '').replace(EMAIL_RE, (m) => maskEmail(m))
21
21
  }
22
22
 
23
+ const SECRET_PATTERNS = [
24
+ [/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED]'],
25
+ [/\b(?:sk|ghp|gho|ghu|ghs|ghr|github_pat|xox[baprs])[-_][A-Za-z0-9._-]{8,}/g, '[REDACTED]'],
26
+ [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[REDACTED]'],
27
+ [/\b(access_token|refresh_token|token|key|secret|password)=([^&\s]+)/gi, '$1=[REDACTED]'],
28
+ ]
29
+
30
+ // #578: redact credentials as well as emails from free-text diagnostics.
31
+ export function maskSecrets(value) {
32
+ let text = maskText(value)
33
+ for (const [pattern, replacement] of SECRET_PATTERNS) {
34
+ text = text.replace(pattern, replacement)
35
+ }
36
+ return text
37
+ }
38
+
23
39
  export function cleanErrorMessage(raw) {
24
40
  if (!raw) return ''
25
41
  let text = typeof raw === 'string' ? raw : (raw.message || String(raw))
@@ -0,0 +1,12 @@
1
+ // #580: shared expiry sweep for in-memory OAuth pending rows.
2
+ export function sweepExpiredPending(pending, nowMs, ttlMs) {
3
+ let removed = 0
4
+ for (const [stateKey, row] of pending) {
5
+ const createdAt = Number(row && row.createdAt)
6
+ if (!Number.isFinite(createdAt) || Number(nowMs) - createdAt > Number(ttlMs)) {
7
+ pending.delete(stateKey)
8
+ removed += 1
9
+ }
10
+ }
11
+ return removed
12
+ }
package/lib/pricing.js ADDED
@@ -0,0 +1,89 @@
1
+ // Subscription Value Delivered & API Cost Equivalent Counter (#561)
2
+ // Translates raw prompt, completion and cache tokens into equivalent official API pricing,
3
+ // comparing delivered value against monthly subscription fee ($20/mo standard).
4
+
5
+ export const DEFAULT_SUBSCRIPTION_MONTHLY_COST = 20.0
6
+
7
+ export const TOKEN_RATES = {
8
+ 'claude-3-7-sonnet': { prompt: 3.0, completion: 15.0, cacheRead: 0.3 },
9
+ 'claude-3-5-sonnet': { prompt: 3.0, completion: 15.0, cacheRead: 0.3 },
10
+ 'claude-3-5-haiku': { prompt: 0.8, completion: 4.0, cacheRead: 0.08 },
11
+ 'claude-3-opus': { prompt: 15.0, completion: 75.0, cacheRead: 1.5 },
12
+ 'gpt-4o': { prompt: 2.5, completion: 10.0, cacheRead: 1.25 },
13
+ 'gpt-4o-mini': { prompt: 0.15, completion: 0.6, cacheRead: 0.075 },
14
+ 'o1': { prompt: 15.0, completion: 60.0, cacheRead: 7.5 },
15
+ 'o3-mini': { prompt: 1.1, completion: 4.4, cacheRead: 0.55 },
16
+ 'grok-2': { prompt: 2.0, completion: 10.0, cacheRead: 0.2 },
17
+ 'sonar-reasoning': { prompt: 5.0, completion: 25.0, cacheRead: 0.5 },
18
+ 'sonar-pro': { prompt: 3.0, completion: 15.0, cacheRead: 0.3 },
19
+ 'sonar': { prompt: 1.0, completion: 5.0, cacheRead: 0.1 },
20
+ 'gemini-1.5-pro': { prompt: 1.25, completion: 5.0, cacheRead: 0.3 },
21
+ 'gemini-2.0-flash': { prompt: 0.1, completion: 0.4, cacheRead: 0.025 },
22
+ 'default': { prompt: 2.5, completion: 10.0, cacheRead: 0.5 },
23
+ }
24
+
25
+ /**
26
+ * Resolves standard token rates per 1M tokens for a given model identifier.
27
+ * @param {string} [modelId]
28
+ * @returns {{ prompt: number, completion: number, cacheRead: number }}
29
+ */
30
+ export function lookupTokenRate(modelId = '') {
31
+ const m = String(modelId || '').toLowerCase()
32
+ for (const [key, rates] of Object.entries(TOKEN_RATES)) {
33
+ if (key !== 'default' && m.includes(key)) {
34
+ return rates
35
+ }
36
+ }
37
+ return TOKEN_RATES.default
38
+ }
39
+
40
+ /**
41
+ * Calculates dollar cost equivalent for the given token quantities.
42
+ * @param {number} promptTokens
43
+ * @param {number} completionTokens
44
+ * @param {number} [cacheReadTokens]
45
+ * @param {string} [modelId]
46
+ * @returns {number}
47
+ */
48
+ export function calculateEquivalentCost(promptTokens = 0, completionTokens = 0, cacheReadTokens = 0, modelId = 'default') {
49
+ const rates = lookupTokenRate(modelId)
50
+ const p = (Number(promptTokens) || 0) * (rates.prompt / 1_000_000)
51
+ const c = (Number(completionTokens) || 0) * (rates.completion / 1_000_000)
52
+ const cr = (Number(cacheReadTokens) || 0) * (rates.cacheRead / 1_000_000)
53
+ return Math.round((p + c + cr) * 100) / 100
54
+ }
55
+
56
+ /**
57
+ * Computes value delivered metrics and ROI ratio for an account.
58
+ * @param {object} params
59
+ * @param {number} [params.promptTokens]
60
+ * @param {number} [params.completionTokens]
61
+ * @param {number} [params.cacheReadTokens]
62
+ * @param {string} [params.modelId]
63
+ * @param {number} [params.subscriptionCost]
64
+ * @returns {object}
65
+ */
66
+ export function calculateValueDelivered({
67
+ promptTokens = 0,
68
+ completionTokens = 0,
69
+ cacheReadTokens = 0,
70
+ modelId = 'default',
71
+ subscriptionCost = DEFAULT_SUBSCRIPTION_MONTHLY_COST,
72
+ } = {}) {
73
+ const equivalentCost = calculateEquivalentCost(promptTokens, completionTokens, cacheReadTokens, modelId)
74
+ const subCost = Number(subscriptionCost) > 0 ? Number(subscriptionCost) : DEFAULT_SUBSCRIPTION_MONTHLY_COST
75
+ const roiMultiplier = Math.round((equivalentCost / subCost) * 10) / 10
76
+ const roiLabel = `${roiMultiplier}x`
77
+
78
+ return {
79
+ equivalentCost,
80
+ formattedCost: `$${equivalentCost.toFixed(2)}`,
81
+ subscriptionCost: subCost,
82
+ formattedSubscriptionCost: `$${subCost.toFixed(2)}`,
83
+ roiMultiplier,
84
+ roiLabel,
85
+ promptTokens: Number(promptTokens) || 0,
86
+ completionTokens: Number(completionTokens) || 0,
87
+ cacheReadTokens: Number(cacheReadTokens) || 0,
88
+ }
89
+ }
package/lib/refs.js CHANGED
@@ -56,6 +56,8 @@ const DISPLAY = {
56
56
  cursor: 'Cursor',
57
57
  kiro: 'AWS Kiro',
58
58
  copilot: 'GitHub Copilot',
59
+ perplexity: 'Perplexity Pro',
60
+ jetbrains: 'JetBrains AI',
59
61
  }
60
62
 
61
63
  export function displayName(provider) {
@@ -1,9 +1,12 @@
1
1
  import { parseHeaders } from './ratelimit.js'
2
+ import { formatRelativeReset } from './relative-time.js'
2
3
 
3
4
  export const VENDOR_WINDOW_MS = {
4
5
  claude: 5 * 3600 * 1000,
5
6
  codex: 3 * 3600 * 1000,
6
7
  grok: 2 * 3600 * 1000,
8
+ copilot: 24 * 3600 * 1000,
9
+ antigravity: 24 * 3600 * 1000,
7
10
  }
8
11
 
9
12
  export function parseResetFromText(message, nowMs = Date.now()) {
@@ -85,3 +88,55 @@ export function resolveAccurateReset(err, account, provider, nowMs = Date.now(),
85
88
 
86
89
  return null
87
90
  }
91
+
92
+ export function formatRollingCountdown(resetAt, lang = 'en', now = Date.now()) {
93
+ const ts = Number(resetAt)
94
+ if (!Number.isFinite(ts) || ts <= 0) return ''
95
+ const d = new Date(ts)
96
+ const pad = (n) => String(n).padStart(2, '0')
97
+ const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}`
98
+ const relStr = formatRelativeReset(ts, lang, now)
99
+ if (lang === 'zh') {
100
+ return `额度重置于 ${timeStr} (${relStr})`
101
+ }
102
+ return `Window resets at ${timeStr} (${relStr})`
103
+ }
104
+
105
+ export function computeEffectiveResetAt(account, provider, now = Date.now()) {
106
+ if (!account) return null
107
+ const nowMs = Number(now) || Date.now()
108
+ const candidates = []
109
+
110
+ if (account.quota && account.quota.resetAt) {
111
+ const r = Number(account.quota.resetAt)
112
+ if (Number.isFinite(r) && r > nowMs) candidates.push(r)
113
+ }
114
+ if (account.cooldownUntil) {
115
+ const c = Number(account.cooldownUntil)
116
+ if (Number.isFinite(c) && c > nowMs) candidates.push(c)
117
+ }
118
+ if (account.quarantineUntil) {
119
+ const q = Number(account.quarantineUntil)
120
+ if (Number.isFinite(q) && q > nowMs) candidates.push(q)
121
+ }
122
+ if (Array.isArray(account.usage)) {
123
+ for (const w of account.usage) {
124
+ if (w && w.resetAt) {
125
+ const wr = Number(w.resetAt)
126
+ if (Number.isFinite(wr) && wr > nowMs) candidates.push(wr)
127
+ }
128
+ }
129
+ }
130
+
131
+ if (candidates.length > 0) {
132
+ return Math.min(...candidates)
133
+ }
134
+
135
+ const p = String(provider || (account && account.provider) || '').toLowerCase()
136
+ const windowDefault = VENDOR_WINDOW_MS[p]
137
+ if (windowDefault && account.usagePercent != null && Number(account.usagePercent) >= 100) {
138
+ return nowMs + windowDefault
139
+ }
140
+
141
+ return null
142
+ }
package/lib/rotate.js CHANGED
@@ -17,14 +17,28 @@ export function isRegionError(err) {
17
17
  return status === 400 && (/FAILED_PRECONDITION/i.test(msg) || /User location is not supported/i.test(msg))
18
18
  }
19
19
 
20
+ export function isAuthError(err) {
21
+ if (!err || typeof err !== "object") return false
22
+ const status = Number(err.status || err.statusCode) || 0
23
+ if (status === 401) return true
24
+ const code = String(err.code || (err.failure && err.failure.code) || "").toUpperCase()
25
+ if (code === "AUTH" || code === "TOKEN_REVOKED" || code === "UNAUTHORIZED" || code === "INVALID_TOKEN" || code === "SESSION_EXPIRED") return true
26
+ const msg = String(err.message || "")
27
+ if (/invalid_grant/i.test(msg)) return true
28
+ if (/token (?:has been )?revoked/i.test(msg)) return true
29
+ if (/session (?:expired|revoked|invalid)/i.test(msg)) return true
30
+ if (/unauthorized/i.test(msg) && (status === 0 || status === 400 || status === 401)) return true
31
+ return false
32
+ }
33
+
20
34
  export function isSwitchableError(err) {
21
- if (!err || typeof err !== 'object') return false
22
- if (err.name === 'TimeoutError' || err.code === 'TIMEOUT' || err.code === 'EMPTY_RESPONSE' || err.code === 'SOFT_FAILURE' || err.isSoftFailure) return true
23
- if (err.name === 'AbortError' || err.code === 'ABORT_ERR' || err.code === 'ERR_ABORTED' || (err.message && /aborted/i.test(err.message))) return false
35
+ if (!err || typeof err !== "object") return false
36
+ if (err.name === "TimeoutError" || err.code === "TIMEOUT" || err.code === "EMPTY_RESPONSE" || err.code === "SOFT_FAILURE" || err.isSoftFailure) return true
37
+ if (err.name === "AbortError" || err.code === "ABORT_ERR" || err.code === "ERR_ABORTED" || (err.message && /aborted/i.test(err.message))) return false
24
38
  const code = err.code || (err.failure && err.failure.code)
25
39
  if (SWITCH_CODES.has(code)) return true
26
40
  if (isRegionError(err)) return true
27
- const status = err.status || err.statusCode
41
+ const status = Number(err.status || err.statusCode) || 0
28
42
  return status === 401 || status === 403 || status === 429 || status === 500 || status === 502 || status === 503
29
43
  }
30
44