creditdaddy 0.8.0
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/LICENSE +21 -0
- package/README.md +224 -0
- package/bin/creditdaddy.js +187 -0
- package/package.json +28 -0
- package/src/accounts.js +143 -0
- package/src/authDevice.js +205 -0
- package/src/checkin.js +200 -0
- package/src/constants.js +79 -0
- package/src/daemon.js +585 -0
- package/src/localDetect.js +147 -0
- package/src/logger.js +27 -0
- package/src/panel.html +1785 -0
- package/src/providers.js +75 -0
- package/src/qoderApp.js +330 -0
- package/src/qoderClient.js +199 -0
- package/src/qoderUmid.js +177 -0
- package/src/store.js +187 -0
- package/src/tenrouter.js +221 -0
- package/src/transfer.js +141 -0
- package/src/usageSync.js +319 -0
- package/src/workbuddyAuth.js +126 -0
- package/src/workbuddyClient.js +257 -0
- package/src/workbuddyLocal.js +133 -0
- package/src/zcodeAuth.js +131 -0
- package/src/zcodeClient.js +495 -0
- package/src/zcodeLocal.js +217 -0
- package/src/zcrypto.js +160 -0
package/src/transfer.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账号导入 / 导出 — 与 10router 的 OAuth 迁移文件互通。
|
|
3
|
+
*
|
|
4
|
+
* 加密信封与 10router src/lib/auth/secureTransfer.js 逐字节一致(10router-oauth-secure-v1):
|
|
5
|
+
* scrypt(N=16384, r=8, p=1, 32B) 由口令派生密钥 → AES-256-GCM;salt/iv/tag/payload 均为标准 base64
|
|
6
|
+
*
|
|
7
|
+
* 解密后的载荷(两边通用):
|
|
8
|
+
* { provider, exportedAt, accounts: [{ provider, name, email, uid, accessToken, refreshToken, expiresAt, providerSpecificData }] }
|
|
9
|
+
* CreditDaddy 另写入 format/version,并保留旧字段 token 以兼容 v0.1.x 的导入。
|
|
10
|
+
*
|
|
11
|
+
* 能导入:CreditDaddy(含更名前的 QoderDaddy)明文/加密导出、10router 加密导出(取 qoder / qoder-cn / codebuddy-cn / codebuddy-intl 账号)、
|
|
12
|
+
* [{name,provider,token}] 数组。10router 的 codebuddy-* 映射为 WorkBuddy。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import { PROVIDERS, productOf } from './constants.js';
|
|
17
|
+
|
|
18
|
+
export const TRANSFER_FORMAT = '10router-oauth-secure-v1';
|
|
19
|
+
export const EXPORT_FORMAT = 'creditdaddy-accounts';
|
|
20
|
+
const OWN_FORMATS = new Set([EXPORT_FORMAT, 'qoderdaddy-accounts']); // 兼容更名前的 QoderDaddy 导出
|
|
21
|
+
const KDF = { alg: 'scrypt', N: 16384, r: 8, p: 1, keyLen: 32 };
|
|
22
|
+
|
|
23
|
+
// 10router 的提供商 ID → 本工具 provider
|
|
24
|
+
const PROVIDER_ALIASES = { 'codebuddy-cn': 'workbuddy', 'codebuddy-intl': 'workbuddy-intl', codebuddy: 'workbuddy' };
|
|
25
|
+
const toProvider = (p) => (PROVIDERS.includes(p) ? p : PROVIDER_ALIASES[p] || null);
|
|
26
|
+
|
|
27
|
+
const b64 = (buf) => Buffer.from(buf).toString('base64');
|
|
28
|
+
const unb64 = (s) => Buffer.from(String(s || ''), 'base64');
|
|
29
|
+
|
|
30
|
+
export class TransferError extends Error {
|
|
31
|
+
constructor(code, message) { super(message); this.code = code; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function deriveKey(password, salt, kdf = KDF) {
|
|
35
|
+
return crypto.scryptSync(String(password), salt, kdf.keyLen || 32, {
|
|
36
|
+
N: kdf.N || KDF.N, r: kdf.r || KDF.r, p: kdf.p || KDF.p, maxmem: 64 * 1024 * 1024,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sealTransfer(payload, password) {
|
|
41
|
+
if (typeof password !== 'string' || password.length < 4) {
|
|
42
|
+
throw new TransferError('PASSPHRASE_TOO_SHORT', '加密口令至少 4 个字符');
|
|
43
|
+
}
|
|
44
|
+
const salt = crypto.randomBytes(16);
|
|
45
|
+
const iv = crypto.randomBytes(12);
|
|
46
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(password, salt), iv);
|
|
47
|
+
const ct = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(payload), 'utf8')), cipher.final()]);
|
|
48
|
+
return {
|
|
49
|
+
format: TRANSFER_FORMAT,
|
|
50
|
+
kdf: { ...KDF, salt: b64(salt) },
|
|
51
|
+
cipher: 'aes-256-gcm',
|
|
52
|
+
iv: b64(iv),
|
|
53
|
+
tag: b64(cipher.getAuthTag()),
|
|
54
|
+
payload: b64(ct),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function openTransfer(blob, password) {
|
|
59
|
+
if (blob?.format !== TRANSFER_FORMAT) throw new TransferError('UNSUPPORTED_FORMAT', '不是加密迁移文件');
|
|
60
|
+
if (!password) throw new TransferError('NEED_PASSWORD', '该文件已加密,请输入导出时设置的口令');
|
|
61
|
+
try {
|
|
62
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(password, unb64(blob.kdf?.salt), blob.kdf), unb64(blob.iv));
|
|
63
|
+
decipher.setAuthTag(unb64(blob.tag));
|
|
64
|
+
const plain = Buffer.concat([decipher.update(unb64(blob.payload)), decipher.final()]);
|
|
65
|
+
return JSON.parse(plain.toString('utf8'));
|
|
66
|
+
} catch {
|
|
67
|
+
throw new TransferError('WRONG_PASSWORD', '口令错误或文件已损坏');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 构造导出载荷(10router 兼容字段 + CreditDaddy 旧字段) */
|
|
72
|
+
export function buildExportPayload(accounts, { provider, product } = {}) {
|
|
73
|
+
const list = accounts.filter((a) => (!provider || a.provider === provider) && (!product || productOf(a.provider) === product));
|
|
74
|
+
const providers = [...new Set(list.map((a) => a.provider))];
|
|
75
|
+
return {
|
|
76
|
+
format: EXPORT_FORMAT,
|
|
77
|
+
version: 2,
|
|
78
|
+
provider: providers.length === 1 ? providers[0] : (provider || null),
|
|
79
|
+
exportedAt: new Date().toISOString(),
|
|
80
|
+
accounts: list.map((a) => ({
|
|
81
|
+
provider: a.provider,
|
|
82
|
+
name: a.name || a.email || null,
|
|
83
|
+
email: a.email || null,
|
|
84
|
+
uid: a.uid || null,
|
|
85
|
+
accessToken: a.token,
|
|
86
|
+
token: a.token,
|
|
87
|
+
refreshToken: a.refreshToken || null,
|
|
88
|
+
expiresAt: a.expiresAt || null,
|
|
89
|
+
createdAt: a.createdAt,
|
|
90
|
+
providerSpecificData: { authMethod: a.token.startsWith('pt-') ? 'pat' : 'device', userId: a.uid || null },
|
|
91
|
+
...(a.meta && Object.keys(a.meta).length ? { meta: a.meta } : {}),
|
|
92
|
+
})),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 导出:给口令则输出 10router 兼容的加密信封,否则输出明文载荷 */
|
|
97
|
+
export function exportAccounts(accounts, { password, provider, product } = {}) {
|
|
98
|
+
const payload = buildExportPayload(accounts, { provider, product });
|
|
99
|
+
return password ? sealTransfer(payload, password) : payload;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 解析任意支持的导入文件,统一成 normalizeAccountInput 可接受的记录。
|
|
104
|
+
* @returns {{ accounts: Array, skipped: number, source: string }}
|
|
105
|
+
*/
|
|
106
|
+
export function parseImport(data, { password } = {}) {
|
|
107
|
+
let source = 'creditdaddy';
|
|
108
|
+
let payload = data;
|
|
109
|
+
if (data?.format === TRANSFER_FORMAT) {
|
|
110
|
+
payload = openTransfer(data, password);
|
|
111
|
+
source = OWN_FORMATS.has(payload?.format) ? 'creditdaddy' : '10router';
|
|
112
|
+
}
|
|
113
|
+
const list = Array.isArray(payload) ? payload : payload?.accounts;
|
|
114
|
+
if (!Array.isArray(list)) {
|
|
115
|
+
throw new TransferError('BAD_FORMAT', '无法识别的文件:应为 CreditDaddy 或 10router 导出的 JSON');
|
|
116
|
+
}
|
|
117
|
+
const fallbackProvider = toProvider(payload?.provider);
|
|
118
|
+
|
|
119
|
+
const accounts = [];
|
|
120
|
+
let skipped = 0;
|
|
121
|
+
for (const item of list) {
|
|
122
|
+
// 显式标注了其他提供商(如 10router 的 cursor 账号)→ 跳过;未标注才用文件级 provider
|
|
123
|
+
const provider = item?.provider ? toProvider(item.provider) : fallbackProvider;
|
|
124
|
+
const token = item?.accessToken || item?.access_token || item?.token || item?.apiKey;
|
|
125
|
+
if (!provider || typeof token !== 'string' || !token.trim()) { skipped++; continue; }
|
|
126
|
+
const psd = item.providerSpecificData || {};
|
|
127
|
+
const email = typeof item.email === 'string' && item.email.includes('@') && !/^qoder(-cn)?-user-/.test(item.email) ? item.email : null;
|
|
128
|
+
accounts.push({
|
|
129
|
+
provider,
|
|
130
|
+
token,
|
|
131
|
+
name: item.name || item.nickname || email || null,
|
|
132
|
+
email,
|
|
133
|
+
uid: item.uid || psd.userId || null,
|
|
134
|
+
refreshToken: item.refreshToken || item.refresh_token || null,
|
|
135
|
+
expiresAt: item.expiresAt ?? (item.expiresIn ? Date.now() + Number(item.expiresIn) * 1000 : null),
|
|
136
|
+
source: source === '10router' ? '10router' : 'import',
|
|
137
|
+
...(item.meta && typeof item.meta === 'object' ? { meta: item.meta } : {}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return { accounts, skipped, source };
|
|
141
|
+
}
|
package/src/usageSync.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本机 AI 工具用量 → 10Router 用量统计(与 10router-sync 插件 scripts/export-usage.mjs 同一口径)。
|
|
3
|
+
*
|
|
4
|
+
* 来源:
|
|
5
|
+
* zcode ~/.zcode/cli/db/db.sqlite 的 model_usage(只导出官方渠道 builtin: / account:,
|
|
6
|
+
* 自定义 / 网关渠道的流量已由 10Router 自身记账,导出会重复计数)
|
|
7
|
+
* opencode ~/.local/share/opencode/opencode.db 的 session
|
|
8
|
+
* mirasim ~/.mirasim/insights/usage-YYYY-MM.ndjson(跳过经 10Router 中转的调用)
|
|
9
|
+
* mimo ~/.local/share/mimocode/mimocode.db 的 message(assistant 轮次)
|
|
10
|
+
* 行结构与插件一致(provider 前缀 zcode- / opencode- / mirasim- / mimo-,cost 记 0 或源值),
|
|
11
|
+
* 10Router 按行签名去重,重复同步不会产生重复数据。
|
|
12
|
+
*
|
|
13
|
+
* 增量:每个来源记住已同步到的最大时间戳,下一轮只发这之后(回退 2 天重叠兜底迟到行)的行;
|
|
14
|
+
* 首次同步发送全部历史。SQLite 用 Node 内置 node:sqlite(Node 22.5+ / 桌面版 Electron 37 均可用),
|
|
15
|
+
* 读取前先把数据库连同 -wal/-shm 复制到临时目录,绝不触碰客户端正在写的原文件。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
export const SOURCES = ['zcode', 'opencode', 'mirasim', 'mimo'];
|
|
23
|
+
export const SOURCE_LABEL = { zcode: 'ZCode', opencode: 'OpenCode', mirasim: 'mirasim', mimo: '小米 MiMo' };
|
|
24
|
+
const OVERLAP_MS = 2 * 86400e3;
|
|
25
|
+
|
|
26
|
+
let sqliteMod;
|
|
27
|
+
/** node:sqlite 是否可用(Node 22.5+);不可用时 SQLite 来源跳过,mirasim(ndjson)仍可同步 */
|
|
28
|
+
export async function sqliteAvailable() {
|
|
29
|
+
if (sqliteMod === undefined) {
|
|
30
|
+
try {
|
|
31
|
+
// 屏蔽 “SQLite is an experimental feature” 警告,避免刷进日志
|
|
32
|
+
const emit = process.emitWarning;
|
|
33
|
+
process.emitWarning = (w, ...rest) => (String(w).includes('SQLite') ? undefined : emit.call(process, w, ...rest));
|
|
34
|
+
try { sqliteMod = await import('node:sqlite'); } finally { process.emitWarning = emit; }
|
|
35
|
+
} catch { sqliteMod = null; }
|
|
36
|
+
}
|
|
37
|
+
return Boolean(sqliteMod);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── 路径 ──
|
|
41
|
+
|
|
42
|
+
export function sourcePaths(home = os.homedir(), env = process.env, platform = process.platform) {
|
|
43
|
+
const local = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
44
|
+
const zcode = [path.join(home, '.zcode', 'cli', 'db', 'db.sqlite')];
|
|
45
|
+
try {
|
|
46
|
+
const projects = path.join(home, '.zcode', 'projects');
|
|
47
|
+
for (const name of fs.readdirSync(projects)) zcode.push(path.join(projects, name, 'db.sqlite'));
|
|
48
|
+
} catch {}
|
|
49
|
+
const opencode = [path.join(home, '.local', 'share', 'opencode', 'opencode.db')];
|
|
50
|
+
if (platform === 'win32') opencode.push(path.join(local, 'opencode', 'opencode.db'));
|
|
51
|
+
const mimo = [path.join(home, '.local', 'share', 'mimocode', 'mimocode.db')];
|
|
52
|
+
if (platform === 'win32') {
|
|
53
|
+
mimo.push(path.join(home, 'AppData', 'Roaming', 'Xiaomi MiMo', 'mimocode.db'));
|
|
54
|
+
mimo.push(path.join(home, 'AppData', 'Roaming', 'Xiaomi MiMo', 'mimocode', 'mimocode.db'));
|
|
55
|
+
mimo.push(path.join(local, 'mimocode', 'mimocode.db'));
|
|
56
|
+
}
|
|
57
|
+
const exist = (list) => list.filter((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } });
|
|
58
|
+
let mirasim = [];
|
|
59
|
+
const insights = path.join(home, '.mirasim', 'insights');
|
|
60
|
+
try {
|
|
61
|
+
mirasim = fs.readdirSync(insights).filter((f) => /^usage-\d{4}-\d{2}\.ndjson$/.test(f)).sort().map((f) => path.join(insights, f));
|
|
62
|
+
} catch {}
|
|
63
|
+
return { zcode: exist(zcode), opencode: exist(opencode), mirasim, mimo: exist(mimo) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 本机检测到哪些来源 */
|
|
67
|
+
export async function detectSources() {
|
|
68
|
+
const p = sourcePaths();
|
|
69
|
+
const sqlite = await sqliteAvailable();
|
|
70
|
+
return SOURCES.map((id) => ({
|
|
71
|
+
id, label: SOURCE_LABEL[id],
|
|
72
|
+
found: p[id].length > 0,
|
|
73
|
+
files: p[id].length,
|
|
74
|
+
supported: id === 'mirasim' || sqlite,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── 行转换(与插件一致) ──
|
|
79
|
+
|
|
80
|
+
const OFFICIAL_PREFIXES = ['builtin:', 'account:'];
|
|
81
|
+
const isOfficialProvider = (id) => OFFICIAL_PREFIXES.some((p) => String(id || '').startsWith(p));
|
|
82
|
+
const statusTo10r = (s) => (s === 'completed' ? 'ok' : 'error'); // cancelled 也消耗了 token,记 error
|
|
83
|
+
|
|
84
|
+
export function convertZcodeRow(row) {
|
|
85
|
+
const tokens = {
|
|
86
|
+
prompt_tokens: row.input_tokens || 0,
|
|
87
|
+
completion_tokens: row.output_tokens || 0,
|
|
88
|
+
...(row.reasoning_tokens ? { reasoning_tokens: row.reasoning_tokens } : {}),
|
|
89
|
+
...(row.cache_creation_input_tokens ? { cache_creation_input_tokens: row.cache_creation_input_tokens } : {}),
|
|
90
|
+
...(row.cache_read_input_tokens ? { cache_read_input_tokens: row.cache_read_input_tokens } : {}),
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
timestamp: new Date(row.started_at || row.completed_at || Date.now()).toISOString(),
|
|
94
|
+
provider: 'zcode-' + String(row.provider_id || 'unknown').replace(/^(builtin:|account:)/, ''),
|
|
95
|
+
model: row.model_id || 'unknown',
|
|
96
|
+
connectionId: null, apiKey: null,
|
|
97
|
+
endpoint: 'zcode://' + (row.agent || 'session'),
|
|
98
|
+
cost: 0, // 官方渠道是订阅套餐,不是按量计费
|
|
99
|
+
status: statusTo10r(row.status),
|
|
100
|
+
tokens,
|
|
101
|
+
meta: {
|
|
102
|
+
source: 'zcode', zcodeProviderId: row.provider_id || null, agent: row.agent || null,
|
|
103
|
+
sessionId: row.session_id || null, durationMs: row.duration_ms ?? null, planUsage: true,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function convertOpencodeSession(s) {
|
|
109
|
+
let modelId = 'unknown', providerID = 'opencode';
|
|
110
|
+
if (s.model) {
|
|
111
|
+
try {
|
|
112
|
+
const m = typeof s.model === 'string' ? JSON.parse(s.model) : s.model;
|
|
113
|
+
modelId = m.id || 'unknown';
|
|
114
|
+
providerID = m.providerID || 'opencode';
|
|
115
|
+
} catch { modelId = String(s.model); }
|
|
116
|
+
}
|
|
117
|
+
const tokens = { prompt_tokens: s.tokens_input || 0, completion_tokens: s.tokens_output || 0 };
|
|
118
|
+
if (s.tokens_cache_read) tokens.cache_read_input_tokens = s.tokens_cache_read;
|
|
119
|
+
return {
|
|
120
|
+
timestamp: new Date(s.time_created).toISOString(),
|
|
121
|
+
provider: 'opencode-' + providerID,
|
|
122
|
+
model: modelId,
|
|
123
|
+
connectionId: null, apiKey: null,
|
|
124
|
+
endpoint: 'opencode://desktop',
|
|
125
|
+
cost: s.cost || 0,
|
|
126
|
+
status: 'ok',
|
|
127
|
+
tokens,
|
|
128
|
+
meta: {
|
|
129
|
+
source: 'opencode', opencodeSessionId: s.id || null, title: s.title || null, agent: s.agent || null,
|
|
130
|
+
reasoning_tokens: s.tokens_reasoning || 0, cache_write_tokens: s.tokens_cache_write || 0,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function convertMimoMessage(row, d) {
|
|
136
|
+
const t = d.tokens || {};
|
|
137
|
+
const tokens = {
|
|
138
|
+
prompt_tokens: t.input || 0,
|
|
139
|
+
completion_tokens: t.output || 0,
|
|
140
|
+
...(t.reasoning ? { reasoning_tokens: t.reasoning } : {}),
|
|
141
|
+
...(t.cache?.read ? { cache_read_input_tokens: t.cache.read } : {}),
|
|
142
|
+
...(t.cache?.write ? { cache_creation_input_tokens: t.cache.write } : {}),
|
|
143
|
+
};
|
|
144
|
+
return {
|
|
145
|
+
timestamp: new Date(d.time?.completed || d.time?.created || row.time_created || Date.now()).toISOString(),
|
|
146
|
+
provider: 'mimo-' + (d.providerID || 'mimo'),
|
|
147
|
+
model: d.modelID || 'unknown',
|
|
148
|
+
connectionId: null, apiKey: null,
|
|
149
|
+
endpoint: 'mimo://' + (d.agent || 'desktop'),
|
|
150
|
+
cost: d.cost || 0,
|
|
151
|
+
status: 'ok',
|
|
152
|
+
tokens,
|
|
153
|
+
meta: { source: 'mimo', mimoMessageId: row.id || null, sessionId: row.session_id || null, agent: d.agent || null, mode: d.mode || null, planUsage: true },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** mirasim 调用若经本机 / 局域网的 10Router 中转,10Router 自己已记过账,再导入会重复 */
|
|
158
|
+
export function isSelfHostedUpstream(upstreamHost, endpointUrl) {
|
|
159
|
+
if (!upstreamHost) return false;
|
|
160
|
+
let host = upstreamHost, port = null;
|
|
161
|
+
try {
|
|
162
|
+
const u = new URL(upstreamHost.includes('://') ? upstreamHost : `http://${upstreamHost}`);
|
|
163
|
+
host = u.hostname;
|
|
164
|
+
port = u.port ? Number(u.port) : 80;
|
|
165
|
+
} catch {}
|
|
166
|
+
const isPrivate = host === 'localhost' || /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host);
|
|
167
|
+
if (!isPrivate) return false;
|
|
168
|
+
if (endpointUrl) {
|
|
169
|
+
try { if (new URL(endpointUrl).hostname === host) return true; } catch {}
|
|
170
|
+
}
|
|
171
|
+
return port === 20127 || port === 20128 || port === 80 || port === 443;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function convertMirasimRow(e) {
|
|
175
|
+
// mirasim 的 input 只算净新增(缓存分列),真实输入 = input + cacheRead + cacheWrite
|
|
176
|
+
const tokens = { prompt_tokens: (e.input || 0) + (e.cacheRead || 0) + (e.cacheWrite || 0), completion_tokens: e.output || 0 };
|
|
177
|
+
if (e.cacheRead) tokens.cache_read_input_tokens = e.cacheRead;
|
|
178
|
+
if (e.cacheWrite) tokens.cache_creation_input_tokens = e.cacheWrite;
|
|
179
|
+
if (e.reasoning) tokens.reasoning_tokens = e.reasoning;
|
|
180
|
+
const ok = (e.status || 0) >= 200 && (e.status || 0) < 400;
|
|
181
|
+
return {
|
|
182
|
+
timestamp: e.ts,
|
|
183
|
+
provider: 'mirasim-' + String(e.provider || 'unknown'),
|
|
184
|
+
model: e.model || 'unknown',
|
|
185
|
+
connectionId: null, apiKey: null,
|
|
186
|
+
endpoint: 'mirasim://' + (e.agent || e.leg || 'relay'),
|
|
187
|
+
cost: 0,
|
|
188
|
+
status: ok ? 'ok' : 'error',
|
|
189
|
+
tokens,
|
|
190
|
+
meta: {
|
|
191
|
+
source: 'mirasim', mirasimCallId: e.id || null, relayCallId: e.relayCallId || null, agent: e.agent || null,
|
|
192
|
+
leg: e.leg || null, viaRelay: e.viaRelay ?? null, upstreamHost: e.upstreamHost || null, effort: e.effort || null,
|
|
193
|
+
httpStatus: e.status ?? null, durationMs: e.durationMs ?? null, repo: e.repo || null, workspace: e.workspace || null,
|
|
194
|
+
planUsage: true,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── 读取 ──
|
|
200
|
+
|
|
201
|
+
function withSnapshot(dbPath, fn) {
|
|
202
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'creditdaddy-usage-'));
|
|
203
|
+
try {
|
|
204
|
+
const dst = path.join(tmp, 'db.sqlite');
|
|
205
|
+
fs.copyFileSync(dbPath, dst);
|
|
206
|
+
for (const sfx of ['-wal', '-shm']) if (fs.existsSync(dbPath + sfx)) fs.copyFileSync(dbPath + sfx, dst + sfx);
|
|
207
|
+
const db = new sqliteMod.DatabaseSync(dst, { readOnly: true });
|
|
208
|
+
try { return fn(db); } finally { db.close(); }
|
|
209
|
+
} finally {
|
|
210
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function collectZcode(files) {
|
|
215
|
+
const out = [];
|
|
216
|
+
const seen = new Set();
|
|
217
|
+
let skippedCustom = 0;
|
|
218
|
+
for (const f of files) {
|
|
219
|
+
withSnapshot(f, (db) => {
|
|
220
|
+
let rows;
|
|
221
|
+
try {
|
|
222
|
+
rows = db.prepare('SELECT logical_request_id, provider_id, model_id, agent, status, started_at, completed_at, duration_ms, input_tokens, output_tokens, reasoning_tokens, cache_creation_input_tokens, cache_read_input_tokens FROM model_usage ORDER BY started_at ASC').all();
|
|
223
|
+
} catch {
|
|
224
|
+
rows = db.prepare('SELECT logical_request_id, provider_id, model_id, agent, status, started_at, completed_at, duration_ms, input_tokens, output_tokens FROM model_usage ORDER BY started_at ASC').all();
|
|
225
|
+
}
|
|
226
|
+
for (const r of rows) {
|
|
227
|
+
if (!isOfficialProvider(r.provider_id)) { skippedCustom++; continue; }
|
|
228
|
+
if (r.provider_id && r.logical_request_id) {
|
|
229
|
+
const k = `${r.provider_id}|${r.logical_request_id}`;
|
|
230
|
+
if (seen.has(k)) continue;
|
|
231
|
+
seen.add(k);
|
|
232
|
+
}
|
|
233
|
+
out.push(convertZcodeRow(r));
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return { entries: out, notes: skippedCustom ? [`跳过 ${skippedCustom} 行自定义 / 网关渠道(已在别处记账)`] : [] };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// OpenCode 一个会话一行(会话级累计),会话还在继续时 token 会变 → 签名变 → 10Router 去重拦不住,
|
|
241
|
+
// 同一会话会被记成两行。所以最近 OPENCODE_SETTLE_MS 内仍在更新的会话先不同步,等它停下来再同步。
|
|
242
|
+
const OPENCODE_SETTLE_MS = 60 * 60 * 1000;
|
|
243
|
+
function collectOpencode(files, now = Date.now()) {
|
|
244
|
+
const out = [];
|
|
245
|
+
let active = 0;
|
|
246
|
+
for (const f of files) {
|
|
247
|
+
withSnapshot(f, (db) => {
|
|
248
|
+
for (const r of db.prepare('SELECT id, title, model, agent, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, time_created, time_updated FROM session ORDER BY time_created ASC').all()) {
|
|
249
|
+
if (Number.isFinite(r.time_updated) && now - r.time_updated < OPENCODE_SETTLE_MS) { active++; continue; }
|
|
250
|
+
out.push(convertOpencodeSession(r));
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return { entries: out, notes: active ? [`暂不同步 ${active} 个进行中的会话(1 小时内仍有更新,结束后再同步,避免同一会话记两行)`] : [] };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function collectMimo(files) {
|
|
258
|
+
const out = [];
|
|
259
|
+
const seen = new Set();
|
|
260
|
+
for (const f of files) {
|
|
261
|
+
withSnapshot(f, (db) => {
|
|
262
|
+
for (const r of db.prepare('SELECT id, session_id, time_created, data FROM message ORDER BY time_created ASC').all()) {
|
|
263
|
+
let d;
|
|
264
|
+
try { d = JSON.parse(r.data); } catch { continue; }
|
|
265
|
+
if (d.role !== 'assistant') continue;
|
|
266
|
+
if (r.id) { if (seen.has(r.id)) continue; seen.add(r.id); }
|
|
267
|
+
const t = d.tokens || {};
|
|
268
|
+
if (!((t.input || 0) + (t.output || 0) + (t.reasoning || 0) + (t.cache?.read || 0) + (t.cache?.write || 0))) continue;
|
|
269
|
+
out.push(convertMimoMessage(r, d));
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return { entries: out, notes: [] };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function collectMirasim(files, endpoint) {
|
|
277
|
+
const out = [];
|
|
278
|
+
const seen = new Set();
|
|
279
|
+
let selfHosted = 0;
|
|
280
|
+
for (const f of files) {
|
|
281
|
+
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
|
|
282
|
+
if (!line.trim()) continue;
|
|
283
|
+
let e;
|
|
284
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
285
|
+
if (!e || typeof e !== 'object') continue;
|
|
286
|
+
if (e.id) { if (seen.has(e.id)) continue; seen.add(e.id); }
|
|
287
|
+
if (isSelfHostedUpstream(e.upstreamHost, endpoint)) { selfHosted++; continue; }
|
|
288
|
+
if (!((e.input || 0) + (e.output || 0) + (e.cacheRead || 0) + (e.cacheWrite || 0))) continue;
|
|
289
|
+
out.push(convertMirasimRow(e));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { entries: out, notes: selfHosted ? [`跳过 ${selfHosted} 行经 10Router 中转的调用(已在 10Router 记账)`] : [] };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 读取某来源的全部行:{ entries, notes, files };来源不存在返回 entries=[] */
|
|
296
|
+
export async function collectSource(id, { endpoint, paths = sourcePaths() } = {}) {
|
|
297
|
+
const files = paths[id] || [];
|
|
298
|
+
if (!files.length) return { entries: [], notes: [], files: 0 };
|
|
299
|
+
if (id !== 'mirasim' && !(await sqliteAvailable())) throw new Error('需要 Node 22.5+(node:sqlite)才能读取 ' + SOURCE_LABEL[id] + ' 的数据库');
|
|
300
|
+
const r = id === 'zcode' ? collectZcode(files)
|
|
301
|
+
: id === 'opencode' ? collectOpencode(files)
|
|
302
|
+
: id === 'mimo' ? collectMimo(files)
|
|
303
|
+
: collectMirasim(files, endpoint);
|
|
304
|
+
return { ...r, files: files.length };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** 增量筛选:只保留 watermark - 重叠 之后的行;返回 { selected, maxTs } */
|
|
308
|
+
export function selectSince(entries, watermarkIso) {
|
|
309
|
+
const since = watermarkIso ? new Date(watermarkIso).getTime() - OVERLAP_MS : -Infinity;
|
|
310
|
+
let maxTs = watermarkIso || null;
|
|
311
|
+
const selected = [];
|
|
312
|
+
for (const e of entries) {
|
|
313
|
+
const t = new Date(e.timestamp).getTime();
|
|
314
|
+
if (!Number.isFinite(t)) continue;
|
|
315
|
+
if (t >= since) selected.push(e);
|
|
316
|
+
if (!maxTs || t > new Date(maxTs).getTime()) maxTs = new Date(t).toISOString();
|
|
317
|
+
}
|
|
318
|
+
return { selected, maxTs };
|
|
319
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy / CodeBuddy 浏览器登录 — 与 WorkBuddy 桌面端(platform=WorkBuddy)同一流程:
|
|
3
|
+
*
|
|
4
|
+
* 1. POST {host}/v2/plugin/auth/state?platform=WorkBuddy → {state, authUrl}
|
|
5
|
+
* 2. 用户在浏览器(桌面版为隐私窗口)打开 authUrl 登录
|
|
6
|
+
* 3. GET {host}/v2/plugin/auth/token?state=… code 11217 = 等待;成功返回 accessToken 等
|
|
7
|
+
* 4. GET {host}/v2/plugin/login/account?state=…(Bearer) code 12151 = 等待;返回当前账号
|
|
8
|
+
* 5. GET {host}/v2/plugin/accounts(Bearer) 账号列表(best-effort)
|
|
9
|
+
*
|
|
10
|
+
* 3~5 组装成与 workbuddy-desktop.info 相同结构的会话,经 sessionToAccount 入库,
|
|
11
|
+
* 因此浏览器登录的账号与「本机导入」一样可签到、查积分、一键切换 WorkBuddy 客户端。
|
|
12
|
+
* 国内版走 www.codebuddy.cn(WorkBuddy 客户端默认域名),国际版走 www.codebuddy.ai。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
16
|
+
import { sessionToAccount } from './workbuddyLocal.js';
|
|
17
|
+
|
|
18
|
+
const HOSTS = { workbuddy: 'www.codebuddy.cn', 'workbuddy-intl': 'www.codebuddy.ai' };
|
|
19
|
+
const PLATFORM = 'WorkBuddy';
|
|
20
|
+
const USER_AGENT = 'CLI/2.108.1 CodeBuddy/2.108.1';
|
|
21
|
+
const RETRY_FETCH_TOKEN = 11217;
|
|
22
|
+
const RETRY_FETCH_ACCOUNT = 12151;
|
|
23
|
+
const ACCOUNT_MAX_ATTEMPTS = 8; // 拿到 token 后账号信息迟迟不就绪时,退化为仅 token 入库
|
|
24
|
+
|
|
25
|
+
function headers(host, extra = {}) {
|
|
26
|
+
return {
|
|
27
|
+
Accept: 'application/json',
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
'User-Agent': USER_AGENT,
|
|
30
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
31
|
+
'X-Domain': host,
|
|
32
|
+
'X-Product': 'SaaS',
|
|
33
|
+
...extra,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const ANON = { 'X-No-Authorization': 'true', 'X-No-User-Id': 'true', 'X-No-Enterprise-Id': 'true', 'X-No-Department-Info': 'true' };
|
|
37
|
+
|
|
38
|
+
async function fetchJson(url, init = {}) {
|
|
39
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
let body = null;
|
|
42
|
+
try { body = text ? JSON.parse(text) : null; } catch {}
|
|
43
|
+
return { res, body };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function startWorkbuddyLogin(provider) {
|
|
47
|
+
const host = HOSTS[provider] || HOSTS.workbuddy;
|
|
48
|
+
const { res, body } = await fetchJson(`https://${host}/v2/plugin/auth/state?platform=${PLATFORM}`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: headers(host, { 'X-No-Authorization': 'true', 'X-No-User-Id': 'true' }),
|
|
51
|
+
body: '{}',
|
|
52
|
+
});
|
|
53
|
+
const d = body?.data;
|
|
54
|
+
if (!res.ok || body?.code !== 0 || !d?.state || !d?.authUrl) {
|
|
55
|
+
throw new Error('WorkBuddy 登录初始化失败:' + (body?.msg || 'HTTP ' + res.status));
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
url: d.authUrl,
|
|
59
|
+
expiresInMs: 10 * 60e3,
|
|
60
|
+
intervalMs: 3000,
|
|
61
|
+
data: { provider, host, state: d.state, token: null, accountAttempts: 0 },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 单次轮询(按阶段推进,状态存在 data 上):{status:'pending'} | {status:'ok', input};失败抛错 */
|
|
66
|
+
export async function pollWorkbuddyLogin(data) {
|
|
67
|
+
const { host, state } = data;
|
|
68
|
+
if (!data.token) {
|
|
69
|
+
const { res, body } = await fetchJson(`https://${host}/v2/plugin/auth/token?state=${encodeURIComponent(state)}`, {
|
|
70
|
+
headers: headers(host, ANON),
|
|
71
|
+
});
|
|
72
|
+
if (body?.code === RETRY_FETCH_TOKEN) return { status: 'pending' };
|
|
73
|
+
if (res.status >= 500 || res.status === 429) return { status: 'pending' };
|
|
74
|
+
if (!res.ok || body?.code !== 0 || !body?.data?.accessToken) {
|
|
75
|
+
throw new Error('WorkBuddy 授权失败:' + (body?.msg || 'HTTP ' + res.status));
|
|
76
|
+
}
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const t = body.data;
|
|
79
|
+
data.token = {
|
|
80
|
+
...t,
|
|
81
|
+
domain: t.domain || host,
|
|
82
|
+
lastRefreshTime: now,
|
|
83
|
+
...(Number.isFinite(t.expiresIn) ? { expiresAt: now + t.expiresIn * 1000 } : {}),
|
|
84
|
+
...(Number.isFinite(t.refreshExpiresIn) ? { refreshExpiresAt: now + t.refreshExpiresIn * 1000 } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const auth = { Authorization: `Bearer ${data.token.accessToken}` };
|
|
89
|
+
let account = null;
|
|
90
|
+
data.accountAttempts++;
|
|
91
|
+
try {
|
|
92
|
+
const { res, body } = await fetchJson(`https://${host}/v2/plugin/login/account?state=${encodeURIComponent(state)}`, {
|
|
93
|
+
headers: headers(host, { ...auth, 'X-No-User-Id': 'true', 'X-No-Enterprise-Id': 'true', 'X-No-Department-Info': 'true' }),
|
|
94
|
+
});
|
|
95
|
+
if (res.ok && body?.code === 0 && body.data && typeof body.data === 'object') account = body.data;
|
|
96
|
+
else if (data.accountAttempts < ACCOUNT_MAX_ATTEMPTS
|
|
97
|
+
&& (body?.code === RETRY_FETCH_ACCOUNT || res.status === 401 || res.status === 403 || res.status >= 500)) {
|
|
98
|
+
return { status: 'pending' };
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
if (data.accountAttempts < ACCOUNT_MAX_ATTEMPTS) return { status: 'pending' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let list = [];
|
|
105
|
+
if (account) {
|
|
106
|
+
try {
|
|
107
|
+
const { res, body } = await fetchJson(`https://${host}/v2/plugin/accounts`, { headers: headers(host, auth) });
|
|
108
|
+
if (res.ok && Array.isArray(body?.data?.accounts)) list = body.data.accounts;
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 与 WorkBuddy 客户端的会话文件同构;拿不到账号信息时只保留 token(可签到 / 查积分,不能切换客户端)
|
|
113
|
+
const session = account
|
|
114
|
+
? {
|
|
115
|
+
account: { ...(list.find((a) => a.uid === account.uid) || {}), ...account, lastLogin: true },
|
|
116
|
+
accounts: list.filter((a) => a.pluginEnabled),
|
|
117
|
+
allAccounts: list,
|
|
118
|
+
auth: data.token,
|
|
119
|
+
}
|
|
120
|
+
: { auth: data.token };
|
|
121
|
+
const input = sessionToAccount(session, '浏览器登录');
|
|
122
|
+
if (!input) throw new Error('WorkBuddy 返回的 token 无法识别(签发方不是 CodeBuddy / WorkBuddy)');
|
|
123
|
+
if (!account) delete input.meta.session;
|
|
124
|
+
input.source = 'browser';
|
|
125
|
+
return { status: 'ok', input };
|
|
126
|
+
}
|