@goodandready/dsh-subscriptions 0.6.26 → 0.6.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/adapter.js CHANGED
@@ -118,6 +118,9 @@ export class SubscriptionAdapter extends LlmAdapter {
118
118
  },
119
119
  streamOnce: async function* (account, opts) {
120
120
  const t0 = Date.now()
121
+ let firstChunkTime = null
122
+ let outputTokens = 0
123
+ let approxChars = 0
121
124
  const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
122
125
  const vendor = getVendor(provider)
123
126
  // ponytail: capture x-ratelimit headers without touching vendors
@@ -145,7 +148,7 @@ export class SubscriptionAdapter extends LlmAdapter {
145
148
  }
146
149
  try {
147
150
  if (typeof deps.rememberRequest === 'function') deps.rememberRequest(account.ref)
148
- yield* vendor.streamOnce({
151
+ for await (const chunk of vendor.streamOnce({
149
152
  blob,
150
153
  options: opts,
151
154
  fetchImpl: quotaFetch,
@@ -153,12 +156,37 @@ export class SubscriptionAdapter extends LlmAdapter {
153
156
  config: deps.vendorConfig(provider),
154
157
  signal: opts.signal,
155
158
  saveBlob: (next) => deps.saveBlob(account.ref, next),
156
- })
159
+ })) {
160
+ if (firstChunkTime === null) {
161
+ firstChunkTime = Date.now()
162
+ }
163
+ if (chunk && typeof chunk === 'object') {
164
+ if (chunk.type === 'usage' && chunk.usage) {
165
+ const u = toTokenUsage(chunk.usage)
166
+ if (u && typeof u.outputTokens === 'number' && u.outputTokens > 0) {
167
+ outputTokens = u.outputTokens
168
+ }
169
+ } else if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
170
+ approxChars += chunk.text.length
171
+ }
172
+ }
173
+ yield chunk
174
+ }
175
+ if (!outputTokens && approxChars > 0) {
176
+ outputTokens = Math.max(1, Math.round(approxChars / 4))
177
+ }
157
178
  // stream completed successfully: clear cooldown and health penalties
158
179
  if (typeof deps.recordSuccess === 'function') deps.recordSuccess(account.ref)
159
180
  // #65: record in the request and cost history.
160
181
  if (typeof deps.recordHistory === 'function') {
161
182
  try {
183
+ const totalMs = Date.now() - t0
184
+ const ttftMs = firstChunkTime !== null ? Math.max(0, firstChunkTime - t0) : null
185
+ const genMs = (firstChunkTime !== null && totalMs > ttftMs) ? (totalMs - ttftMs) : 0
186
+ const tps = (outputTokens > 0 && genMs > 0)
187
+ ? Math.round((outputTokens / (genMs / 1000)) * 10) / 10
188
+ : null
189
+
162
190
  deps.recordHistory({
163
191
  provider,
164
192
  ref: account.ref,
@@ -166,7 +194,10 @@ export class SubscriptionAdapter extends LlmAdapter {
166
194
  path: '/responses',
167
195
  method: 'POST',
168
196
  status: 200,
169
- ms: Date.now() - t0,
197
+ ms: totalMs,
198
+ ttftMs,
199
+ tps,
200
+ outputTokens: outputTokens || undefined,
170
201
  kind: 'stream',
171
202
  })
172
203
  } catch { /* ignore quota capture failure */ }
package/lib/client.js CHANGED
@@ -149,6 +149,7 @@ window.__ModuleLoader__.load({
149
149
  '.dsub-slot-meta-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2);font-size:12px}' +
150
150
  '.dsub-ref-tag{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--dsw-alias-label-secondary)}' +
151
151
  '.dsub-ref-tag code{padding:2px 6px;border-radius:4px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);font-family:ui-monospace,monospace;color:var(--dsw-alias-label-primary);font-size:11px}' +
152
+ '.dsub-speed-tag{display:inline-flex;align-items:center;gap:4px;font-size:11.5px;color:var(--dsw-alias-brand-default,#3b82f6);background:color-mix(in srgb,var(--dsw-alias-brand-default,#3b82f6) 8%,transparent);padding:2px 8px;border-radius:6px;font-weight:500;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-default,#3b82f6) 20%,transparent)}' +
152
153
  '.dsub-btn-danger{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent)!important;color:var(--dsw-alias-state-error-primary)!important}' +
153
154
  '.dsub-btn-danger:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)!important}' +
154
155
  '.dsub-poolTitle{margin-top:16px;margin-bottom:8px;font-size:11.5px;font-weight:700;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.05em}' +
@@ -321,6 +322,22 @@ const cssId = 'dsh-subscriptions/settings.module.css'
321
322
  ],
322
323
  link: "https://aws.amazon.com",
323
324
  },
325
+ copilot: {
326
+ title: "GitHub Copilot",
327
+ stepsEn: [
328
+ "1. Click \"Device Login\" (or \"Connect\") on the slot card.",
329
+ "2. A one-time code will appear on the card.",
330
+ "3. Open https://github.com/login/device, enter the code, and approve access for your GitHub account with Copilot subscription.",
331
+ "4. The plugin automatically captures tokens and enables Copilot models.",
332
+ ],
333
+ stepsZh: [
334
+ "1. 点击卡片上的 \"设备码登录\" (Device Login) 或 \"连接\"。",
335
+ "2. 界面将展示一次性设备授权码。",
336
+ "3. 在浏览器打开 https://github.com/login/device 输入代码,并为您拥有 Copilot 订阅的 GitHub 账户授权。",
337
+ "4. 授权成功后,插件会自动捕获会话并激活 GitHub Copilot 官方模型。",
338
+ ],
339
+ link: "https://github.com/login/device",
340
+ },
324
341
  }
325
342
 
326
343
  const en = {
@@ -503,7 +520,21 @@ const cssId = 'dsh-subscriptions/settings.module.css'
503
520
  "healthQuarantine": "Quarantine",
504
521
  "healthWarmup": "Warmup",
505
522
  "healthActive": "Active",
506
- "healthIdle": "Idle"
523
+ "healthIdle": "Idle",
524
+ "speedMetricsHint": "Time to first token (TTFT) and token generation speed",
525
+ "backupExport": "Export Backup",
526
+ "backupImport": "Import Backup",
527
+ "backupExportTitle": "Export Encrypted Backup",
528
+ "backupImportTitle": "Import Encrypted Backup",
529
+ "backupExportAction": "Export",
530
+ "backupImportAction": "Import",
531
+ "backupPassphrase": "Vault Passphrase (min 4 chars)",
532
+ "backupExportHint": "Encrypt connected subscription accounts and slots with AES-256-GCM and PBKDF2.",
533
+ "backupImportHint": "Restore encrypted subscription accounts and credentials from a vault payload.",
534
+ "backupPayloadPlaceholder": "Paste DSHE... encrypted string here",
535
+ "backupExportOk": "Backup exported successfully",
536
+ "backupImportOk": "Backup imported successfully",
537
+ "backupDownload": "Download .enc"
507
538
  };
508
539
 
509
540
  const zh = {
@@ -686,7 +717,21 @@ const cssId = 'dsh-subscriptions/settings.module.css'
686
717
  "healthQuarantine": "隔离期",
687
718
  "healthWarmup": "预热中",
688
719
  "healthActive": "活跃",
689
- "healthIdle": "空闲"
720
+ "healthIdle": "空闲",
721
+ "speedMetricsHint": "首字响应延迟 (TTFT) 与每秒生成速度 (Tokens/s)",
722
+ "backupExport": "导出备份",
723
+ "backupImport": "导入备份",
724
+ "backupExportTitle": "导出加密备份",
725
+ "backupImportTitle": "导入加密备份",
726
+ "backupExportAction": "导出",
727
+ "backupImportAction": "导入",
728
+ "backupPassphrase": "凭据库密码(至少 4 位)",
729
+ "backupExportHint": "使用 AES-256-GCM 与 PBKDF2 安全加密导出所有订阅账户与配置。",
730
+ "backupImportHint": "从已加密的备份内容中导入并恢复订阅账户与凭据。",
731
+ "backupPayloadPlaceholder": "在此粘贴 DSHE... 加密内容",
732
+ "backupExportOk": "备份导出成功",
733
+ "backupImportOk": "备份导入成功",
734
+ "backupDownload": "下载 .enc 文件"
690
735
  };
691
736
  // #51: live countdown to the quota window reset (account.quota.resetAt).
692
737
  function cleanErrorMessage(raw) {
@@ -908,6 +953,64 @@ const cssId = 'dsh-subscriptions/settings.module.css'
908
953
  const [telemetry, setTelemetry] = React.useState(null)
909
954
  const [smoke, setSmoke] = React.useState({ testing: false, result: null })
910
955
  const [updater, setUpdater] = React.useState({ checking: false, updating: false, status: null, message: '' })
956
+ const [backupModal, setBackupModal] = React.useState(null)
957
+ const [backupPass, setBackupPass] = React.useState('')
958
+ const [backupData, setBackupData] = React.useState('')
959
+ const [backupErr, setBackupErr] = React.useState('')
960
+ const [backupInfo, setBackupInfo] = React.useState('')
961
+ const [backupLoading, setBackupLoading] = React.useState(false)
962
+
963
+ const doExportBackup = async () => {
964
+ setBackupErr(''); setBackupInfo(''); setBackupLoading(true)
965
+ try {
966
+ const res = await fetch('/dsh-subscriptions/export-backup', {
967
+ method: 'POST',
968
+ headers: { 'Content-Type': 'application/json' },
969
+ body: JSON.stringify({ passphrase: backupPass, algorithm: 'pbkdf2' }),
970
+ })
971
+ const d = await res.json().catch(() => ({}))
972
+ if (!res.ok || !d.ok) throw new Error((d.error && d.error.message) || ('HTTP ' + res.status))
973
+ setBackupData(d.vault)
974
+ setBackupInfo(t('backupExportOk') + ' (' + d.slotCount + ' slots)')
975
+ } catch (e) {
976
+ setBackupErr(cleanErrorMessage(e.message || e))
977
+ }
978
+ setBackupLoading(false)
979
+ }
980
+
981
+ const doImportBackup = async () => {
982
+ setBackupErr(''); setBackupInfo(''); setBackupLoading(true)
983
+ try {
984
+ const res = await fetch('/dsh-subscriptions/import-backup', {
985
+ method: 'POST',
986
+ headers: { 'Content-Type': 'application/json' },
987
+ body: JSON.stringify({ passphrase: backupPass, vault: backupData.trim() }),
988
+ })
989
+ const d = await res.json().catch(() => ({}))
990
+ if (!res.ok || !d.ok) throw new Error((d.error && d.error.message) || ('HTTP ' + res.status))
991
+ setBackupInfo(t('backupImportOk') + ' (' + d.slotCount + ' slots)')
992
+ if (d.accounts) setAccounts(d.accounts)
993
+ await reload()
994
+ } catch (e) {
995
+ setBackupErr(cleanErrorMessage(e.message || e))
996
+ }
997
+ setBackupLoading(false)
998
+ }
999
+
1000
+ const downloadBackupFile = () => {
1001
+ if (!backupData) return
1002
+ try {
1003
+ const blob = new window.Blob([backupData], { type: 'text/plain;charset=utf-8' })
1004
+ const url = URL.createObjectURL(blob)
1005
+ const a = document.createElement('a')
1006
+ a.href = url
1007
+ a.download = 'dsh-subscriptions-backup.enc'
1008
+ document.body.appendChild(a)
1009
+ a.click()
1010
+ document.body.removeChild(a)
1011
+ URL.revokeObjectURL(url)
1012
+ } catch { /* ignore */ }
1013
+ }
911
1014
 
912
1015
  const checkUpdater = () => {
913
1016
  setUpdater((u) => Object.assign({}, u, { checking: true, message: '' }))
@@ -1056,6 +1159,9 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1056
1159
 
1057
1160
  const connect = async (provider, index) => {
1058
1161
  setErr('')
1162
+ if (provider === 'copilot') {
1163
+ return deviceStartLogin(provider, index).catch((e) => setErr(cleanErrorMessage(e.message || e)))
1164
+ }
1059
1165
  if (provider === 'antigravity' && (!draft || !draft.antigravityClientId || !String(draft.antigravityClientId).trim())) {
1060
1166
  setErr(t('missingClientIdAntigravity'))
1061
1167
  return
@@ -1521,6 +1627,8 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1521
1627
  React.createElement('div', { className: 'dsub-row' },
1522
1628
  React.createElement('button', { type: 'button', className: 'dsub-mini', onClick: genDiag }, t('diagGenerate')),
1523
1629
  React.createElement('button', { type: 'button', className: 'dsub-mini', onClick: runAnalyzeSession }, analyzingCache ? '…' : t('cacheAnalyze')),
1630
+ React.createElement('button', { type: 'button', className: 'dsub-mini', onClick: () => { setBackupModal('export'); setBackupErr(''); setBackupInfo('') } }, '🔒 ' + t('backupExport')),
1631
+ React.createElement('button', { type: 'button', className: 'dsub-mini', onClick: () => { setBackupModal('import'); setBackupErr(''); setBackupInfo('') } }, '📥 ' + t('backupImport')),
1524
1632
  React.createElement('a', { href: 'https://github.com/GooDAnDReaDY/dsh-subscriptions/issues', target: '_blank', rel: 'noopener noreferrer', className: 'dsub-mini' }, t('diagIssues')),
1525
1633
  ),
1526
1634
  React.createElement('div', { className: 'dsub-sub' }, t('diagHint')),
@@ -1535,6 +1643,63 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1535
1643
  }, t('diagCopy')),
1536
1644
  React.createElement('pre', { className: 'dsub-diag' }, diag),
1537
1645
  ) : null,
1646
+ backupModal ? React.createElement('div', { className: 'dsub-panel-box', style: { marginTop: '10px' } },
1647
+ React.createElement('div', { className: 'dsub-panel-title' },
1648
+ React.createElement('span', null, backupModal === 'export' ? ('🔒 ' + t('backupExportTitle')) : ('📥 ' + t('backupImportTitle'))),
1649
+ React.createElement('button', {
1650
+ type: 'button',
1651
+ className: 'dsub-mini',
1652
+ onClick: () => { setBackupModal(null); setBackupPass(''); setBackupData(''); setBackupErr(''); setBackupInfo('') }
1653
+ }, '✕ ' + t('closeModal'))
1654
+ ),
1655
+ React.createElement('div', { className: 'dsub-panel-desc' },
1656
+ backupModal === 'export' ? t('backupExportHint') : t('backupImportHint')
1657
+ ),
1658
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' } },
1659
+ backupModal === 'import' ? React.createElement('textarea', {
1660
+ className: 'dsub-diag',
1661
+ style: { width: '100%', height: '80px', fontFamily: 'monospace', fontSize: '11px' },
1662
+ placeholder: t('backupPayloadPlaceholder'),
1663
+ value: backupData,
1664
+ onChange: (e) => setBackupData(e.target.value),
1665
+ }) : null,
1666
+ React.createElement('div', { className: 'dsub-row' },
1667
+ React.createElement('input', {
1668
+ type: 'password',
1669
+ className: 'dsub-grow',
1670
+ placeholder: t('backupPassphrase'),
1671
+ value: backupPass,
1672
+ onChange: (e) => setBackupPass(e.target.value),
1673
+ }),
1674
+ backupModal === 'export' ? React.createElement('button', {
1675
+ type: 'button',
1676
+ className: 'dsub-mini',
1677
+ disabled: backupLoading || !backupPass || backupPass.length < 4,
1678
+ onClick: doExportBackup,
1679
+ }, backupLoading ? '…' : t('backupExportAction')) : React.createElement('button', {
1680
+ type: 'button',
1681
+ className: 'dsub-mini',
1682
+ disabled: backupLoading || !backupPass || !backupData,
1683
+ onClick: doImportBackup,
1684
+ }, backupLoading ? '…' : t('backupImportAction'))
1685
+ ),
1686
+ backupErr ? React.createElement('div', { className: 'dsub-bad' }, backupErr) : null,
1687
+ backupInfo ? React.createElement('div', { className: 'dsub-ok' }, backupInfo) : null,
1688
+ (backupModal === 'export' && backupData) ? React.createElement('div', { className: 'dsub-row' },
1689
+ React.createElement('button', {
1690
+ type: 'button',
1691
+ className: 'dsub-mini',
1692
+ onClick: () => { try { navigator.clipboard.writeText(backupData) } catch { /* ignore */ } },
1693
+ }, t('diagCopy')),
1694
+ React.createElement('button', {
1695
+ type: 'button',
1696
+ className: 'dsub-mini',
1697
+ onClick: downloadBackupFile,
1698
+ }, t('backupDownload')),
1699
+ React.createElement('pre', { className: 'dsub-diag', style: { maxHeight: '60px', overflow: 'hidden', textOverflow: 'ellipsis' } }, backupData)
1700
+ ) : null
1701
+ )
1702
+ ) : null,
1538
1703
  ),
1539
1704
  names.map((prov) => {
1540
1705
  const rows = slots
@@ -1564,7 +1729,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1564
1729
  type: 'button', className: 'dsub-mini',
1565
1730
  onClick: () => connect(row.slot.provider, row.slot.index).catch((e) => setErr(cleanErrorMessage(e.message || e))),
1566
1731
  }, on ? t('reconnect') : t('connect')),
1567
- (row.slot.provider === 'codex' ? React.createElement('button', {
1732
+ ((row.slot.provider === 'codex' || row.slot.provider === 'copilot') ? React.createElement('button', {
1568
1733
  type: 'button', className: 'dsub-mini',
1569
1734
  onClick: () => deviceStartLogin(row.slot.provider, row.slot.index).catch((e) => setErr(cleanErrorMessage(e.message || e))),
1570
1735
  }, t('deviceLogin')) : null),
@@ -1826,7 +1991,11 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1826
1991
  var est = Math.floor(rem / (w.usedPercent / account.requests))
1827
1992
  if (est > 0) extra = React.createElement('span', null, t('forecast') + ' ' + est)
1828
1993
  }
1829
- items.push(renderItem(windowLabel(w), w.usedPercent, extra))
1994
+ var resetElem = w.resetAt ? React.createElement(ResetCountdown, { key: 'reset-' + idx, resetAt: w.resetAt }) : null
1995
+ var extraContent = (resetElem && extra)
1996
+ ? React.createElement('span', { className: 'dsub-row', style: { gap: '6px' } }, resetElem, extra)
1997
+ : (resetElem || extra)
1998
+ items.push(renderItem(windowLabel(w), w.usedPercent, extraContent))
1830
1999
  }
1831
2000
  })
1832
2001
  }
