creditdaddy 1.2.0 → 1.3.2

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/src/usageSync.js CHANGED
@@ -1,505 +1,505 @@
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
- * catpaw 妙手云端用量(catx 网关 /v1/usage/token/daily,按天 token 总量;
11
- * 无输入/输出拆分,整包记 prompt_tokens;当天桶隔天再入账)
12
- * 行结构与插件一致(provider 前缀 zcode- / opencode- / mirasim- / mimo-,cost 记 0 或源值),
13
- * 10Router 按行内容签名去重——签名 = (时间戳, provider, model, connectionId, apiKeyHash,
14
- * 输入 token, 输出 token),行内容一模一样才会被认作重复;行内容还在变(会话进行中、
15
- * 按天聚合桶增长)就会被当成新行。所以客户端要保证发出去的行已经定型:
16
- * - 可能回填/累计的来源(opencode 会话、mimo 消息)在结算窗 SETTLE_MS 内暂不发送;
17
- * - 妙手按天桶只发差量,桶增长不再整包重发;
18
- * - 时间戳缺失的行直接跳过,绝不用 Date.now() 兜底(每次同步都会变成“新行”)。
19
- *
20
- * 增量:每个来源记住已同步到的最大时间戳,下一轮只发这之后(回退 2 天重叠兜底迟到行)的行;
21
- * 首次同步发送全部历史。SQLite 用 Node 内置 node:sqlite(Node 22.5+ / 桌面版 Electron 37 均可用),
22
- * 读取前先把数据库连同 -wal/-shm 复制到临时目录,绝不触碰客户端正在写的原文件。
23
- */
24
-
25
- import fs from 'node:fs';
26
- import os from 'node:os';
27
- import path from 'node:path';
28
- import { loadAccounts } from './store.js';
29
- import { fetchCatpawTokenDaily } from './catpawClient.js';
30
-
31
- export const SOURCES = ['zcode', 'opencode', 'mirasim', 'mimo', 'catpaw', '10r'];
32
- export const SOURCE_LABEL = { zcode: 'ZCode', opencode: 'OpenCode', mirasim: 'mirasim', mimo: '小米 MiMo', catpaw: '妙手', '10r': '10Router 本机' };
33
- const OVERLAP_MS = 2 * 86400e3;
34
- /** 结算窗:时间戳在此窗口内的行可能还在被来源更新(token 累计、时间字段回填),暂不发送 */
35
- const SETTLE_MS = 60 * 60 * 1000;
36
-
37
- let sqliteMod;
38
- /** node:sqlite 是否可用(Node 22.5+);不可用时 SQLite 来源跳过,mirasim(ndjson)仍可同步 */
39
- export async function sqliteAvailable() {
40
- if (sqliteMod === undefined) {
41
- try {
42
- // 屏蔽 “SQLite is an experimental feature” 警告,避免刷进日志
43
- const emit = process.emitWarning;
44
- process.emitWarning = (w, ...rest) => (String(w).includes('SQLite') ? undefined : emit.call(process, w, ...rest));
45
- try { sqliteMod = await import('node:sqlite'); } finally { process.emitWarning = emit; }
46
- } catch { sqliteMod = null; }
47
- }
48
- return Boolean(sqliteMod);
49
- }
50
-
51
- // ── 路径 ──
52
-
53
- export function sourcePaths(home = os.homedir(), env = process.env, platform = process.platform) {
54
- const local = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
55
- const zcode = [path.join(home, '.zcode', 'cli', 'db', 'db.sqlite')];
56
- try {
57
- const projects = path.join(home, '.zcode', 'projects');
58
- for (const name of fs.readdirSync(projects)) zcode.push(path.join(projects, name, 'db.sqlite'));
59
- } catch {}
60
- const opencode = [path.join(home, '.local', 'share', 'opencode', 'opencode.db')];
61
- if (platform === 'win32') opencode.push(path.join(local, 'opencode', 'opencode.db'));
62
- const mimo = [path.join(home, '.local', 'share', 'mimocode', 'mimocode.db')];
63
- if (platform === 'win32') {
64
- mimo.push(path.join(home, 'AppData', 'Roaming', 'Xiaomi MiMo', 'mimocode.db'));
65
- mimo.push(path.join(home, 'AppData', 'Roaming', 'Xiaomi MiMo', 'mimocode', 'mimocode.db'));
66
- mimo.push(path.join(local, 'mimocode', 'mimocode.db'));
67
- }
68
- // 本机 10Router/9Router 实例的记账库(同 10router-sync 插件 --source 10r 的发现逻辑);
69
- // TENROUTER_DB 可显式指向另一个实例的库
70
- const roaming = env.APPDATA || path.join(home, 'AppData', 'Roaming');
71
- const routerDb = [];
72
- if (env.TENROUTER_DB) routerDb.push(env.TENROUTER_DB);
73
- if (platform === 'win32') {
74
- routerDb.push(path.join(roaming, '10router', 'db', 'data.sqlite'));
75
- routerDb.push(path.join(roaming, '9router', 'db', 'data.sqlite'));
76
- } else {
77
- routerDb.push(path.join(home, '.10router', 'db', 'data.sqlite'));
78
- routerDb.push(path.join(home, '.9router', 'db', 'data.sqlite'));
79
- }
80
- const exist = (list) => list.filter((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } });
81
- let mirasim = [];
82
- const insights = path.join(home, '.mirasim', 'insights');
83
- try {
84
- mirasim = fs.readdirSync(insights).filter((f) => /^usage-\d{4}-\d{2}\.ndjson$/.test(f)).sort().map((f) => path.join(insights, f));
85
- } catch {}
86
- return { zcode: exist(zcode), opencode: exist(opencode), mirasim, mimo: exist(mimo), '10r': exist(routerDb) };
87
- }
88
-
89
- /** 本机检测到哪些来源 */
90
- export async function detectSources() {
91
- const p = sourcePaths();
92
- const sqlite = await sqliteAvailable();
93
- const catpawAccounts = (await loadAccounts()).filter((a) => a.provider === 'catpaw' && a.token).length;
94
- return SOURCES.map((id) => ({
95
- id, label: SOURCE_LABEL[id],
96
- found: id === 'catpaw' ? catpawAccounts > 0 : p[id].length > 0,
97
- files: id === 'catpaw' ? catpawAccounts : p[id].length,
98
- supported: id === 'mirasim' || id === 'catpaw' || sqlite,
99
- }));
100
- }
101
-
102
- // ── 行转换(与插件一致) ──
103
-
104
- const OFFICIAL_PREFIXES = ['builtin:', 'account:'];
105
- const isOfficialProvider = (id) => OFFICIAL_PREFIXES.some((p) => String(id || '').startsWith(p));
106
- const statusTo10r = (s) => (s === 'completed' ? 'ok' : 'error'); // cancelled 也消耗了 token,记 error
107
-
108
- export function convertZcodeRow(row) {
109
- const ms = Number(row.started_at || row.completed_at);
110
- if (!Number.isFinite(ms) || ms <= 0) return null; // 缺时间戳不能用 Date.now() 兜底:每次同步都是一条“新行”
111
- const tokens = {
112
- prompt_tokens: row.input_tokens || 0,
113
- completion_tokens: row.output_tokens || 0,
114
- ...(row.reasoning_tokens ? { reasoning_tokens: row.reasoning_tokens } : {}),
115
- ...(row.cache_creation_input_tokens ? { cache_creation_input_tokens: row.cache_creation_input_tokens } : {}),
116
- ...(row.cache_read_input_tokens ? { cache_read_input_tokens: row.cache_read_input_tokens } : {}),
117
- };
118
- return {
119
- timestamp: new Date(ms).toISOString(),
120
- provider: 'zcode-' + String(row.provider_id || 'unknown').replace(/^(builtin:|account:)/, ''),
121
- model: row.model_id || 'unknown',
122
- connectionId: null, apiKey: null,
123
- endpoint: 'zcode://' + (row.agent || 'session'),
124
- cost: 0, // 官方渠道是订阅套餐,不是按量计费
125
- status: statusTo10r(row.status),
126
- tokens,
127
- meta: {
128
- source: 'zcode', zcodeProviderId: row.provider_id || null, agent: row.agent || null,
129
- sessionId: row.session_id || null, durationMs: row.duration_ms ?? null, planUsage: true,
130
- },
131
- };
132
- }
133
-
134
- export function convertOpencodeSession(s) {
135
- const ms = Number(s.time_created);
136
- if (!Number.isFinite(ms) || ms <= 0) return null;
137
- let modelId = 'unknown', providerID = 'opencode';
138
- if (s.model) {
139
- try {
140
- const m = typeof s.model === 'string' ? JSON.parse(s.model) : s.model;
141
- modelId = m.id || 'unknown';
142
- providerID = m.providerID || 'opencode';
143
- } catch { modelId = String(s.model); }
144
- }
145
- const tokens = { prompt_tokens: s.tokens_input || 0, completion_tokens: s.tokens_output || 0 };
146
- if (s.tokens_cache_read) tokens.cache_read_input_tokens = s.tokens_cache_read;
147
- return {
148
- timestamp: new Date(ms).toISOString(),
149
- provider: 'opencode-' + providerID,
150
- model: modelId,
151
- connectionId: null, apiKey: null,
152
- endpoint: 'opencode://desktop',
153
- cost: s.cost || 0,
154
- status: 'ok',
155
- tokens,
156
- meta: {
157
- source: 'opencode', opencodeSessionId: s.id || null, title: s.title || null, agent: s.agent || null,
158
- reasoning_tokens: s.tokens_reasoning || 0, cache_write_tokens: s.tokens_cache_write || 0,
159
- },
160
- };
161
- }
162
-
163
- export function convertMimoMessage(row, d) {
164
- const t = d.tokens || {};
165
- const tokens = {
166
- prompt_tokens: t.input || 0,
167
- completion_tokens: t.output || 0,
168
- ...(t.reasoning ? { reasoning_tokens: t.reasoning } : {}),
169
- ...(t.cache?.read ? { cache_read_input_tokens: t.cache.read } : {}),
170
- ...(t.cache?.write ? { cache_creation_input_tokens: t.cache.write } : {}),
171
- };
172
- const ms = Number(d.time?.completed || d.time?.created || row.time_created);
173
- if (!Number.isFinite(ms) || ms <= 0) return null; // 缺时间戳宁可不发:Date.now() 兜底每次都会变成“新行”
174
- return {
175
- timestamp: new Date(ms).toISOString(),
176
- provider: 'mimo-' + (d.providerID || 'mimo'),
177
- model: d.modelID || 'unknown',
178
- connectionId: null, apiKey: null,
179
- endpoint: 'mimo://' + (d.agent || 'desktop'),
180
- cost: d.cost || 0,
181
- status: 'ok',
182
- tokens,
183
- meta: { source: 'mimo', mimoMessageId: row.id || null, sessionId: row.session_id || null, agent: d.agent || null, mode: d.mode || null, planUsage: true },
184
- };
185
- }
186
-
187
- /** mirasim 调用若经本机 / 局域网的 10Router 中转,10Router 自己已记过账,再导入会重复 */
188
- export function isSelfHostedUpstream(upstreamHost, endpointUrl) {
189
- if (!upstreamHost) return false;
190
- let host = upstreamHost, port = null;
191
- try {
192
- const u = new URL(upstreamHost.includes('://') ? upstreamHost : `http://${upstreamHost}`);
193
- host = u.hostname;
194
- port = u.port ? Number(u.port) : 80;
195
- } catch {}
196
- const isPrivate = host === 'localhost' || /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host);
197
- if (!isPrivate) return false;
198
- if (endpointUrl) {
199
- try { if (new URL(endpointUrl).hostname === host) return true; } catch {}
200
- }
201
- return port === 20127 || port === 20128 || port === 80 || port === 443;
202
- }
203
-
204
- export function convertMirasimRow(e) {
205
- // mirasim 的 input 只算净新增(缓存分列),真实输入 = input + cacheRead + cacheWrite
206
- const tokens = { prompt_tokens: (e.input || 0) + (e.cacheRead || 0) + (e.cacheWrite || 0), completion_tokens: e.output || 0 };
207
- if (e.cacheRead) tokens.cache_read_input_tokens = e.cacheRead;
208
- if (e.cacheWrite) tokens.cache_creation_input_tokens = e.cacheWrite;
209
- if (e.reasoning) tokens.reasoning_tokens = e.reasoning;
210
- const ok = (e.status || 0) >= 200 && (e.status || 0) < 400;
211
- return {
212
- timestamp: e.ts,
213
- provider: 'mirasim-' + String(e.provider || 'unknown'),
214
- model: e.model || 'unknown',
215
- connectionId: null, apiKey: null,
216
- endpoint: 'mirasim://' + (e.agent || e.leg || 'relay'),
217
- cost: 0,
218
- status: ok ? 'ok' : 'error',
219
- tokens,
220
- meta: {
221
- source: 'mirasim', mirasimCallId: e.id || null, relayCallId: e.relayCallId || null, agent: e.agent || null,
222
- leg: e.leg || null, viaRelay: e.viaRelay ?? null, upstreamHost: e.upstreamHost || null, effort: e.effort || null,
223
- httpStatus: e.status ?? null, durationMs: e.durationMs ?? null, repo: e.repo || null, workspace: e.workspace || null,
224
- planUsage: true,
225
- },
226
- };
227
- }
228
-
229
- /**
230
- * 妙手(CatPaw)云端按天用量 → 行。
231
- * 云端只有一个总数(无输入/输出/缓存拆分、无模型维度),整包记入 prompt_tokens
232
- * (LLM 负载输入占大头,这样拆分偏差最小);成本记 0(套餐制,与 zcode 官方渠道同口径)。
233
- * connectionId 参与 10Router 行签名,用它区分多账号,避免同日同量互相去重。
234
- */
235
- export function convertCatpawDaily(date, deltaTokens, account, modelLabel = 'unknown', seq = 0, totalTokens = deltaTokens) {
236
- const [y, m, d] = String(date).split('-').map(Number);
237
- // 本地正午锚点:任何时区都落在当天;seq 秒偏移让同日多段差量签名不同(否则等量差量会被去重误杀)
238
- const ts = new Date(y, (m || 1) - 1, d || 1, 12, 0, seq % 60);
239
- return {
240
- timestamp: ts.toISOString(),
241
- provider: 'catpaw-catx',
242
- model: modelLabel,
243
- connectionId: 'catpaw-' + account.id,
244
- apiKey: null,
245
- endpoint: 'catpaw://daily',
246
- cost: 0,
247
- status: 'ok',
248
- tokens: { prompt_tokens: deltaTokens, completion_tokens: 0 },
249
- meta: {
250
- source: 'catpaw', aggregate: 'daily', unsplitTokens: true, planUsage: true,
251
- catpawAccountId: account.id, accountName: account.name || null,
252
- date, deltaTokens, totalTokens, seq,
253
- },
254
- };
255
- }
256
-
257
- const localDateKey = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
258
-
259
- /**
260
- * 妙手云端用量:逐账号拉按天 token 总量(当天桶不完整,留到隔天再入账)。
261
- * 云端按天桶会随补录增长,整包重发会被 10Router 当成新行(token 数不同、签名不同)→ 同一天记多行。
262
- * 所以按 sentTotals 记账只发差量:桶没长就不发;桶长了只发增量,时间戳按发送次数错开秒。
263
- */
264
- async function collectCatpaw(accounts, modelLabel, sentTotals = {}) {
265
- const out = [];
266
- const notes = [];
267
- const nextSent = {};
268
- const now = Date.now();
269
- const todayKey = localDateKey();
270
- let failed = 0, lastErr = null;
271
- for (const a of accounts) {
272
- try {
273
- const daily = await fetchCatpawTokenDaily(a.token, { startTime: now - 400 * 86400e3, endTime: now });
274
- for (const d of daily) {
275
- if (!d || !d.date || !(Number(d.totalTokens) > 0)) continue;
276
- if (d.date >= todayKey) continue;
277
- const key = a.id + '|' + d.date;
278
- const total = Number(d.totalTokens);
279
- const prev = sentTotals[key] || { total: 0, parts: 0 };
280
- const delta = total - (Number(prev.total) || 0);
281
- if (delta <= 0) { nextSent[key] = prev; continue; } // 桶没长(或云端回撤):不重发
282
- out.push(convertCatpawDaily(d.date, delta, a, modelLabel, prev.parts || 0, total));
283
- nextSent[key] = { total, parts: (prev.parts || 0) + 1 };
284
- }
285
- } catch (e) { failed++; lastErr = e; }
286
- }
287
- if (failed && !out.length) throw lastErr;
288
- if (failed) notes.push(`${failed} 个妙手账号用量拉取失败:${lastErr && lastErr.message}`);
289
- out.sort((x, y) => (x.timestamp < y.timestamp ? -1 : 1));
290
- return { entries: out, notes, nextSentTotals: { ...sentTotals, ...nextSent } };
291
- }
292
-
293
- /** 本机 10Router/9Router 的 usageHistory 行 → 导入行(同 10router-sync 插件 --source 10r)。
294
- * 平面列 + tokens/meta JSON 原样保留:10R 原生行带真实计费与错误状态,不做任何归零;
295
- * meta.gatewaySync 只标「源实例原生产生的行」,源实例自己从别处导入的行保持 imported 标记,
296
- * 聚合端据此排除健康度统计、并按行签名去重。 */
297
- export function convertRouterRow(row, dbPath, tag = 'creditdaddy') {
298
- const safeParse = (v) => {
299
- if (v && typeof v === 'object') return v;
300
- try { return v ? JSON.parse(v) : {}; } catch { return {}; }
301
- };
302
- const tokens = safeParse(row.tokens);
303
- const meta = safeParse(row.meta);
304
- if (!meta.source) meta.source = '10r';
305
- meta.syncedFrom = tag || dbPath;
306
- meta.sourceDbPath = dbPath; // 机器可查的出处,同实例防护据此拒绝回灌
307
- if (meta.imported !== true) meta.gatewaySync = true;
308
- if (row.connectionId) meta.sourceConnectionId = row.connectionId;
309
- return {
310
- timestamp: row.timestamp,
311
- provider: row.provider || 'unknown',
312
- model: row.model || 'unknown',
313
- connectionId: null, // 源实例的连接 uuid 对聚合端无意义,只留在 meta 里
314
- apiKey: row.apiKey || null,
315
- endpoint: row.endpoint || null,
316
- promptTokens: row.promptTokens ?? tokens.prompt_tokens ?? tokens.input_tokens ?? 0,
317
- completionTokens: row.completionTokens ?? tokens.completion_tokens ?? tokens.output_tokens ?? 0,
318
- cost: row.cost || 0,
319
- status: row.status || 'ok',
320
- tokens,
321
- meta,
322
- };
323
- }
324
-
325
- function isLoopbackEndpoint(url) {
326
- try {
327
- const u = new URL(String(url || '').includes('://') ? url : 'http://' + url);
328
- return u.hostname === 'localhost' || u.hostname === '::1' || u.hostname === '[::1]' || /^127\./.test(u.hostname);
329
- } catch { return false; }
330
- }
331
-
332
- function collectRouter(files, endpoint) {
333
- // 同实例防护(同插件的 exit 2 守卫):地址是本机回环时,探测到的本机实例库就是它自己的账,
334
- // 导回去会让存量行被打上 meta.imported 且全部去重命中。聚合端应把地址指向 NAS / 远端实例。
335
- if (isLoopbackEndpoint(endpoint)) {
336
- return { entries: [], notes: ['配置的 10Router 地址是本机回环:本机实例库就是它自己的账,不同步(聚合请把地址指向 NAS / 远端实例)'], files: files.length };
337
- }
338
- const out = [];
339
- let noTs = 0;
340
- for (const f of files) {
341
- withSnapshot(f, (db) => {
342
- let rows;
343
- try {
344
- rows = db.prepare('SELECT timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, status, tokens, meta FROM usageHistory ORDER BY id ASC').all();
345
- } catch {
346
- rows = db.prepare('SELECT timestamp, provider, model, promptTokens, completionTokens, tokens FROM usageHistory ORDER BY id ASC').all();
347
- }
348
- for (const r of rows) {
349
- // NULL 时间戳的行会让服务端按「当下」回填,每次签名都不同,去重失效——绝不发
350
- if (!r.timestamp) { noTs++; continue; }
351
- out.push(convertRouterRow(r, f));
352
- }
353
- });
354
- }
355
- const notes = [];
356
- if (noTs) notes.push(`跳过 ${noTs} 行无时间戳(会导致去重失效)`);
357
- out.sort((x, y) => (x.timestamp < y.timestamp ? -1 : 1));
358
- return { entries: out, notes, files: files.length };
359
- }
360
-
361
- // ── 读取 ──
362
-
363
- function withSnapshot(dbPath, fn) {
364
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'creditdaddy-usage-'));
365
- try {
366
- const dst = path.join(tmp, 'db.sqlite');
367
- fs.copyFileSync(dbPath, dst);
368
- for (const sfx of ['-wal', '-shm']) if (fs.existsSync(dbPath + sfx)) fs.copyFileSync(dbPath + sfx, dst + sfx);
369
- const db = new sqliteMod.DatabaseSync(dst, { readOnly: true });
370
- try { return fn(db); } finally { db.close(); }
371
- } finally {
372
- fs.rmSync(tmp, { recursive: true, force: true });
373
- }
374
- }
375
-
376
- function collectZcode(files) {
377
- const out = [];
378
- const seen = new Set();
379
- let skippedCustom = 0, skippedNoTs = 0;
380
- for (const f of files) {
381
- withSnapshot(f, (db) => {
382
- let rows;
383
- try {
384
- 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();
385
- } catch {
386
- 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();
387
- }
388
- for (const r of rows) {
389
- if (!isOfficialProvider(r.provider_id)) { skippedCustom++; continue; }
390
- if (r.provider_id && r.logical_request_id) {
391
- const k = `${r.provider_id}|${r.logical_request_id}`;
392
- if (seen.has(k)) continue;
393
- seen.add(k);
394
- }
395
- const e = convertZcodeRow(r);
396
- if (!e) { skippedNoTs++; continue; }
397
- out.push(e);
398
- }
399
- });
400
- }
401
- return { entries: out, notes: (skippedCustom ? [`跳过 ${skippedCustom} 行自定义 / 网关渠道(已在别处记账)`] : []).concat(skippedNoTs ? [`跳过 ${skippedNoTs} 行缺时间戳的记录(无稳定签名,避免每次同步都当成新行)`] : []) };
402
- }
403
-
404
- // OpenCode 一个会话一行(会话级累计),会话还在继续时 token 会变 → 签名变 → 10Router 去重拦不住,
405
- // 同一会话会被记成两行。所以最近 SETTLE_MS 内仍在更新的会话(或更新时间不可信的)先不同步,等它停下来再同步。
406
- function collectOpencode(files, now = Date.now()) {
407
- const out = [];
408
- let active = 0, skippedNoTs = 0;
409
- for (const f of files) {
410
- withSnapshot(f, (db) => {
411
- 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()) {
412
- if (!Number.isFinite(r.time_updated) || now - r.time_updated < SETTLE_MS) { active++; continue; }
413
- const e = convertOpencodeSession(r);
414
- if (!e) { skippedNoTs++; continue; }
415
- out.push(e);
416
- }
417
- });
418
- }
419
- return { entries: out, notes: (active ? [`暂不同步 ${active} 个进行中的会话(1 小时内仍有更新,结束后再同步,避免同一会话记两行)`] : []).concat(skippedNoTs ? [`跳过 ${skippedNoTs} 行缺时间戳的会话`] : []) };
420
- }
421
-
422
- function collectMimo(files, now = Date.now()) {
423
- const out = [];
424
- const seen = new Set();
425
- let unsettled = 0, skippedNoTs = 0;
426
- for (const f of files) {
427
- withSnapshot(f, (db) => {
428
- for (const r of db.prepare('SELECT id, session_id, time_created, data FROM message ORDER BY time_created ASC').all()) {
429
- let d;
430
- try { d = JSON.parse(r.data); } catch { continue; }
431
- if (d.role !== 'assistant') continue;
432
- if (r.id) { if (seen.has(r.id)) continue; seen.add(r.id); }
433
- const t = d.tokens || {};
434
- if (!((t.input || 0) + (t.output || 0) + (t.reasoning || 0) + (t.cache?.read || 0) + (t.cache?.write || 0))) continue;
435
- // 消息落库后 token / 完成时间还可能回填:没有 time.completed 或刚完成的先不同步,等它定型
436
- const doneMs = Number(d.time?.completed);
437
- if (!Number.isFinite(doneMs) || doneMs <= 0 || now - doneMs < SETTLE_MS) {
438
- const anyMs = Number(d.time?.created || r.time_created);
439
- if (Number.isFinite(anyMs) && anyMs > 0) unsettled++; else skippedNoTs++;
440
- continue;
441
- }
442
- const e = convertMimoMessage(r, d);
443
- if (!e) { skippedNoTs++; continue; }
444
- out.push(e);
445
- }
446
- });
447
- }
448
- return { entries: out, notes: (unsettled ? [`暂不同步 ${unsettled} 行未定型的消息(1 小时内完成或完成时间缺失,结束后再同步,避免同一消息记两行)`] : []).concat(skippedNoTs ? [`跳过 ${skippedNoTs} 行缺时间戳的消息`] : []) };
449
- }
450
-
451
- function collectMirasim(files, endpoint, now = Date.now()) {
452
- const out = [];
453
- const seen = new Set();
454
- let selfHosted = 0, unsettled = 0;
455
- for (const f of files) {
456
- for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
457
- if (!line.trim()) continue;
458
- let e;
459
- try { e = JSON.parse(line); } catch { continue; }
460
- if (!e || typeof e !== 'object') continue;
461
- if (e.id) { if (seen.has(e.id)) continue; seen.add(e.id); }
462
- if (isSelfHostedUpstream(e.upstreamHost, endpoint)) { selfHosted++; continue; }
463
- if (!((e.input || 0) + (e.output || 0) + (e.cacheRead || 0) + (e.cacheWrite || 0))) continue;
464
- // 刚发生的调用可能还在补字段(durationMs / token 等),定型前先不发
465
- const tsMs = Date.parse(e.ts);
466
- if (!Number.isFinite(tsMs) || now - tsMs < SETTLE_MS) { unsettled++; continue; }
467
- out.push(convertMirasimRow(e));
468
- }
469
- }
470
- return { entries: out, notes: (selfHosted ? [`跳过 ${selfHosted} 行经 10Router 中转的调用(已在 10Router 记账)`] : []).concat(unsettled ? [`暂不同步 ${unsettled} 行 1 小时内的新调用(等记录定型)`] : []) };
471
- }
472
-
473
- /** 读取某来源的全部行:{ entries, notes, files, sentTotals? };来源不存在返回 entries=[] */
474
- export async function collectSource(id, { endpoint, paths = sourcePaths(), catpawModel, sentTotals } = {}) {
475
- if (id === 'catpaw') {
476
- const accounts = (await loadAccounts()).filter((a) => a.provider === 'catpaw' && a.token);
477
- if (!accounts.length) return { entries: [], notes: [], files: 0 };
478
- // catx 网关当前只出 GLM-5.3-FlashX——未配置时用真实模型名,避免 10r 里显示「未知」
479
- const r = await collectCatpaw(accounts, catpawModel || 'GLM-5.3-FlashX', sentTotals || {});
480
- return { ...r, files: accounts.length };
481
- }
482
- const files = paths[id] || [];
483
- if (!files.length) return { entries: [], notes: [], files: 0 };
484
- if (id !== 'mirasim' && !(await sqliteAvailable())) throw new Error('需要 Node 22.5+(node:sqlite)才能读取 ' + SOURCE_LABEL[id] + ' 的数据库');
485
- const r = id === 'zcode' ? collectZcode(files)
486
- : id === 'opencode' ? collectOpencode(files)
487
- : id === 'mimo' ? collectMimo(files)
488
- : id === '10r' ? collectRouter(files, endpoint)
489
- : collectMirasim(files, endpoint);
490
- return { ...r, files: files.length };
491
- }
492
-
493
- /** 增量筛选:只保留 watermark - 重叠 之后的行;返回 { selected, maxTs } */
494
- export function selectSince(entries, watermarkIso) {
495
- const since = watermarkIso ? new Date(watermarkIso).getTime() - OVERLAP_MS : -Infinity;
496
- let maxTs = watermarkIso || null;
497
- const selected = [];
498
- for (const e of entries) {
499
- const t = new Date(e.timestamp).getTime();
500
- if (!Number.isFinite(t)) continue;
501
- if (t >= since) selected.push(e);
502
- if (!maxTs || t > new Date(maxTs).getTime()) maxTs = new Date(t).toISOString();
503
- }
504
- return { selected, maxTs };
505
- }
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
+ * catpaw 妙手云端用量(catx 网关 /v1/usage/token/daily,按天 token 总量;
11
+ * 无输入/输出拆分,整包记 prompt_tokens;当天桶隔天再入账)
12
+ * 行结构与插件一致(provider 前缀 zcode- / opencode- / mirasim- / mimo-,cost 记 0 或源值),
13
+ * 10Router 按行内容签名去重——签名 = (时间戳, provider, model, connectionId, apiKeyHash,
14
+ * 输入 token, 输出 token),行内容一模一样才会被认作重复;行内容还在变(会话进行中、
15
+ * 按天聚合桶增长)就会被当成新行。所以客户端要保证发出去的行已经定型:
16
+ * - 可能回填/累计的来源(opencode 会话、mimo 消息)在结算窗 SETTLE_MS 内暂不发送;
17
+ * - 妙手按天桶只发差量,桶增长不再整包重发;
18
+ * - 时间戳缺失的行直接跳过,绝不用 Date.now() 兜底(每次同步都会变成“新行”)。
19
+ *
20
+ * 增量:每个来源记住已同步到的最大时间戳,下一轮只发这之后(回退 2 天重叠兜底迟到行)的行;
21
+ * 首次同步发送全部历史。SQLite 用 Node 内置 node:sqlite(Node 22.5+ / 桌面版 Electron 37 均可用),
22
+ * 读取前先把数据库连同 -wal/-shm 复制到临时目录,绝不触碰客户端正在写的原文件。
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+ import { loadAccounts } from './store.js';
29
+ import { fetchCatpawTokenDaily } from './catpawClient.js';
30
+
31
+ export const SOURCES = ['zcode', 'opencode', 'mirasim', 'mimo', 'catpaw', '10r'];
32
+ export const SOURCE_LABEL = { zcode: 'ZCode', opencode: 'OpenCode', mirasim: 'mirasim', mimo: '小米 MiMo', catpaw: '妙手', '10r': '10Router 本机' };
33
+ const OVERLAP_MS = 2 * 86400e3;
34
+ /** 结算窗:时间戳在此窗口内的行可能还在被来源更新(token 累计、时间字段回填),暂不发送 */
35
+ const SETTLE_MS = 60 * 60 * 1000;
36
+
37
+ let sqliteMod;
38
+ /** node:sqlite 是否可用(Node 22.5+);不可用时 SQLite 来源跳过,mirasim(ndjson)仍可同步 */
39
+ export async function sqliteAvailable() {
40
+ if (sqliteMod === undefined) {
41
+ try {
42
+ // 屏蔽 “SQLite is an experimental feature” 警告,避免刷进日志
43
+ const emit = process.emitWarning;
44
+ process.emitWarning = (w, ...rest) => (String(w).includes('SQLite') ? undefined : emit.call(process, w, ...rest));
45
+ try { sqliteMod = await import('node:sqlite'); } finally { process.emitWarning = emit; }
46
+ } catch { sqliteMod = null; }
47
+ }
48
+ return Boolean(sqliteMod);
49
+ }
50
+
51
+ // ── 路径 ──
52
+
53
+ export function sourcePaths(home = os.homedir(), env = process.env, platform = process.platform) {
54
+ const local = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
55
+ const zcode = [path.join(home, '.zcode', 'cli', 'db', 'db.sqlite')];
56
+ try {
57
+ const projects = path.join(home, '.zcode', 'projects');
58
+ for (const name of fs.readdirSync(projects)) zcode.push(path.join(projects, name, 'db.sqlite'));
59
+ } catch {}
60
+ const opencode = [path.join(home, '.local', 'share', 'opencode', 'opencode.db')];
61
+ if (platform === 'win32') opencode.push(path.join(local, 'opencode', 'opencode.db'));
62
+ const mimo = [path.join(home, '.local', 'share', 'mimocode', 'mimocode.db')];
63
+ if (platform === 'win32') {
64
+ mimo.push(path.join(home, 'AppData', 'Roaming', 'Xiaomi MiMo', 'mimocode.db'));
65
+ mimo.push(path.join(home, 'AppData', 'Roaming', 'Xiaomi MiMo', 'mimocode', 'mimocode.db'));
66
+ mimo.push(path.join(local, 'mimocode', 'mimocode.db'));
67
+ }
68
+ // 本机 10Router/9Router 实例的记账库(同 10router-sync 插件 --source 10r 的发现逻辑);
69
+ // TENROUTER_DB 可显式指向另一个实例的库
70
+ const roaming = env.APPDATA || path.join(home, 'AppData', 'Roaming');
71
+ const routerDb = [];
72
+ if (env.TENROUTER_DB) routerDb.push(env.TENROUTER_DB);
73
+ if (platform === 'win32') {
74
+ routerDb.push(path.join(roaming, '10router', 'db', 'data.sqlite'));
75
+ routerDb.push(path.join(roaming, '9router', 'db', 'data.sqlite'));
76
+ } else {
77
+ routerDb.push(path.join(home, '.10router', 'db', 'data.sqlite'));
78
+ routerDb.push(path.join(home, '.9router', 'db', 'data.sqlite'));
79
+ }
80
+ const exist = (list) => list.filter((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } });
81
+ let mirasim = [];
82
+ const insights = path.join(home, '.mirasim', 'insights');
83
+ try {
84
+ mirasim = fs.readdirSync(insights).filter((f) => /^usage-\d{4}-\d{2}\.ndjson$/.test(f)).sort().map((f) => path.join(insights, f));
85
+ } catch {}
86
+ return { zcode: exist(zcode), opencode: exist(opencode), mirasim, mimo: exist(mimo), '10r': exist(routerDb) };
87
+ }
88
+
89
+ /** 本机检测到哪些来源 */
90
+ export async function detectSources() {
91
+ const p = sourcePaths();
92
+ const sqlite = await sqliteAvailable();
93
+ const catpawAccounts = (await loadAccounts()).filter((a) => a.provider === 'catpaw' && a.token).length;
94
+ return SOURCES.map((id) => ({
95
+ id, label: SOURCE_LABEL[id],
96
+ found: id === 'catpaw' ? catpawAccounts > 0 : p[id].length > 0,
97
+ files: id === 'catpaw' ? catpawAccounts : p[id].length,
98
+ supported: id === 'mirasim' || id === 'catpaw' || sqlite,
99
+ }));
100
+ }
101
+
102
+ // ── 行转换(与插件一致) ──
103
+
104
+ const OFFICIAL_PREFIXES = ['builtin:', 'account:'];
105
+ const isOfficialProvider = (id) => OFFICIAL_PREFIXES.some((p) => String(id || '').startsWith(p));
106
+ const statusTo10r = (s) => (s === 'completed' ? 'ok' : 'error'); // cancelled 也消耗了 token,记 error
107
+
108
+ export function convertZcodeRow(row) {
109
+ const ms = Number(row.started_at || row.completed_at);
110
+ if (!Number.isFinite(ms) || ms <= 0) return null; // 缺时间戳不能用 Date.now() 兜底:每次同步都是一条“新行”
111
+ const tokens = {
112
+ prompt_tokens: row.input_tokens || 0,
113
+ completion_tokens: row.output_tokens || 0,
114
+ ...(row.reasoning_tokens ? { reasoning_tokens: row.reasoning_tokens } : {}),
115
+ ...(row.cache_creation_input_tokens ? { cache_creation_input_tokens: row.cache_creation_input_tokens } : {}),
116
+ ...(row.cache_read_input_tokens ? { cache_read_input_tokens: row.cache_read_input_tokens } : {}),
117
+ };
118
+ return {
119
+ timestamp: new Date(ms).toISOString(),
120
+ provider: 'zcode-' + String(row.provider_id || 'unknown').replace(/^(builtin:|account:)/, ''),
121
+ model: row.model_id || 'unknown',
122
+ connectionId: null, apiKey: null,
123
+ endpoint: 'zcode://' + (row.agent || 'session'),
124
+ cost: 0, // 官方渠道是订阅套餐,不是按量计费
125
+ status: statusTo10r(row.status),
126
+ tokens,
127
+ meta: {
128
+ source: 'zcode', zcodeProviderId: row.provider_id || null, agent: row.agent || null,
129
+ sessionId: row.session_id || null, durationMs: row.duration_ms ?? null, planUsage: true,
130
+ },
131
+ };
132
+ }
133
+
134
+ export function convertOpencodeSession(s) {
135
+ const ms = Number(s.time_created);
136
+ if (!Number.isFinite(ms) || ms <= 0) return null;
137
+ let modelId = 'unknown', providerID = 'opencode';
138
+ if (s.model) {
139
+ try {
140
+ const m = typeof s.model === 'string' ? JSON.parse(s.model) : s.model;
141
+ modelId = m.id || 'unknown';
142
+ providerID = m.providerID || 'opencode';
143
+ } catch { modelId = String(s.model); }
144
+ }
145
+ const tokens = { prompt_tokens: s.tokens_input || 0, completion_tokens: s.tokens_output || 0 };
146
+ if (s.tokens_cache_read) tokens.cache_read_input_tokens = s.tokens_cache_read;
147
+ return {
148
+ timestamp: new Date(ms).toISOString(),
149
+ provider: 'opencode-' + providerID,
150
+ model: modelId,
151
+ connectionId: null, apiKey: null,
152
+ endpoint: 'opencode://desktop',
153
+ cost: s.cost || 0,
154
+ status: 'ok',
155
+ tokens,
156
+ meta: {
157
+ source: 'opencode', opencodeSessionId: s.id || null, title: s.title || null, agent: s.agent || null,
158
+ reasoning_tokens: s.tokens_reasoning || 0, cache_write_tokens: s.tokens_cache_write || 0,
159
+ },
160
+ };
161
+ }
162
+
163
+ export function convertMimoMessage(row, d) {
164
+ const t = d.tokens || {};
165
+ const tokens = {
166
+ prompt_tokens: t.input || 0,
167
+ completion_tokens: t.output || 0,
168
+ ...(t.reasoning ? { reasoning_tokens: t.reasoning } : {}),
169
+ ...(t.cache?.read ? { cache_read_input_tokens: t.cache.read } : {}),
170
+ ...(t.cache?.write ? { cache_creation_input_tokens: t.cache.write } : {}),
171
+ };
172
+ const ms = Number(d.time?.completed || d.time?.created || row.time_created);
173
+ if (!Number.isFinite(ms) || ms <= 0) return null; // 缺时间戳宁可不发:Date.now() 兜底每次都会变成“新行”
174
+ return {
175
+ timestamp: new Date(ms).toISOString(),
176
+ provider: 'mimo-' + (d.providerID || 'mimo'),
177
+ model: d.modelID || 'unknown',
178
+ connectionId: null, apiKey: null,
179
+ endpoint: 'mimo://' + (d.agent || 'desktop'),
180
+ cost: d.cost || 0,
181
+ status: 'ok',
182
+ tokens,
183
+ meta: { source: 'mimo', mimoMessageId: row.id || null, sessionId: row.session_id || null, agent: d.agent || null, mode: d.mode || null, planUsage: true },
184
+ };
185
+ }
186
+
187
+ /** mirasim 调用若经本机 / 局域网的 10Router 中转,10Router 自己已记过账,再导入会重复 */
188
+ export function isSelfHostedUpstream(upstreamHost, endpointUrl) {
189
+ if (!upstreamHost) return false;
190
+ let host = upstreamHost, port = null;
191
+ try {
192
+ const u = new URL(upstreamHost.includes('://') ? upstreamHost : `http://${upstreamHost}`);
193
+ host = u.hostname;
194
+ port = u.port ? Number(u.port) : 80;
195
+ } catch {}
196
+ const isPrivate = host === 'localhost' || /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host);
197
+ if (!isPrivate) return false;
198
+ if (endpointUrl) {
199
+ try { if (new URL(endpointUrl).hostname === host) return true; } catch {}
200
+ }
201
+ return port === 20127 || port === 20128 || port === 80 || port === 443;
202
+ }
203
+
204
+ export function convertMirasimRow(e) {
205
+ // mirasim 的 input 只算净新增(缓存分列),真实输入 = input + cacheRead + cacheWrite
206
+ const tokens = { prompt_tokens: (e.input || 0) + (e.cacheRead || 0) + (e.cacheWrite || 0), completion_tokens: e.output || 0 };
207
+ if (e.cacheRead) tokens.cache_read_input_tokens = e.cacheRead;
208
+ if (e.cacheWrite) tokens.cache_creation_input_tokens = e.cacheWrite;
209
+ if (e.reasoning) tokens.reasoning_tokens = e.reasoning;
210
+ const ok = (e.status || 0) >= 200 && (e.status || 0) < 400;
211
+ return {
212
+ timestamp: e.ts,
213
+ provider: 'mirasim-' + String(e.provider || 'unknown'),
214
+ model: e.model || 'unknown',
215
+ connectionId: null, apiKey: null,
216
+ endpoint: 'mirasim://' + (e.agent || e.leg || 'relay'),
217
+ cost: 0,
218
+ status: ok ? 'ok' : 'error',
219
+ tokens,
220
+ meta: {
221
+ source: 'mirasim', mirasimCallId: e.id || null, relayCallId: e.relayCallId || null, agent: e.agent || null,
222
+ leg: e.leg || null, viaRelay: e.viaRelay ?? null, upstreamHost: e.upstreamHost || null, effort: e.effort || null,
223
+ httpStatus: e.status ?? null, durationMs: e.durationMs ?? null, repo: e.repo || null, workspace: e.workspace || null,
224
+ planUsage: true,
225
+ },
226
+ };
227
+ }
228
+
229
+ /**
230
+ * 妙手(CatPaw)云端按天用量 → 行。
231
+ * 云端只有一个总数(无输入/输出/缓存拆分、无模型维度),整包记入 prompt_tokens
232
+ * (LLM 负载输入占大头,这样拆分偏差最小);成本记 0(套餐制,与 zcode 官方渠道同口径)。
233
+ * connectionId 参与 10Router 行签名,用它区分多账号,避免同日同量互相去重。
234
+ */
235
+ export function convertCatpawDaily(date, deltaTokens, account, modelLabel = 'unknown', seq = 0, totalTokens = deltaTokens) {
236
+ const [y, m, d] = String(date).split('-').map(Number);
237
+ // 本地正午锚点:任何时区都落在当天;seq 秒偏移让同日多段差量签名不同(否则等量差量会被去重误杀)
238
+ const ts = new Date(y, (m || 1) - 1, d || 1, 12, 0, seq % 60);
239
+ return {
240
+ timestamp: ts.toISOString(),
241
+ provider: 'catpaw-catx',
242
+ model: modelLabel,
243
+ connectionId: 'catpaw-' + account.id,
244
+ apiKey: null,
245
+ endpoint: 'catpaw://daily',
246
+ cost: 0,
247
+ status: 'ok',
248
+ tokens: { prompt_tokens: deltaTokens, completion_tokens: 0 },
249
+ meta: {
250
+ source: 'catpaw', aggregate: 'daily', unsplitTokens: true, planUsage: true,
251
+ catpawAccountId: account.id, accountName: account.name || null,
252
+ date, deltaTokens, totalTokens, seq,
253
+ },
254
+ };
255
+ }
256
+
257
+ const localDateKey = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
258
+
259
+ /**
260
+ * 妙手云端用量:逐账号拉按天 token 总量(当天桶不完整,留到隔天再入账)。
261
+ * 云端按天桶会随补录增长,整包重发会被 10Router 当成新行(token 数不同、签名不同)→ 同一天记多行。
262
+ * 所以按 sentTotals 记账只发差量:桶没长就不发;桶长了只发增量,时间戳按发送次数错开秒。
263
+ */
264
+ async function collectCatpaw(accounts, modelLabel, sentTotals = {}) {
265
+ const out = [];
266
+ const notes = [];
267
+ const nextSent = {};
268
+ const now = Date.now();
269
+ const todayKey = localDateKey();
270
+ let failed = 0, lastErr = null;
271
+ for (const a of accounts) {
272
+ try {
273
+ const daily = await fetchCatpawTokenDaily(a.token, { startTime: now - 400 * 86400e3, endTime: now });
274
+ for (const d of daily) {
275
+ if (!d || !d.date || !(Number(d.totalTokens) > 0)) continue;
276
+ if (d.date >= todayKey) continue;
277
+ const key = a.id + '|' + d.date;
278
+ const total = Number(d.totalTokens);
279
+ const prev = sentTotals[key] || { total: 0, parts: 0 };
280
+ const delta = total - (Number(prev.total) || 0);
281
+ if (delta <= 0) { nextSent[key] = prev; continue; } // 桶没长(或云端回撤):不重发
282
+ out.push(convertCatpawDaily(d.date, delta, a, modelLabel, prev.parts || 0, total));
283
+ nextSent[key] = { total, parts: (prev.parts || 0) + 1 };
284
+ }
285
+ } catch (e) { failed++; lastErr = e; }
286
+ }
287
+ if (failed && !out.length) throw lastErr;
288
+ if (failed) notes.push(`${failed} 个妙手账号用量拉取失败:${lastErr && lastErr.message}`);
289
+ out.sort((x, y) => (x.timestamp < y.timestamp ? -1 : 1));
290
+ return { entries: out, notes, nextSentTotals: { ...sentTotals, ...nextSent } };
291
+ }
292
+
293
+ /** 本机 10Router/9Router 的 usageHistory 行 → 导入行(同 10router-sync 插件 --source 10r)。
294
+ * 平面列 + tokens/meta JSON 原样保留:10R 原生行带真实计费与错误状态,不做任何归零;
295
+ * meta.gatewaySync 只标「源实例原生产生的行」,源实例自己从别处导入的行保持 imported 标记,
296
+ * 聚合端据此排除健康度统计、并按行签名去重。 */
297
+ export function convertRouterRow(row, dbPath, tag = 'creditdaddy') {
298
+ const safeParse = (v) => {
299
+ if (v && typeof v === 'object') return v;
300
+ try { return v ? JSON.parse(v) : {}; } catch { return {}; }
301
+ };
302
+ const tokens = safeParse(row.tokens);
303
+ const meta = safeParse(row.meta);
304
+ if (!meta.source) meta.source = '10r';
305
+ meta.syncedFrom = tag || dbPath;
306
+ meta.sourceDbPath = dbPath; // 机器可查的出处,同实例防护据此拒绝回灌
307
+ if (meta.imported !== true) meta.gatewaySync = true;
308
+ if (row.connectionId) meta.sourceConnectionId = row.connectionId;
309
+ return {
310
+ timestamp: row.timestamp,
311
+ provider: row.provider || 'unknown',
312
+ model: row.model || 'unknown',
313
+ connectionId: null, // 源实例的连接 uuid 对聚合端无意义,只留在 meta 里
314
+ apiKey: row.apiKey || null,
315
+ endpoint: row.endpoint || null,
316
+ promptTokens: row.promptTokens ?? tokens.prompt_tokens ?? tokens.input_tokens ?? 0,
317
+ completionTokens: row.completionTokens ?? tokens.completion_tokens ?? tokens.output_tokens ?? 0,
318
+ cost: row.cost || 0,
319
+ status: row.status || 'ok',
320
+ tokens,
321
+ meta,
322
+ };
323
+ }
324
+
325
+ function isLoopbackEndpoint(url) {
326
+ try {
327
+ const u = new URL(String(url || '').includes('://') ? url : 'http://' + url);
328
+ return u.hostname === 'localhost' || u.hostname === '::1' || u.hostname === '[::1]' || /^127\./.test(u.hostname);
329
+ } catch { return false; }
330
+ }
331
+
332
+ function collectRouter(files, endpoint) {
333
+ // 同实例防护(同插件的 exit 2 守卫):地址是本机回环时,探测到的本机实例库就是它自己的账,
334
+ // 导回去会让存量行被打上 meta.imported 且全部去重命中。聚合端应把地址指向 NAS / 远端实例。
335
+ if (isLoopbackEndpoint(endpoint)) {
336
+ return { entries: [], notes: ['配置的 10Router 地址是本机回环:本机实例库就是它自己的账,不同步(聚合请把地址指向 NAS / 远端实例)'], files: files.length };
337
+ }
338
+ const out = [];
339
+ let noTs = 0;
340
+ for (const f of files) {
341
+ withSnapshot(f, (db) => {
342
+ let rows;
343
+ try {
344
+ rows = db.prepare('SELECT timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, status, tokens, meta FROM usageHistory ORDER BY id ASC').all();
345
+ } catch {
346
+ rows = db.prepare('SELECT timestamp, provider, model, promptTokens, completionTokens, tokens FROM usageHistory ORDER BY id ASC').all();
347
+ }
348
+ for (const r of rows) {
349
+ // NULL 时间戳的行会让服务端按「当下」回填,每次签名都不同,去重失效——绝不发
350
+ if (!r.timestamp) { noTs++; continue; }
351
+ out.push(convertRouterRow(r, f));
352
+ }
353
+ });
354
+ }
355
+ const notes = [];
356
+ if (noTs) notes.push(`跳过 ${noTs} 行无时间戳(会导致去重失效)`);
357
+ out.sort((x, y) => (x.timestamp < y.timestamp ? -1 : 1));
358
+ return { entries: out, notes, files: files.length };
359
+ }
360
+
361
+ // ── 读取 ──
362
+
363
+ function withSnapshot(dbPath, fn) {
364
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'creditdaddy-usage-'));
365
+ try {
366
+ const dst = path.join(tmp, 'db.sqlite');
367
+ fs.copyFileSync(dbPath, dst);
368
+ for (const sfx of ['-wal', '-shm']) if (fs.existsSync(dbPath + sfx)) fs.copyFileSync(dbPath + sfx, dst + sfx);
369
+ const db = new sqliteMod.DatabaseSync(dst, { readOnly: true });
370
+ try { return fn(db); } finally { db.close(); }
371
+ } finally {
372
+ fs.rmSync(tmp, { recursive: true, force: true });
373
+ }
374
+ }
375
+
376
+ function collectZcode(files) {
377
+ const out = [];
378
+ const seen = new Set();
379
+ let skippedCustom = 0, skippedNoTs = 0;
380
+ for (const f of files) {
381
+ withSnapshot(f, (db) => {
382
+ let rows;
383
+ try {
384
+ 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();
385
+ } catch {
386
+ 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();
387
+ }
388
+ for (const r of rows) {
389
+ if (!isOfficialProvider(r.provider_id)) { skippedCustom++; continue; }
390
+ if (r.provider_id && r.logical_request_id) {
391
+ const k = `${r.provider_id}|${r.logical_request_id}`;
392
+ if (seen.has(k)) continue;
393
+ seen.add(k);
394
+ }
395
+ const e = convertZcodeRow(r);
396
+ if (!e) { skippedNoTs++; continue; }
397
+ out.push(e);
398
+ }
399
+ });
400
+ }
401
+ return { entries: out, notes: (skippedCustom ? [`跳过 ${skippedCustom} 行自定义 / 网关渠道(已在别处记账)`] : []).concat(skippedNoTs ? [`跳过 ${skippedNoTs} 行缺时间戳的记录(无稳定签名,避免每次同步都当成新行)`] : []) };
402
+ }
403
+
404
+ // OpenCode 一个会话一行(会话级累计),会话还在继续时 token 会变 → 签名变 → 10Router 去重拦不住,
405
+ // 同一会话会被记成两行。所以最近 SETTLE_MS 内仍在更新的会话(或更新时间不可信的)先不同步,等它停下来再同步。
406
+ function collectOpencode(files, now = Date.now()) {
407
+ const out = [];
408
+ let active = 0, skippedNoTs = 0;
409
+ for (const f of files) {
410
+ withSnapshot(f, (db) => {
411
+ 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()) {
412
+ if (!Number.isFinite(r.time_updated) || now - r.time_updated < SETTLE_MS) { active++; continue; }
413
+ const e = convertOpencodeSession(r);
414
+ if (!e) { skippedNoTs++; continue; }
415
+ out.push(e);
416
+ }
417
+ });
418
+ }
419
+ return { entries: out, notes: (active ? [`暂不同步 ${active} 个进行中的会话(1 小时内仍有更新,结束后再同步,避免同一会话记两行)`] : []).concat(skippedNoTs ? [`跳过 ${skippedNoTs} 行缺时间戳的会话`] : []) };
420
+ }
421
+
422
+ function collectMimo(files, now = Date.now()) {
423
+ const out = [];
424
+ const seen = new Set();
425
+ let unsettled = 0, skippedNoTs = 0;
426
+ for (const f of files) {
427
+ withSnapshot(f, (db) => {
428
+ for (const r of db.prepare('SELECT id, session_id, time_created, data FROM message ORDER BY time_created ASC').all()) {
429
+ let d;
430
+ try { d = JSON.parse(r.data); } catch { continue; }
431
+ if (d.role !== 'assistant') continue;
432
+ if (r.id) { if (seen.has(r.id)) continue; seen.add(r.id); }
433
+ const t = d.tokens || {};
434
+ if (!((t.input || 0) + (t.output || 0) + (t.reasoning || 0) + (t.cache?.read || 0) + (t.cache?.write || 0))) continue;
435
+ // 消息落库后 token / 完成时间还可能回填:没有 time.completed 或刚完成的先不同步,等它定型
436
+ const doneMs = Number(d.time?.completed);
437
+ if (!Number.isFinite(doneMs) || doneMs <= 0 || now - doneMs < SETTLE_MS) {
438
+ const anyMs = Number(d.time?.created || r.time_created);
439
+ if (Number.isFinite(anyMs) && anyMs > 0) unsettled++; else skippedNoTs++;
440
+ continue;
441
+ }
442
+ const e = convertMimoMessage(r, d);
443
+ if (!e) { skippedNoTs++; continue; }
444
+ out.push(e);
445
+ }
446
+ });
447
+ }
448
+ return { entries: out, notes: (unsettled ? [`暂不同步 ${unsettled} 行未定型的消息(1 小时内完成或完成时间缺失,结束后再同步,避免同一消息记两行)`] : []).concat(skippedNoTs ? [`跳过 ${skippedNoTs} 行缺时间戳的消息`] : []) };
449
+ }
450
+
451
+ function collectMirasim(files, endpoint, now = Date.now()) {
452
+ const out = [];
453
+ const seen = new Set();
454
+ let selfHosted = 0, unsettled = 0;
455
+ for (const f of files) {
456
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
457
+ if (!line.trim()) continue;
458
+ let e;
459
+ try { e = JSON.parse(line); } catch { continue; }
460
+ if (!e || typeof e !== 'object') continue;
461
+ if (e.id) { if (seen.has(e.id)) continue; seen.add(e.id); }
462
+ if (isSelfHostedUpstream(e.upstreamHost, endpoint)) { selfHosted++; continue; }
463
+ if (!((e.input || 0) + (e.output || 0) + (e.cacheRead || 0) + (e.cacheWrite || 0))) continue;
464
+ // 刚发生的调用可能还在补字段(durationMs / token 等),定型前先不发
465
+ const tsMs = Date.parse(e.ts);
466
+ if (!Number.isFinite(tsMs) || now - tsMs < SETTLE_MS) { unsettled++; continue; }
467
+ out.push(convertMirasimRow(e));
468
+ }
469
+ }
470
+ return { entries: out, notes: (selfHosted ? [`跳过 ${selfHosted} 行经 10Router 中转的调用(已在 10Router 记账)`] : []).concat(unsettled ? [`暂不同步 ${unsettled} 行 1 小时内的新调用(等记录定型)`] : []) };
471
+ }
472
+
473
+ /** 读取某来源的全部行:{ entries, notes, files, sentTotals? };来源不存在返回 entries=[] */
474
+ export async function collectSource(id, { endpoint, paths = sourcePaths(), catpawModel, sentTotals } = {}) {
475
+ if (id === 'catpaw') {
476
+ const accounts = (await loadAccounts()).filter((a) => a.provider === 'catpaw' && a.token);
477
+ if (!accounts.length) return { entries: [], notes: [], files: 0 };
478
+ // catx 网关当前只出 GLM-5.3-FlashX——未配置时用真实模型名,避免 10r 里显示「未知」
479
+ const r = await collectCatpaw(accounts, catpawModel || 'GLM-5.3-FlashX', sentTotals || {});
480
+ return { ...r, files: accounts.length };
481
+ }
482
+ const files = paths[id] || [];
483
+ if (!files.length) return { entries: [], notes: [], files: 0 };
484
+ if (id !== 'mirasim' && !(await sqliteAvailable())) throw new Error('需要 Node 22.5+(node:sqlite)才能读取 ' + SOURCE_LABEL[id] + ' 的数据库');
485
+ const r = id === 'zcode' ? collectZcode(files)
486
+ : id === 'opencode' ? collectOpencode(files)
487
+ : id === 'mimo' ? collectMimo(files)
488
+ : id === '10r' ? collectRouter(files, endpoint)
489
+ : collectMirasim(files, endpoint);
490
+ return { ...r, files: files.length };
491
+ }
492
+
493
+ /** 增量筛选:只保留 watermark - 重叠 之后的行;返回 { selected, maxTs } */
494
+ export function selectSince(entries, watermarkIso) {
495
+ const since = watermarkIso ? new Date(watermarkIso).getTime() - OVERLAP_MS : -Infinity;
496
+ let maxTs = watermarkIso || null;
497
+ const selected = [];
498
+ for (const e of entries) {
499
+ const t = new Date(e.timestamp).getTime();
500
+ if (!Number.isFinite(t)) continue;
501
+ if (t >= since) selected.push(e);
502
+ if (!maxTs || t > new Date(maxTs).getTime()) maxTs = new Date(t).toISOString();
503
+ }
504
+ return { selected, maxTs };
505
+ }