@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 +34 -3
- package/lib/client.js +195 -7
- package/lib/crypto.js +32 -13
- package/lib/gemini-schema.js +8 -1
- package/lib/history.js +104 -0
- package/lib/import-auth.js +34 -3
- package/lib/refs.js +1 -0
- package/lib/rotate.js +8 -0
- package/lib/routes/accounts.js +101 -84
- package/lib/stream-rotate.js +43 -29
- package/lib/usage.js +78 -24
- package/lib/vault.js +3 -3
- package/lib/vendors/copilot.js +8 -0
- package/lib/wire.js +40 -12
- package/package.json +1 -1
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
|
-
|
|
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:
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
4
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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/gemini-schema.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/import-auth.js
CHANGED
|
@@ -169,7 +169,27 @@ async function discoverLocalCliSessionsInner() {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
// 9. GitHub Copilot token
|
|
172
|
-
|
|
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
|
-
|
|
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
|
|
255
|
+
email,
|
|
256
|
+
label: email,
|
|
226
257
|
}
|
|
227
258
|
}
|
|
228
259
|
|
package/lib/refs.js
CHANGED
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
|
}
|
package/lib/routes/accounts.js
CHANGED
|
@@ -349,100 +349,117 @@ export function registerAccountsRoutes(ctx, state) {
|
|
|
349
349
|
}),
|
|
350
350
|
}), 'dsh-subscriptions: /logout')
|
|
351
351
|
|
|
352
|
-
|
|
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:
|
|
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/
|
|
392
|
-
handler:
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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/stream-rotate.js
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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 !==
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 ===
|
|
139
|
+
if (child && typeof child === 'object') walk(child, k, depth + 1)
|
|
102
140
|
}
|
|
103
141
|
}
|
|
104
|
-
walk(obj,
|
|
105
|
-
|
|
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
|
|
package/lib/vendors/copilot.js
CHANGED
|
@@ -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
|
|
217
|
+
textIndex = json.index ?? index++
|
|
217
218
|
yield { type: 'block-start', index: textIndex, blockType: 'text' }
|
|
218
219
|
} else if (block.type === 'tool_use') {
|
|
219
|
-
|
|
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:
|
|
223
|
-
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
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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",
|