@@ -1851,11 +2020,24 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1851
2020
  React.createElement('div', { className: 'dsub-usage-grid' }, items)
1852
2021
  )
1853
2022
  })(),
1854
- (account.paidTierName || account.ref ? React.createElement('div', { className: 'dsub-slot-meta-row' },
2023
+ ((account.paidTierName || account.ref || (telemetry && telemetry.accounts && telemetry.accounts[account.ref])) ? React.createElement('div', { className: 'dsub-slot-meta-row' },
1855
2024
  account.paidTierName ? React.createElement('span', { className: 'dsub-sub' },
1856
2025
  React.createElement('strong', null, t('plan') + ': '),
1857
2026
  account.paidTierName
1858
2027
  ) : null,
2028
+ (function(){
2029
+ const accTelem = telemetry && telemetry.accounts && telemetry.accounts[account.ref]
2030
+ if (!accTelem) return null
2031
+ const parts = []
2032
+ if (accTelem.avgTtftMs != null) parts.push('TTFT: ' + accTelem.avgTtftMs + 'ms')
2033
+ else if (accTelem.lastTtftMs != null) parts.push('TTFT: ' + accTelem.lastTtftMs + 'ms')
2034
+ if (accTelem.avgTps != null) parts.push(accTelem.avgTps + ' t/s')
2035
+ else if (accTelem.lastTps != null) parts.push(accTelem.lastTps + ' t/s')
2036
+ if (parts.length === 0) return null
2037
+ return React.createElement('span', { className: 'dsub-speed-tag', title: t('speedMetricsHint') },
2038
+ '⚡ ' + parts.join(' · ')
2039
+ )
2040
+ })(),
1859
2041
  account.ref ? React.createElement('span', { className: 'dsub-ref-tag' },
1860
2042
  React.createElement('span', null, t('storedAs')),
1861
2043
  React.createElement('code', null, account.ref)
@@ -2166,7 +2348,13 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2166
2348
  if (state.active) {
2167
2349
  const a = state.active
2168
2350
  const wins = Array.isArray(a.windows) ? a.windows : []
2169
- const winStr = wins.map((w) => (w.label || w.id) + ' ' + Math.round(w.usedPercent) + '%').join(' ')
2351
+ const winStr = wins.map((w) => {
2352
+ let s = (w.label || w.id) + ' ' + Math.round(w.usedPercent) + '%'
2353
+ if (w.resetAt && w.resetAt > now) {
2354
+ s += ' (' + formatRelativeReset(w.resetAt, t('lang'), now) + ')'
2355
+ }
2356
+ return s
2357
+ }).join(' ')
2170
2358
  const provName = a.provider ? (a.provider.charAt(0).toUpperCase() + a.provider.slice(1)) : ''
2171
2359
  pillText = (a.provider === 'codex' && a.fastMode ? '⚡ ' : '') + provName + (winStr ? ' · ' + winStr : (a.usagePercent != null ? ' · ' + Math.round(a.usagePercent) + '%' : ''))
2172
2360
  }
@@ -2209,7 +2397,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2209
2397
  ),
2210
2398
  secWin ? React.createElement('div', null,
2211
2399
  React.createElement('div', { className: 'dsub-barLabelRow' },
2212
- React.createElement('span', null, (secWin.label || '7d') + ' window'),
2400
+ React.createElement('span', null, ((secWin.label || '7d') + ' window') + (secWin && secWin.resetAt ? ' (' + formatRelativeReset(secWin.resetAt, t('lang'), now) + ')' : '')),
2213
2401
  React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, secPct + '%'),
2214
2402
  ),
2215
2403
  React.createElement('div', { className: 'dsub-barTrack' },
package/lib/crypto.js CHANGED
@@ -1,27 +1,46 @@
1
- import { scryptSync, createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
1
+ import { scryptSync, pbkdf2Sync, createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
2
2
 
3
- // AES-256-GCM with a passphrase-derived key (scrypt, no separately stored salt -
4
- // the salt is stored at the head of the block).
5
- export function encryptWithPassphrase(plainText, passphrase) {
3
+ // AES-256-GCM with PBKDF2 (100,000 iterations, SHA-256) (DSHE2:) or scrypt (DSHE1:).
4
+ // Backward-compatible across both key derivation algorithms.
5
+ export function encryptWithPassphrase(plainText, passphrase, { algorithm = "scrypt" } = {}) {
6
6
  const salt = randomBytes(16)
7
- const key = scryptSync(String(passphrase), salt, 32)
7
+ const isPbkdf2 = algorithm === "pbkdf2"
8
+ const key = isPbkdf2
9
+ ? pbkdf2Sync(String(passphrase), salt, 100000, 32, "sha256")
10
+ : scryptSync(String(passphrase), salt, 32)
8
11
  const iv = randomBytes(12)
9
12
  const cipher = createCipheriv("aes-256-gcm", key, iv)
10
13
  const encrypted = Buffer.concat([cipher.update(String(plainText), "utf8"), cipher.final()])
11
14
  const tag = cipher.getAuthTag()
12
15
  // format: magic + salt + iv + tag + data
13
- return "DSHE1:" + Buffer.concat([salt, iv, tag, encrypted]).toString("base64")
16
+ const prefix = isPbkdf2 ? "DSHE2:" : "DSHE1:"
17
+ return prefix + Buffer.concat([salt, iv, tag, encrypted]).toString("base64")
14
18
  }
15
19
 
16
20
  export function decryptWithPassphrase(payload, passphrase) {
17
21
  const raw = String(payload || "")
18
- if (!raw.startsWith("DSHE1:")) throw new Error("unknown export format")
19
- const buf = Buffer.from(raw.slice(6), "base64")
20
- const salt = buf.subarray(0, 16)
21
- const iv = buf.subarray(16, 28)
22
- const tag = buf.subarray(28, 44)
23
- const data = buf.subarray(44)
24
- const key = scryptSync(String(passphrase), salt, 32)
22
+ let key
23
+ let salt
24
+ let iv
25
+ let tag
26
+ let data
27
+ if (raw.startsWith("DSHE2:")) {
28
+ const buf = Buffer.from(raw.slice(6), "base64")
29
+ salt = buf.subarray(0, 16)
30
+ iv = buf.subarray(16, 28)
31
+ tag = buf.subarray(28, 44)
32
+ data = buf.subarray(44)
33
+ key = pbkdf2Sync(String(passphrase), salt, 100000, 32, "sha256")
34
+ } else if (raw.startsWith("DSHE1:")) {
35
+ const buf = Buffer.from(raw.slice(6), "base64")
36
+ salt = buf.subarray(0, 16)
37
+ iv = buf.subarray(16, 28)
38
+ tag = buf.subarray(28, 44)
39
+ data = buf.subarray(44)
40
+ key = scryptSync(String(passphrase), salt, 32)
41
+ } else {
42
+ throw new Error("unknown export format")
43
+ }
25
44
  const decipher = createDecipheriv("aes-256-gcm", key, iv)
26
45
  decipher.setAuthTag(tag)
27
46
  return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8")
package/lib/history.js CHANGED
@@ -109,6 +109,108 @@ export class HistoryStore {
109
109
  return this.rows.length
110
110
  }
111
111
 
112
+ /** Summarize session and 24-hour telemetry for UI stat cards. */
113
+ accountTelemetry(ref) {
114
+ if (!ref) return null
115
+ let count = 0
116
+ let ttftSum = 0
117
+ let ttftCount = 0
118
+ let lastTtft = null
119
+ let tpsSum = 0
120
+ let tpsCount = 0
121
+ let lastTps = null
122
+ let totalOutputTokens = 0
123
+ let latencySum = 0
124
+ let lastLatency = 0
125
+
126
+ for (const r of this.rows) {
127
+ if (r.ref !== ref) continue
128
+ count++
129
+ if (typeof r.ms === "number" && r.ms > 0) {
130
+ latencySum += r.ms
131
+ if (!lastLatency) lastLatency = r.ms
132
+ }
133
+ if (typeof r.ttftMs === "number" && r.ttftMs >= 0) {
134
+ ttftSum += r.ttftMs
135
+ ttftCount++
136
+ if (lastTtft === null) lastTtft = r.ttftMs
137
+ }
138
+ if (typeof r.tps === "number" && r.tps > 0) {
139
+ tpsSum += r.tps
140
+ tpsCount++
141
+ if (lastTps === null) lastTps = r.tps
142
+ }
143
+ if (typeof r.outputTokens === "number" && r.outputTokens > 0) {
144
+ totalOutputTokens += r.outputTokens
145
+ }
146
+ }
147
+ if (count === 0) return null
148
+ return {
149
+ totalRequests: count,
150
+ avgLatencyMs: count > 0 ? Math.round(latencySum / count) : 0,
151
+ lastLatencyMs: lastLatency,
152
+ avgTtftMs: ttftCount > 0 ? Math.round(ttftSum / ttftCount) : null,
153
+ lastTtftMs: lastTtft,
154
+ avgTps: tpsCount > 0 ? Math.round((tpsSum / tpsCount) * 10) / 10 : null,
155
+ lastTps: lastTps,
156
+ totalOutputTokens,
157
+ }
158
+ }
159
+
160
+ accountsTelemetry() {
161
+ const map = {}
162
+ for (const r of this.rows) {
163
+ if (!r.ref) continue
164
+ if (!map[r.ref]) {
165
+ map[r.ref] = {
166
+ count: 0,
167
+ latencySum: 0,
168
+ lastLatency: 0,
169
+ ttftSum: 0,
170
+ ttftCount: 0,
171
+ lastTtft: null,
172
+ tpsSum: 0,
173
+ tpsCount: 0,
174
+ lastTps: null,
175
+ totalOutputTokens: 0,
176
+ }
177
+ }
178
+ const st = map[r.ref]
179
+ st.count++
180
+ if (typeof r.ms === "number" && r.ms > 0) {
181
+ st.latencySum += r.ms
182
+ if (!st.lastLatency) st.lastLatency = r.ms
183
+ }
184
+ if (typeof r.ttftMs === "number" && r.ttftMs >= 0) {
185
+ st.ttftSum += r.ttftMs
186
+ st.ttftCount++
187
+ if (st.lastTtft === null) st.lastTtft = r.ttftMs
188
+ }
189
+ if (typeof r.tps === "number" && r.tps > 0) {
190
+ st.tpsSum += r.tps
191
+ st.tpsCount++
192
+ if (st.lastTps === null) st.lastTps = r.tps
193
+ }
194
+ if (typeof r.outputTokens === "number" && r.outputTokens > 0) {
195
+ st.totalOutputTokens += r.outputTokens
196
+ }
197
+ }
198
+ const out = {}
199
+ for (const [ref, st] of Object.entries(map)) {
200
+ out[ref] = {
201
+ totalRequests: st.count,
202
+ avgLatencyMs: st.count > 0 ? Math.round(st.latencySum / st.count) : 0,
203
+ lastLatencyMs: st.lastLatency,
204
+ avgTtftMs: st.ttftCount > 0 ? Math.round(st.ttftSum / st.ttftCount) : null,
205
+ lastTtftMs: st.lastTtft,
206
+ avgTps: st.tpsCount > 0 ? Math.round((st.tpsSum / st.tpsCount) * 10) / 10 : null,
207
+ lastTps: st.lastTps,
208
+ totalOutputTokens: st.totalOutputTokens,
209
+ }
210
+ }
211
+ return out
212
+ }
213
+
112
214
  /** Summarize session and 24-hour telemetry for UI stat cards. */
113
215
  telemetrySummary() {
114
216
  const total = this.rows.length
@@ -124,6 +226,7 @@ export class HistoryStore {
124
226
  lastRequestAt: null,
125
227
  lastProvider: null,
126
228
  lastModel: null,
229
+ accounts: {},
127
230
  }
128
231
  }
129
232
  let success = 0
@@ -157,6 +260,7 @@ export class HistoryStore {
157
260
  lastRequestAt: (first && first.ts) || null,
158
261
  lastProvider: (first && first.provider) || null,
159
262
  lastModel: (first && first.model) || null,
263
+ accounts: this.accountsTelemetry(),
160
264
  }
161
265
  }
162
266
  }
@@ -169,7 +169,27 @@ async function discoverLocalCliSessionsInner() {
169
169
  }
170
170
 
171
171
  // 9. GitHub Copilot token
172
- if (process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN) {
172
+ const copilotPaths = [
173
+ homeFile('.config', 'github-copilot', 'hosts.json'),
174
+ homeFile('.config', 'github-copilot', 'apps.json'),
175
+ ]
176
+ for (const p of copilotPaths) {
177
+ const cp = await readJson(p)
178
+ if (cp) {
179
+ const gh = cp['github.com'] || cp
180
+ const tok = gh.oauth_token || gh.token || gh.access_token
181
+ if (tok) {
182
+ detected.copilot = {
183
+ provider: 'copilot',
184
+ path: p,
185
+ email: gh.user || 'GitHub Copilot User',
186
+ hasRefreshToken: false,
187
+ }
188
+ break
189
+ }
190
+ }
191
+ }
192
+ if (!detected.copilot && (process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN)) {
173
193
  detected.copilot = {
174
194
  provider: 'copilot',
175
195
  path: 'GITHUB_COPILOT_TOKEN env',
@@ -217,12 +237,23 @@ export async function loadLocalCliBlob(provider, options = {}) {
217
237
  }
218
238
 
219
239
  if (provider === 'copilot') {
220
- const tok = process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN || ''
240
+ let tok = process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN || ''
241
+ let email = 'GitHub Copilot User'
242
+ if (info.path && info.path !== 'GITHUB_COPILOT_TOKEN env') {
243
+ const cp = await readJson(info.path)
244
+ if (cp) {
245
+ const gh = cp['github.com'] || cp
246
+ tok = gh.oauth_token || gh.token || gh.access_token || tok
247
+ email = gh.user || email
248
+ }
249
+ }
221
250
  return {
222
251
  accessToken: tok,
223
252
  refreshToken: tok,
253
+ githubToken: tok,
224
254
  expiresAt: Date.now() + 30 * 86400 * 1000,
225
- email: 'GitHub Copilot User'
255
+ email,
256
+ label: email,
226
257
  }
227
258
  }
228
259
 
package/lib/refs.js CHANGED
@@ -55,6 +55,7 @@ const DISPLAY = {
55
55
  glm: 'Zhipu GLM',
56
56
  cursor: 'Cursor',
57
57
  kiro: 'AWS Kiro',
58
+ copilot: 'GitHub Copilot',
58
59
  }
59
60
 
60
61
  export function displayName(provider) {
@@ -349,100 +349,117 @@ export function registerAccountsRoutes(ctx, state) {
349
349
  }),
350
350
  }), 'dsh-subscriptions: /logout')
351
351
 
352
- // #353: Encrypted Vault export
352
+ const handleVaultExport = safeJsonHandler(async (req, res) => {
353
+ if (req.method !== 'POST') {
354
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
355
+ return
356
+ }
357
+ if (!isTrustedSettingsRequest(req)) {
358
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
359
+ return
360
+ }
361
+ let payload
362
+ try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
363
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
364
+ return
365
+ }
366
+ const passphrase = payload.passphrase
367
+ const algorithm = payload.algorithm || 'pbkdf2'
368
+ try {
369
+ const slots = live().slots || []
370
+ const blobs = {}
371
+ for (const slot of normalizeSlots(slots)) {
372
+ try {
373
+ const blob = await store.loadBlob(slot.ref)
374
+ if (blob) blobs[slot.ref] = blob
375
+ } catch { /* ignore */ }
376
+ }
377
+ const result = exportVault({ slots, blobs, algorithm }, passphrase)
378
+ writeJson(res, 200, result)
379
+ } catch (e) {
380
+ writeJson(res, 400, { ok: false, error: { code: 'vault_export', message: String(e && e.message || e) } })
381
+ }
382
+ })
383
+
384
+ // #353 & #396: Encrypted Vault export
353
385
  ctx.effect(() => ctx.webServer.register({
354
386
  kind: 'exact',
355
387
  path: '/dsh-subscriptions/vault/export',
356
- handler: safeJsonHandler(async (req, res) => {
357
- if (req.method !== 'POST') {
358
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
359
- return
360
- }
361
- if (!isTrustedSettingsRequest(req)) {
362
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
363
- return
364
- }
365
- let payload
366
- try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
367
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
368
- return
369
- }
370
- const passphrase = payload.passphrase
371
- try {
372
- const slots = live().slots || []
373
- const blobs = {}
374
- for (const slot of normalizeSlots(slots)) {
375
- try {
376
- const blob = await store.loadBlob(slot.ref)
377
- if (blob) blobs[slot.ref] = blob
378
- } catch { /* ignore */ }
379
- }
380
- const result = exportVault({ slots, blobs }, passphrase)
381
- writeJson(res, 200, result)
382
- } catch (e) {
383
- writeJson(res, 400, { ok: false, error: { code: 'vault_export', message: String(e && e.message || e) } })
384
- }
385
- }),
388
+ handler: handleVaultExport,
386
389
  }), 'dsh-subscriptions: /vault/export')
387
390
 
388
- // #353: Encrypted Vault import
389
391
  ctx.effect(() => ctx.webServer.register({
390
392
  kind: 'exact',
391
- path: '/dsh-subscriptions/vault/import',
392
- handler: safeJsonHandler(async (req, res) => {
393
- if (req.method !== 'POST') {
394
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
395
- return
396
- }
397
- if (!isTrustedSettingsRequest(req)) {
398
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
399
- return
400
- }
401
- let payload
402
- try { payload = JSON.parse((await readBody(req, 2 * 1024 * 1024)).toString('utf8') || '{}') } catch {
403
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
404
- return
405
- }
406
- const { passphrase, vault } = payload
407
- try {
408
- const result = importVault(vault, passphrase)
409
- const settings = getSettingsApi()
410
- let parsed
411
- if (result.slots.length) {
412
- if (!settings) throw new Error('settings are unavailable for vault import')
413
- const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
414
- for (const s of result.slots) {
415
- const idx = curSlots.findIndex((c) => c.provider === s.provider && Number(c.index) === Number(s.index))
416
- if (idx >= 0) {
417
- curSlots[idx] = { ...curSlots[idx], ...s }
418
- } else {
419
- curSlots.push(s)
420
- }
393
+ path: '/dsh-subscriptions/export-backup',
394
+ handler: handleVaultExport,
395
+ }), 'dsh-subscriptions: /export-backup')
396
+
397
+ const handleVaultImport = safeJsonHandler(async (req, res) => {
398
+ if (req.method !== 'POST') {
399
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
400
+ return
401
+ }
402
+ if (!isTrustedSettingsRequest(req)) {
403
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
404
+ return
405
+ }
406
+ let payload
407
+ try { payload = JSON.parse((await readBody(req, 2 * 1024 * 1024)).toString('utf8') || '{}') } catch {
408
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
409
+ return
410
+ }
411
+ const { passphrase, vault } = payload
412
+ try {
413
+ const result = importVault(vault, passphrase)
414
+ const settings = getSettingsApi()
415
+ let parsed
416
+ if (result.slots.length) {
417
+ if (!settings) throw new Error('settings are unavailable for vault import')
418
+ const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
419
+ for (const s of result.slots) {
420
+ const idx = curSlots.findIndex((c) => c.provider === s.provider && Number(c.index) === Number(s.index))
421
+ if (idx >= 0) {
422
+ curSlots[idx] = { ...curSlots[idx], ...s }
423
+ } else {
424
+ curSlots.push(s)
421
425
  }
422
- parsed = Config({ ...live(), slots: stripLegacySlots(curSlots) })
423
- }
424
- let blobCount = 0
425
- for (const [ref, blob] of Object.entries(result.blobs)) {
426
- await store.saveBlob(ref, blob)
427
- blobCount++
428
- }
429
- if (parsed) {
430
- await settings.replace(parsed)
431
- syncCustomVendors()
432
426
  }
433
- await syncAdapter()
434
- refreshModels().catch(() => {})
435
- writeJson(res, 200, {
436
- ok: true,
437
- slotCount: result.slots.length,
438
- blobCount,
439
- exportedAt: result.exportedAt,
440
- accounts: await accountsView(),
441
- })
442
- } catch (e) {
443
- writeJson(res, 400, { ok: false, error: { code: 'vault_import', message: String(e && e.message || e) } })
427
+ parsed = Config({ ...live(), slots: stripLegacySlots(curSlots) })
444
428
  }
445
- }),
429
+ let blobCount = 0
430
+ for (const [ref, blob] of Object.entries(result.blobs)) {
431
+ await store.saveBlob(ref, blob)
432
+ blobCount++
433
+ }
434
+ if (parsed) {
435
+ await settings.replace(parsed)
436
+ syncCustomVendors()
437
+ }
438
+ await syncAdapter()
439
+ refreshModels().catch(() => {})
440
+ writeJson(res, 200, {
441
+ ok: true,
442
+ slotCount: result.slots.length,
443
+ blobCount,
444
+ exportedAt: result.exportedAt,
445
+ accounts: await accountsView(),
446
+ })
447
+ } catch (e) {
448
+ writeJson(res, 400, { ok: false, error: { code: 'vault_import', message: String(e && e.message || e) } })
449
+ }
450
+ })
451
+
452
+ // #353 & #396: Encrypted Vault import
453
+ ctx.effect(() => ctx.webServer.register({
454
+ kind: 'exact',
455
+ path: '/dsh-subscriptions/vault/import',
456
+ handler: handleVaultImport,
446
457
  }), 'dsh-subscriptions: /vault/import')
447
458
 
459
+ ctx.effect(() => ctx.webServer.register({
460
+ kind: 'exact',
461
+ path: '/dsh-subscriptions/import-backup',
462
+ handler: handleVaultImport,
463
+ }), 'dsh-subscriptions: /import-backup')
464
+
448
465
  }
package/lib/usage.js CHANGED
@@ -49,6 +49,24 @@ export function asUsageSnapshot(percent) {
49
49
  if (usedPercent == null) return null
50
50
  return { usedPercent }
51
51
  }
52
+
53
+ export function parseResetAt(val) {
54
+ if (val == null) return null
55
+ if (typeof val === 'number' && Number.isFinite(val) && val > 0) {
56
+ return val < 1e11 ? Math.round(val * 1000) : Math.round(val)
57
+ }
58
+ if (typeof val === 'string' && val.trim()) {
59
+ const s = val.trim()
60
+ const n = Number(s)
61
+ if (Number.isFinite(n) && n > 0) {
62
+ return n < 1e11 ? Math.round(n * 1000) : Math.round(n)
63
+ }
64
+ const parsed = Date.parse(s)
65
+ if (Number.isFinite(parsed) && parsed > 0) return parsed
66
+ }
67
+ return null
68
+ }
69
+
52
70
  // Named limit windows reported by vendors (five_hour, seven_day, primary...).
53
71
  // Label map is a data table: extend it when a vendor adds a window name.
54
72
  const WINDOW_LABELS = {
@@ -105,11 +123,14 @@ export function usageWindows(obj) {
105
123
  if (usedPercent != null) {
106
124
  seen.add(resolved.id)
107
125
  const label = windowLabel(resolved.id)
126
+ const rawReset = value.resetAt ?? value.reset_at ?? value.resetTime ?? value.resets_at ?? value.resetsAt ?? value.reset_time
127
+ const resetAt = parseResetAt(rawReset)
108
128
  out.push({
109
129
  id: resolved.id,
110
130
  zh: label.zh,
111
131
  en: label.en,
112
132
  usedPercent,
133
+ ...(resetAt ? { resetAt } : {}),
113
134
  isModelBucket: resolved.isModelBucket,
114
135
  })
115
136
  }
package/lib/vault.js CHANGED
@@ -9,7 +9,7 @@ export const VAULT_FORMAT = 'dsh-subscriptions-vault-v1'
9
9
  /**
10
10
  * Encrypts slots configuration and credential blobs into a portable vault string.
11
11
  */
12
- export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscriptions' }, passphrase) {
12
+ export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscriptions', algorithm = 'scrypt' }, passphrase) {
13
13
  const pass = String(passphrase || '').trim()
14
14
  if (!pass || pass.length < 4) {
15
15
  throw new Error('passphrase must be at least 4 characters')
@@ -24,7 +24,7 @@ export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscrip
24
24
  blobs: blobs && typeof blobs === 'object' ? blobs : {},
25
25
  }
26
26
 
27
- const encrypted = encryptWithPassphrase(JSON.stringify(payload), pass)
27
+ const encrypted = encryptWithPassphrase(JSON.stringify(payload), pass, { algorithm })
28
28
  return {
29
29
  ok: true,
30
30
  vault: encrypted,
@@ -43,7 +43,7 @@ export function importVault(encryptedPayload, passphrase) {
43
43
  }
44
44
 
45
45
  const raw = String(encryptedPayload || '').trim()
46
- if (!raw.startsWith('DSHE1:')) {
46
+ if (!raw.startsWith('DSHE1:') && !raw.startsWith('DSHE2:')) {
47
47
  throw new Error('invalid vault format: missing DSHE1 header')
48
48
  }
49
49
 
@@ -42,6 +42,14 @@ export const COPILOT_MODELS = [
42
42
  maxTokens: 65536,
43
43
  inputModalities: ["text"],
44
44
  reasoning: { efforts: [{ id: "low", name: "Low" }, { id: "medium", name: "Medium" }, { id: "high", name: "High" }] }
45
+ },
46
+ {
47
+ id: "o1",
48
+ name: "o1 (Copilot)",
49
+ contextWindow: 200000,
50
+ maxTokens: 65536,
51
+ inputModalities: ["text", "image"],
52
+ reasoning: { efforts: [{ id: "low", name: "Low" }, { id: "medium", name: "Medium" }, { id: "high", name: "High" }] }
45
53
  }
46
54
  ]
47
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.26",
3
+ "version": "0.6.27",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",