@goodandready/dsh-subscriptions 0.6.25 → 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")
@@ -52,7 +52,14 @@ export function toGeminiSchema(schema, depth) {
52
52
  out.items = toGeminiSchema(Array.isArray(items) ? items[0] : (items || { type: 'string' }), level + 1)
53
53
  }
54
54
  if (Array.isArray(schema.enum) && schema.enum.length) {
55
- out.enum = schema.enum.map((item) => String(item))
55
+ const cleaned = [...new Set(
56
+ schema.enum
57
+ .map((item) => String(item ?? '').trim())
58
+ .filter((item) => item.length > 0),
59
+ )]
60
+ if (cleaned.length > 0) {
61
+ out.enum = cleaned
62
+ }
56
63
  }
57
64
  return out
58
65
  }
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) {
package/lib/rotate.js CHANGED
@@ -8,11 +8,19 @@ const SWITCH_CODES = new Set([
8
8
  'LICENSE_REQUIRED',
9
9
  ])
10
10
 
11
+ export function isRegionError(err) {
12
+ if (!err || typeof err !== 'object') return false
13
+ const status = Number(err.status || err.statusCode) || 0
14
+ const msg = String(err.message || '')
15
+ return status === 400 && (/FAILED_PRECONDITION/i.test(msg) || /User location is not supported/i.test(msg))
16
+ }
17
+
11
18
  export function isSwitchableError(err) {
12
19
  if (!err || typeof err !== 'object') return false
13
20
  if (err.name === 'AbortError' || err.code === 'ABORT_ERR' || err.code === 'ERR_ABORTED' || (err.message && /aborted/i.test(err.message))) return false
14
21
  const code = err.code || (err.failure && err.failure.code)
15
22
  if (SWITCH_CODES.has(code)) return true
23
+ if (isRegionError(err)) return true
16
24
  const status = err.status || err.statusCode
17
25
  return status === 401 || status === 403 || status === 429 || status === 500 || status === 502 || status === 503
18
26
  }
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import { pickAccount, markCooldown, isSwitchableError, modelFamily } from './rotate.js'
1
+ import { pickAccount, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
2
2
  import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
3
3
 
4
4
  export async function* streamWithRotation({
@@ -45,38 +45,52 @@ export async function* streamWithRotation({
45
45
  tried.add(account.ref || account.id)
46
46
 
47
47
  let firstChunkDelivered = false
48
- try {
49
- for await (const chunk of streamOnce(account, options)) {
50
- firstChunkDelivered = true
51
- yield chunk
52
- }
53
- return
54
- } catch (err) {
55
- lastError = err
56
- // If chunks were already yielded to the caller, never rotate mid-stream
57
- // as that would repeat or scramble generated tokens.
58
- if (firstChunkDelivered) throw err
59
- if (!isSwitchableError(err)) throw err
48
+ let regionRetries = 0
49
+
50
+ while (true) {
51
+ try {
52
+ for await (const chunk of streamOnce(account, options)) {
53
+ firstChunkDelivered = true
54
+ yield chunk
55
+ }
56
+ return
57
+ } catch (err) {
58
+ lastError = err
59
+ // If chunks were already yielded to the caller, never rotate mid-stream
60
+ // as that would repeat or scramble generated tokens.
61
+ if (firstChunkDelivered) throw err
60
62
 
61
- // Move slot to cooldown and quarantine (#172)
62
- const cooled = markCooldown(account, nowMs(), cooldownMs, modelFamily(options && options.provider, options && options.model))
63
- account.cooldownUntil = cooled.cooldownUntil
64
- if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
63
+ // Transient region 400 error: retry same account up to 2 times before rotating (#391 / GH #9)
64
+ if (isRegionError(err) && regionRetries < 2 && (!options || !options.signal || !options.signal.aborted)) {
65
+ regionRetries++
66
+ await new Promise((r) => setTimeout(r, 400 * regionRetries))
67
+ if (options && options.signal && options.signal.aborted) throw err
68
+ continue
69
+ }
65
70
 
66
- const status = Number(err && (err.status || err.statusCode) || 0)
67
- const code = String(err && err.code || '')
68
- const reason = (status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
69
- ? REASON_REVOKED
70
- : (status === 403 || code === 'HARD_LIMIT' || code === 'LICENSE_REQUIRED')
71
- ? REASON_HARD_LIMIT
72
- : REASON_RATE_LIMIT
71
+ if (!isSwitchableError(err)) throw err
73
72
 
74
- const quarantined = putInQuarantine(account, reason, nowMs())
75
- account.quarantineUntil = quarantined.quarantineUntil
76
- account.quarantineReason = quarantined.quarantineReason
73
+ // Move slot to cooldown and quarantine (#172)
74
+ const cooled = markCooldown(account, nowMs(), cooldownMs, modelFamily(options && options.provider, options && options.model))
75
+ account.cooldownUntil = cooled.cooldownUntil
76
+ if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
77
77
 
78
- if (onCooldown) onCooldown(account)
79
- // Loop continues seamlessly to next slot if no chunk was delivered yet
78
+ const status = Number(err && (err.status || err.statusCode) || 0)
79
+ const code = String(err && err.code || '')
80
+ const reason = (status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
81
+ ? REASON_REVOKED
82
+ : (status === 403 || code === 'HARD_LIMIT' || code === 'LICENSE_REQUIRED')
83
+ ? REASON_HARD_LIMIT
84
+ : REASON_RATE_LIMIT
85
+
86
+ const quarantined = putInQuarantine(account, reason, nowMs())
87
+ account.quarantineUntil = quarantined.quarantineUntil
88
+ account.quarantineReason = quarantined.quarantineReason
89
+
90
+ if (onCooldown) onCooldown(account)
91
+ // Break inner retry loop to continue to next account in outer while (true) loop
92
+ break
93
+ }
80
94
  }
81
95
  }
82
96
  }
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 = {
@@ -70,37 +88,73 @@ export function windowLabel(id) {
70
88
  return known || { zh: id, en: id }
71
89
  }
72
90
 
91
+ function resolveWindowId(value, key) {
92
+ if (value && typeof value === 'object') {
93
+ const semantic = value.modelId ?? value.model ?? value.window ?? value.name ?? value.id ?? value.scope
94
+ if (typeof semantic === 'string' && semantic.trim() && !/^\d+$/.test(semantic.trim())) {
95
+ return { id: semantic.trim(), isModelBucket: Boolean(value.modelId || value.model) }
96
+ }
97
+ }
98
+ if (typeof key === 'string' && key.trim() && !/^\d+$/.test(key.trim())) {
99
+ return { id: key.trim(), isModelBucket: false }
100
+ }
101
+ return null
102
+ }
103
+
73
104
  // Walk vendor usage JSON; every node carrying a utilization-style number
74
- // becomes a named window keyed by its JSON key.
105
+ // becomes a named window keyed by its semantic identifier or JSON key.
75
106
  export function usageWindows(obj) {
76
107
  const out = []
77
108
  const seen = new Set()
78
109
  function walk(value, key, depth) {
79
- if (!value || typeof value !== "object" || depth > 6) return
80
- const pct = numberOrNull(
81
- value.utilization ?? value.used_percent ?? value.usedPercent ?? value.creditUsagePercent ?? value.used_percentage,
82
- )
83
- if (pct != null && key && !seen.has(key)) {
84
- seen.add(key)
85
- const label = windowLabel(key)
86
- out.push({ id: key, zh: label.zh, en: label.en, usedPercent: pct })
87
- }
88
- const frac = numberOrNull(value.remainingFraction)
89
- if (frac != null && key && !seen.has(key)) {
90
- seen.add(key)
91
- const label = windowLabel(key)
92
- out.push({ id: key, zh: label.zh, en: label.en, usedPercent: (1 - frac) * 100 })
93
- }
94
- const ufrac = numberOrNull(value.usedFraction)
95
- if (ufrac != null && key && !seen.has(key)) {
96
- seen.add(key)
97
- const label = windowLabel(key)
98
- out.push({ id: key, zh: label.zh, en: label.en, usedPercent: ufrac * 100 })
110
+ if (!value || typeof value !== 'object' || depth > 6) return
111
+ const resolved = resolveWindowId(value, key)
112
+ if (resolved && !seen.has(resolved.id)) {
113
+ const pct = numberOrNull(
114
+ value.utilization ?? value.used_percent ?? value.usedPercent ?? value.creditUsagePercent ?? value.used_percentage,
115
+ )
116
+ const frac = numberOrNull(value.remainingFraction)
117
+ const ufrac = numberOrNull(value.usedFraction)
118
+ let usedPercent = null
119
+ if (pct != null) usedPercent = pct
120
+ else if (frac != null) usedPercent = (1 - frac) * 100
121
+ else if (ufrac != null) usedPercent = ufrac * 100
122
+
123
+ if (usedPercent != null) {
124
+ seen.add(resolved.id)
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)
128
+ out.push({
129
+ id: resolved.id,
130
+ zh: label.zh,
131
+ en: label.en,
132
+ usedPercent,
133
+ ...(resetAt ? { resetAt } : {}),
134
+ isModelBucket: resolved.isModelBucket,
135
+ })
136
+ }
99
137
  }
100
138
  for (const [k, child] of Object.entries(value)) {
101
- if (child && typeof child === "object") walk(child, k, depth + 1)
139
+ if (child && typeof child === 'object') walk(child, k, depth + 1)
102
140
  }
103
141
  }
104
- walk(obj, "", 0)
105
- return out.length ? out : null
142
+ walk(obj, '', 0)
143
+
144
+ // Separate model buckets from standard named windows
145
+ const standardWindows = out.filter((w) => !w.isModelBucket)
146
+ const modelBuckets = out.filter((w) => w.isModelBucket)
147
+
148
+ let keptModelBuckets = []
149
+ if (modelBuckets.length) {
150
+ // Only surface model buckets when usage > 0, keeping the tightest 1-2
151
+ const active = modelBuckets.filter((w) => w.usedPercent > 0)
152
+ if (active.length) {
153
+ active.sort((a, b) => b.usedPercent - a.usedPercent)
154
+ keptModelBuckets = active.slice(0, 2)
155
+ }
156
+ }
157
+
158
+ const result = [...standardWindows, ...keptModelBuckets].map(({ isModelBucket: _, ...rest }) => rest)
159
+ return result.length ? result : null
106
160
  }
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/lib/wire.js CHANGED
@@ -190,7 +190,7 @@ export async function* openaiChatStream(body) {
190
190
  yield {
191
191
  type: 'tool-call-delta',
192
192
  index: block.index,
193
- id: block.id,
193
+ id: String(block.id || `call_${block.index}`),
194
194
  ...(block.name ? { name: block.name } : {}),
195
195
  argumentsDelta: fragment,
196
196
  }
@@ -207,20 +207,24 @@ export { streamResponses as codexResponsesStream } from './responses-stream.js'
207
207
  export async function* anthropicStream(body) {
208
208
  let index = 0
209
209
  let textIndex = 0
210
+ const toolCallIds = new Map()
210
211
  for await (const data of iterateSse(body)) {
211
212
  const json = jsonSse(data)
212
213
  if (!json) continue
213
214
  if (json.type === 'content_block_start') {
214
215
  const block = json.content_block || {}
215
216
  if (block.type === 'text') {
216
- textIndex = json.index || index++
217
+ textIndex = json.index ?? index++
217
218
  yield { type: 'block-start', index: textIndex, blockType: 'text' }
218
219
  } else if (block.type === 'tool_use') {
219
- yield { type: 'block-start', index: json.index || index++, blockType: 'tool-call' }
220
+ const bIndex = json.index ?? index++
221
+ const toolId = String(block.id || `toolu_${bIndex}`)
222
+ toolCallIds.set(bIndex, toolId)
223
+ yield { type: 'block-start', index: bIndex, blockType: 'tool-call' }
220
224
  yield {
221
225
  type: 'tool-call-delta',
222
- index: json.index || 0,
223
- id: block.id,
226
+ index: bIndex,
227
+ id: toolId,
224
228
  name: block.name,
225
229
  argumentsDelta: '',
226
230
  }
@@ -228,16 +232,25 @@ export async function* anthropicStream(body) {
228
232
  } else if (json.type === 'content_block_delta') {
229
233
  const delta = json.delta || {}
230
234
  if (delta.type === 'text_delta' && delta.text) {
231
- yield { type: 'text-delta', index: json.index || textIndex, text: delta.text }
235
+ yield { type: 'text-delta', index: json.index ?? textIndex, text: delta.text }
232
236
  } else if (delta.type === 'input_json_delta' && delta.partial_json) {
237
+ const bIndex = json.index ?? 0
238
+ const toolId = toolCallIds.get(bIndex) || `toolu_${bIndex}`
233
239
  yield {
234
240
  type: 'tool-call-delta',
235
- index: json.index || 0,
241
+ index: bIndex,
242
+ id: toolId,
236
243
  argumentsDelta: delta.partial_json,
237
244
  }
238
245
  }
239
246
  } else if (json.type === 'message_delta' && json.usage) {
240
- yield { type: 'usage', usage: json.usage }
247
+ yield {
248
+ type: 'usage',
249
+ usage: {
250
+ input: Number(json.usage.input_tokens ?? json.usage.input ?? 0) || 0,
251
+ output: Number(json.usage.output_tokens ?? json.usage.output ?? 0) || 0,
252
+ },
253
+ }
241
254
  }
242
255
  }
243
256
  yield { type: 'finish', reason: { kind: 'stop' } }
@@ -246,6 +259,8 @@ export async function* anthropicStream(body) {
246
259
  export async function* googleStream(body) {
247
260
  let index = 0
248
261
  let started = false
262
+ let callCount = 0
263
+ const functionCallIndexes = new Map()
249
264
  for await (const data of iterateSse(body)) {
250
265
  const json = jsonSse(data)
251
266
  const wrapped = json && json.response ? json.response : json
@@ -261,10 +276,23 @@ export async function* googleStream(body) {
261
276
  yield { type: 'text-delta', index, text: part.text }
262
277
  }
263
278
  if (part.functionCall) {
264
- yield { type: 'block-start', index: index + 1, blockType: 'tool-call' }
279
+ const rawId = part.functionCall.id
280
+ let callIndex
281
+ let callId
282
+ if (rawId && functionCallIndexes.has(rawId)) {
283
+ callIndex = functionCallIndexes.get(rawId)
284
+ callId = String(rawId)
285
+ } else {
286
+ callCount++
287
+ callIndex = index + callCount
288
+ callId = String(rawId || `gemini_call_${callCount}`)
289
+ if (rawId) functionCallIndexes.set(rawId, callIndex)
290
+ yield { type: 'block-start', index: callIndex, blockType: 'tool-call' }
291
+ }
265
292
  yield {
266
293
  type: 'tool-call-delta',
267
- index: index + 1,
294
+ index: callIndex,
295
+ id: callId,
268
296
  name: part.functionCall.name,
269
297
  argumentsDelta: JSON.stringify(part.functionCall.args || {}),
270
298
  }
@@ -274,8 +302,8 @@ export async function* googleStream(body) {
274
302
  yield {
275
303
  type: 'usage',
276
304
  usage: {
277
- input: wrapped.usageMetadata.promptTokenCount,
278
- output: wrapped.usageMetadata.candidatesTokenCount,
305
+ input: Number(wrapped.usageMetadata.promptTokenCount ?? 0) || 0,
306
+ output: Number(wrapped.usageMetadata.candidatesTokenCount ?? 0) || 0,
279
307
  },
280
308
  }
281
309
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.25",
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",