tokentracker-cli 1.1.2 → 1.1.4
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/README.de.md +6 -4
- package/README.ja.md +6 -4
- package/README.ko.md +6 -4
- package/README.md +7 -4
- package/README.zh-CN.md +6 -4
- package/dashboard/dist/assets/{AchievementBadge-CX7g238N.js → AchievementBadge-BGA-HT_B.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-BbkEymra.js → AchievementsPage-CtRh5AjH.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-CmhoerE7.js → AchievementsSection-BarRcKaN.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-996i13Vf.js → BadgeDetailModal-Bg0BDAN2.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-B22CfAlF.js → BotAnimated-By9J3-dn.js} +1 -1
- package/dashboard/dist/assets/{Card-DFd9hCFB.js → Card-CIoJYi2j.js} +1 -1
- package/dashboard/dist/assets/{ClawdAnimated-DXKN28M2.js → ClawdAnimated-Dkvc2xe8.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-DVRs34TV.js → CommandPalette-BssDtHRl.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-BExjH9E8.js → CommunityStatsModal-CQrgLlDb.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-KJhOrJ98.js → ConfirmModal-C3hBPgYc.js} +1 -1
- package/dashboard/dist/assets/{Controls-xnIEnOqd.js → Controls-BSW6a1HS.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-Dhv7fejy.js +1 -0
- package/dashboard/dist/assets/{DevicePage-Bo8e-KVq.js → DevicePage-D5eZn0wo.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-gBd-BmR7.js → DialogClose-BltQpaJo.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-Cof52Icj.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-B0UbsqD7.js → DialogTitle-CVddcRPZ.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-Dv2DtOPn.js → FadeIn-goPv73MT.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-1kEsgGdK.js → HeaderGithubStar-BDBrrkzt.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-DQlpkw2L.js → HoverTooltip-qyMezXOs.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-CFlkmSlA.js → IpCheckPage-Ces8JIPb.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-DNs6mSij.js → LandingPage-B2TmLGno.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardAvatar-DSltCiRr.js → LeaderboardAvatar-CVVRoNua.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-CkjUMA-k.js → LeaderboardPage-xtt-bOY8.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProfileModal-DJjaD7Kz.js → LeaderboardProfileModal-BM4d_I_r.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-Bue7tJoi.js → LeaderboardProfilePage-BWqhFLQX.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-Ctbvl8Q8.js → LimitsPage-BZL4Pcl8.js} +1 -1
- package/dashboard/dist/assets/{LocalOnlyNotice-LjNI8j4I.js → LocalOnlyNotice-D4gnOEJj.js} +1 -1
- package/dashboard/dist/assets/LoginCard-CB17FQ5w.js +1 -0
- package/dashboard/dist/assets/{LoginModal-CUmfCsHY.js → LoginModal-BI-O1rH4.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-CjJb9qFM.js → LoginPage-DnGzsvJ4.js} +1 -1
- package/dashboard/dist/assets/{PetPage-BWKEnBEW.js → PetPage-NRnIKMaJ.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-DeXj3kM3.js → PopoverPopup-DHoK6UJZ.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-DL-oa-UM.js → ProviderIcon-BTkdx9o2.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-CeeKMN8H.js → ResetPasswordPage-C6K0w318.js} +1 -1
- package/dashboard/dist/assets/{SegmentedControl-Bx5Z9tYf.js → SegmentedControl-gEoWioBE.js} +1 -1
- package/dashboard/dist/assets/{Select-DRbgbSPd.js → Select-CI-qtEYu.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-D792o2Et.js → SelectItemText-q-yhzzjw.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-NjQ1oGqx.js → ServiceStatusPage-C4xD1CEe.js} +1 -1
- package/dashboard/dist/assets/{SessionsPage-Cr2uHP91.js → SessionsPage-CS0Yun2z.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-Av8pVbqT.js → SettingsPage-GVY_ktz4.js} +2 -2
- package/dashboard/dist/assets/{SkillsPage-feW4j329.js → SkillsPage-COPRL95O.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-DX36F36Q.js → TokenGalaxy-Czi0Ic48.js} +1 -1
- package/dashboard/dist/assets/{ToolbarRootContext-CzMlVMfI.js → ToolbarRootContext-BZkTiBJB.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-BosWE34g.js → TrendMonitor-CnIsuxV-.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-DhoypTuq.js → WidgetsPage-OdX_Fsul.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-DR6BoipR.js → WrappedPage-h9N63-F8.js} +1 -1
- package/dashboard/dist/assets/__vite-browser-external-BFNWaGsk.js +45 -0
- package/dashboard/dist/assets/{agent-logos-QSScjsfz.js → agent-logos-BDiPxrST.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-BJQYadF-.js → arrow-up-right-DtFQUeJG.js} +1 -1
- package/dashboard/dist/assets/{check-CINIOTsF.js → check-C35onaNL.js} +1 -1
- package/dashboard/dist/assets/{composite-YV7-iFo_.js → composite-qxBzts0W.js} +1 -1
- package/dashboard/dist/assets/{copy-Bp_U7bAZ.js → copy-D_D71TLc.js} +1 -1
- package/dashboard/dist/assets/{download-CVOIkdjl.js → download-DMQI2hQe.js} +1 -1
- package/dashboard/dist/assets/{external-link-P6LDhEET.js → external-link-CemM4dWf.js} +1 -1
- package/dashboard/dist/assets/{flame-AbZCJ878.js → flame-CIvk0SP5.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-M5bvANSq.js → format-tokens-D3JSvcFl.js} +1 -1
- package/dashboard/dist/assets/{icons-DpRn5Rxt.js → icons-Ccf5W0AL.js} +1 -1
- package/dashboard/dist/assets/{index-KQ5H65Ds.js → index-Brd2evyz.js} +1 -1
- package/dashboard/dist/assets/{index-CW2bybhs.js → index-C8Wri_HI.js} +1 -1
- package/dashboard/dist/assets/{index-L3O7cNuj.js → index-CFXmD_KX.js} +1 -1
- package/dashboard/dist/assets/{info-GEiJwgYk.js → info-CafrKEG8.js} +1 -1
- package/dashboard/dist/assets/{link-2-D8KpexwS.js → link-2-Ds-9Nzlp.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-r0nEab80.js → loader-circle-DA4I55-V.js} +1 -1
- package/dashboard/dist/assets/{maximize-2-azGbxGbz.js → maximize-2-CjMPt5kU.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-Cke4_92g.js → modifiers.esm-DIxTS09h.js} +1 -1
- package/dashboard/dist/assets/module-WSx8viXK.js +3 -0
- package/dashboard/dist/assets/{monitor-smartphone-BnHiLGNs.js → monitor-smartphone-IZpIOxn3.js} +1 -1
- package/dashboard/dist/assets/{plus-CJbY6Uuk.js → plus-vbLFNs70.js} +1 -1
- package/dashboard/dist/assets/{provider-display-B05KW8SE.js → provider-display-BAu_UIfV.js} +2 -2
- package/dashboard/dist/assets/{react-CingcaIm.js → react-Djb4VGKb.js} +1 -1
- package/dashboard/dist/assets/{search-CyffdJFU.js → search-Ct6LY9Ke.js} +1 -1
- package/dashboard/dist/assets/{skills-api-CdDPrjMM.js → skills-api-BnGI2gEp.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-B48YEoXA.js → sortable.esm-CjCGd6g5.js} +1 -1
- package/dashboard/dist/assets/{store-B0W0vLvA.js → store-CBxkOqRe.js} +1 -1
- package/dashboard/dist/assets/{terminal-CJ0D4hSx.js → terminal-Dlpdp5Az.js} +1 -1
- package/dashboard/dist/assets/{trash-DH-_Q3A5.js → trash-pxUSKv66.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-y6OaeOVs.js → use-community-stats-tHaixRIw.js} +1 -1
- package/dashboard/dist/assets/{use-latest-request-guard-DP7kE0Xa.js → use-latest-request-guard-Cs44WPfL.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-Bgcto2k1.js → use-limits-display-prefs-Dw56PXWy.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-1L7o2taR.js → use-ordered-list-DpLx8oLV.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-DrlJ9X6p.js +1 -0
- package/dashboard/dist/assets/{use-session-efficiency-pref-36sqzSZl.js → use-session-efficiency-pref-BN3FtBvr.js} +1 -1
- package/dashboard/dist/assets/{use-transform-DZgeA77H.js → use-transform-C4nTGgzb.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-C5P1O9oT.js → use-usage-limits-Dmwdc1yo.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-Dwv89bBn.js → useCurrency-DSc3A_Ih.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-BGeVB81B.js → useTokenFormat-BHjcMgm8.js} +1 -1
- package/dashboard/dist/assets/{zap-D18Nt3M5.js → zap-BE8i_wu2.js} +1 -1
- package/dashboard/dist/brand-logos/trae.svg +1 -0
- package/dashboard/dist/index.html +15 -11
- package/dashboard/dist/ip-check.html +1 -1
- package/dashboard/dist/leaderboard.html +1 -1
- package/dashboard/dist/llms.txt +4 -2
- package/dashboard/dist/share.html +1 -1
- package/package.json +6 -4
- package/src/commands/init.js +20 -7
- package/src/commands/status.js +39 -9
- package/src/commands/sync.js +62 -1
- package/src/lib/pricing/curated-overrides.json +7 -0
- package/src/lib/pricing/matcher.js +5 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +845 -20
- package/src/lib/trae-db.js +127 -0
- package/src/lib/trae-snapshot.js +230 -0
- package/src/lib/trae-sqlite.js +143 -0
- package/src/lib/trae-usage.js +121 -0
- package/src/lib/wsl-probe.js +55 -12
- package/dashboard/dist/assets/DashboardPage-BzsiCwjX.js +0 -1
- package/dashboard/dist/assets/DialogDescription-CTEI9PvU.js +0 -1
- package/dashboard/dist/assets/LoginCard-U_3SnvtN.js +0 -1
- package/dashboard/dist/assets/__vite-browser-external-DYTQCEz4.js +0 -45
- package/dashboard/dist/assets/module-DtWfRKM4.js +0 -3
- package/dashboard/dist/assets/use-reduced-motion-BQxHT0gI.js +0 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
|
|
5
|
+
const { openTraeSnapshot } = require("./trae-snapshot");
|
|
6
|
+
const { withTraeSqlite } = require("./trae-sqlite");
|
|
7
|
+
|
|
8
|
+
const TOKEN_FIELDS = [
|
|
9
|
+
"prompt_tokens", "prompt_tokens_total", "input_tokens",
|
|
10
|
+
"completion_tokens", "completion_tokens_total", "output_tokens",
|
|
11
|
+
"total_tokens", "cache_read_input_tokens", "cache_creation_input_tokens",
|
|
12
|
+
"reasoning_tokens", "reasoning_output_tokens",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function resolveTraeDbPaths(env = process.env, { platform = process.platform, home = os.homedir() } = {}) {
|
|
16
|
+
const explicit = String(env.TOKENTRACKER_TRAE_DB || "").trim();
|
|
17
|
+
if (explicit) return [path.resolve(explicit)];
|
|
18
|
+
const customHome = String(env.TOKENTRACKER_TRAE_HOME || "").trim();
|
|
19
|
+
const parent = platform === "win32"
|
|
20
|
+
? env.APPDATA || path.join(home, "AppData", "Roaming")
|
|
21
|
+
: platform === "darwin"
|
|
22
|
+
? path.join(home, "Library", "Application Support")
|
|
23
|
+
: env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
24
|
+
const roots = customHome ? [customHome] : [path.join(parent, "Trae"), path.join(parent, "TRAE SOLO")];
|
|
25
|
+
const result = [];
|
|
26
|
+
for (const root of roots) {
|
|
27
|
+
// A decrypted sibling may be an old export. Only an explicit DB override
|
|
28
|
+
// opts into one; normal discovery always reads the current application DB.
|
|
29
|
+
for (const module of ["ai-agent", "ai-chat"]) {
|
|
30
|
+
const candidate = path.resolve(root, "ModularData", module, "database.db");
|
|
31
|
+
let present;
|
|
32
|
+
try { present = fs.statSync(candidate).isFile(); }
|
|
33
|
+
catch (err) {
|
|
34
|
+
// Permission failures must reach the reader's diagnostics, not masquerade
|
|
35
|
+
// as an absent installation and silently drop an entire usage history.
|
|
36
|
+
present = err.code !== "ENOENT" && err.code !== "ENOTDIR";
|
|
37
|
+
}
|
|
38
|
+
if (present) {
|
|
39
|
+
result.push(candidate);
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [...new Set(result)];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function error(message) {
|
|
48
|
+
return new Error(`Cannot read TRAE usage: ${message}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function projectUsage(queryRows) {
|
|
52
|
+
const tables = await queryRows("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chat_turn'");
|
|
53
|
+
// history_v2.token_usage can be a context-size snapshot rather than billed
|
|
54
|
+
// usage. Do not synthesize input tokens from those scalar values.
|
|
55
|
+
if (!tables.length) throw error("unsupported database schema: chat_turn table is missing.");
|
|
56
|
+
const columns = new Set((await queryRows("PRAGMA table_info(chat_turn)")).map((row) => row.name));
|
|
57
|
+
if (!columns.has("context") || (!columns.has("created_at") && !columns.has("updated_at"))) {
|
|
58
|
+
throw error("unsupported chat_turn schema.");
|
|
59
|
+
}
|
|
60
|
+
const column = (name, fallback = "NULL") => columns.has(name) ? `"${name}"` : fallback;
|
|
61
|
+
const date = columns.has("created_at") && columns.has("updated_at")
|
|
62
|
+
? 'COALESCE("created_at", "updated_at")' : column("created_at", column("updated_at"));
|
|
63
|
+
const modelPaths = [
|
|
64
|
+
"persist_user_message_context.model_info.display_model_name",
|
|
65
|
+
"persist_user_message_context.model_info.model_name",
|
|
66
|
+
"persist_user_message_context.model_info.config_name", "model_name", "model",
|
|
67
|
+
"agent_model", "selected_model.name", "selectedModel.name",
|
|
68
|
+
];
|
|
69
|
+
const model = modelPaths.map((field) => `CASE WHEN json_type(context, '$.${field}') = 'text'
|
|
70
|
+
THEN NULLIF(NULLIF(TRIM(json_extract(context, '$.${field}')), ''), '-') END`);
|
|
71
|
+
const fields = TOKEN_FIELDS.map((field) => `CASE
|
|
72
|
+
WHEN json_type(context, '$.token_usage.${field}') IN ('integer', 'real')
|
|
73
|
+
THEN json_extract(context, '$.token_usage.${field}') END AS "${field}"`);
|
|
74
|
+
const invalidFields = TOKEN_FIELDS.map((field) =>
|
|
75
|
+
`COALESCE(json_type(context, '$.token_usage.${field}') NOT IN ('integer', 'real', 'null'), 0)`);
|
|
76
|
+
// Project inside SQLite: prompts, messages, session titles, fee/account data,
|
|
77
|
+
// and the rest of the context never cross into JS results or cursor state.
|
|
78
|
+
const query = `SELECT CAST(${column("id", "rowid")} AS TEXT) AS id,
|
|
79
|
+
${column("session_id")} AS session_id, ${column("turn_id")} AS turn_id,
|
|
80
|
+
${date} AS created_at, ${column("updated_at")} AS updated_at,
|
|
81
|
+
COALESCE(${model.join(", ")}, 'trae-unknown') AS model,
|
|
82
|
+
${fields.join(", ")}, (${invalidFields.join(" OR ")}) AS invalid_usage
|
|
83
|
+
FROM chat_turn WHERE CASE WHEN json_valid(context)
|
|
84
|
+
THEN json_type(context, '$.token_usage') = 'object' ELSE 0 END`;
|
|
85
|
+
return (await queryRows(query)).map((row) => {
|
|
86
|
+
const usage = {};
|
|
87
|
+
let invalid = Boolean(row.invalid_usage);
|
|
88
|
+
for (const field of TOKEN_FIELDS) {
|
|
89
|
+
const value = row[field];
|
|
90
|
+
if (value == null) continue;
|
|
91
|
+
if (!Number.isSafeInteger(value) || value < 0) invalid = true;
|
|
92
|
+
else usage[field] = value;
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
id: row.id, session_id: row.session_id, turn_id: row.turn_id,
|
|
96
|
+
created_at: row.created_at, updated_at: row.updated_at,
|
|
97
|
+
model: row.model, usage: invalid ? null : usage,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function readTraeUsageRows(dbPath, { env = process.env } = {}) {
|
|
103
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
104
|
+
let snapshot;
|
|
105
|
+
try { snapshot = openTraeSnapshot(dbPath, env); }
|
|
106
|
+
catch (err) {
|
|
107
|
+
// A writer can also change the files while the snapshot opens. Retry
|
|
108
|
+
// that too, and report the specific failure once attempts run out.
|
|
109
|
+
if (err?.transient && attempt < 2) continue;
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
let result;
|
|
114
|
+
try { result = await withTraeSqlite(snapshot, projectUsage); }
|
|
115
|
+
catch (err) {
|
|
116
|
+
// A writer may have changed pages while SQLite was reading. Discard
|
|
117
|
+
// every projected row and retry; never publish a mixed snapshot.
|
|
118
|
+
if (snapshot.isStable()) throw err;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (snapshot.isStable()) return result;
|
|
122
|
+
} finally { snapshot.close(); }
|
|
123
|
+
}
|
|
124
|
+
throw error("database changed during the read; retry after TRAE finishes its current turn.");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { resolveTraeDbPaths, readTraeUsageRows };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const crypto = require("node:crypto");
|
|
5
|
+
|
|
6
|
+
const SQLITE_HEADER = Buffer.from("SQLite format 3\0");
|
|
7
|
+
const PAGE_SIZE = 4096;
|
|
8
|
+
const RESERVED = 80;
|
|
9
|
+
// TRAE's shared application key
|
|
10
|
+
const DEFAULT_SQLCIPHER_KEY = "3605f6691095a993f03d5009c918352ef5be31ae31e8f000212b81ff058da773";
|
|
11
|
+
const CACHE_PAGES = 64;
|
|
12
|
+
const MAX_WAL_INDEX_PAGES = 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
function error(message) { return new Error(`Cannot read TRAE usage: ${message}`); }
|
|
15
|
+
// A writer race rather than a bad store: the caller may retry immediately.
|
|
16
|
+
function transientError(message) { return Object.assign(error(message), { transient: true }); }
|
|
17
|
+
|
|
18
|
+
function statOptional(file) {
|
|
19
|
+
try { return fs.statSync(file, { bigint: true }); }
|
|
20
|
+
catch (err) { if (err.code === "ENOENT" || err.code === "ENOTDIR") return null; throw err; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sameStat(a, b) {
|
|
24
|
+
if (!a || !b) return a === b;
|
|
25
|
+
return a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && a.ino === b.ino;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readExact(fd, target, offset) {
|
|
29
|
+
let read = 0;
|
|
30
|
+
while (read < target.length) {
|
|
31
|
+
const count = fs.readSync(fd, target, read, target.length - read, offset + read);
|
|
32
|
+
if (!count) throw transientError("database or WAL was truncated during the read; retry when TRAE is idle.");
|
|
33
|
+
read += count;
|
|
34
|
+
}
|
|
35
|
+
return target;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pageDecryptor(firstPage, env) {
|
|
39
|
+
const hex = String(env.TOKENTRACKER_TRAE_SQLCIPHER_KEY || "").trim() || DEFAULT_SQLCIPHER_KEY;
|
|
40
|
+
if (!/^[0-9a-f]{64}$/i.test(hex)) throw error("TOKENTRACKER_TRAE_SQLCIPHER_KEY must contain 64 hex characters.");
|
|
41
|
+
const key = Buffer.from(hex, "hex");
|
|
42
|
+
const salt = Buffer.from(firstPage.subarray(0, 16).map((byte) => byte ^ 0x3a));
|
|
43
|
+
// SQLCipher 4 raw-key format: AES-256-CBC, 4096-byte pages, SHA-512 HMAC.
|
|
44
|
+
// https://github.com/sqlcipher/sqlcipher/blob/master/src/sqlcipher.c
|
|
45
|
+
const hmacKey = crypto.pbkdf2Sync(key, salt, 2, 32, "sha512");
|
|
46
|
+
salt.fill(0);
|
|
47
|
+
return {
|
|
48
|
+
decrypt(page, number) {
|
|
49
|
+
const start = number === 1 ? 16 : 0;
|
|
50
|
+
const ivOffset = PAGE_SIZE - RESERVED;
|
|
51
|
+
const pageId = Buffer.alloc(4);
|
|
52
|
+
pageId.writeUInt32LE(number);
|
|
53
|
+
const expected = crypto.createHmac("sha512", hmacKey)
|
|
54
|
+
.update(page.subarray(start, ivOffset + 16)).update(pageId).digest();
|
|
55
|
+
if (!crypto.timingSafeEqual(expected, page.subarray(ivOffset + 16))) {
|
|
56
|
+
throw error("SQLCipher authentication failed (incorrect key, unsupported format, or damaged database); set TOKENTRACKER_TRAE_SQLCIPHER_KEY to override the shared application key.");
|
|
57
|
+
}
|
|
58
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", key, page.subarray(ivOffset, ivOffset + 16));
|
|
59
|
+
decipher.setAutoPadding(false);
|
|
60
|
+
const decoded = decipher.update(page.subarray(start, ivOffset));
|
|
61
|
+
decipher.final();
|
|
62
|
+
page.fill(0);
|
|
63
|
+
if (number === 1) SQLITE_HEADER.copy(page);
|
|
64
|
+
decoded.copy(page, start);
|
|
65
|
+
decoded.fill(0);
|
|
66
|
+
if (number === 1 && (page.readUInt16BE(16) !== PAGE_SIZE || page[20] !== RESERVED)) {
|
|
67
|
+
throw error("unsupported SQLCipher page format.");
|
|
68
|
+
}
|
|
69
|
+
return page;
|
|
70
|
+
},
|
|
71
|
+
dispose() { key.fill(0); hmacKey.fill(0); },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function checksum(data, bigEndian, state = [0, 0]) {
|
|
76
|
+
let [a, b] = state;
|
|
77
|
+
for (let i = 0; i < data.length; i += 8) {
|
|
78
|
+
const x = bigEndian ? data.readUInt32BE(i) : data.readUInt32LE(i);
|
|
79
|
+
const y = bigEndian ? data.readUInt32BE(i + 4) : data.readUInt32LE(i + 4);
|
|
80
|
+
a = (a + x + b) >>> 0;
|
|
81
|
+
b = (b + y + a) >>> 0;
|
|
82
|
+
}
|
|
83
|
+
return [a, b];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function indexWal(fd, size, pageSize) {
|
|
87
|
+
const pages = new Map();
|
|
88
|
+
if (fd == null || size === 0) return { pages, pageCount: null };
|
|
89
|
+
if (size < 32) throw transientError("incomplete WAL header; retry when TRAE is idle.");
|
|
90
|
+
const header = readExact(fd, Buffer.alloc(32), 0);
|
|
91
|
+
const magic = header.readUInt32BE(0);
|
|
92
|
+
if ((magic !== 0x377f0682 && magic !== 0x377f0683) ||
|
|
93
|
+
header.readUInt32BE(4) !== 3007000 || header.readUInt32BE(8) !== pageSize) {
|
|
94
|
+
throw error("unsupported WAL format.");
|
|
95
|
+
}
|
|
96
|
+
const bigEndian = magic === 0x377f0683;
|
|
97
|
+
let sum = checksum(header.subarray(0, 24), bigEndian);
|
|
98
|
+
if (sum[0] !== header.readUInt32BE(24) || sum[1] !== header.readUInt32BE(28)) throw error("WAL header checksum failed.");
|
|
99
|
+
let pageCount = null;
|
|
100
|
+
let pending = new Map();
|
|
101
|
+
const frame = Buffer.alloc(24 + pageSize);
|
|
102
|
+
// Index offsets instead of buffering/decrypting an entire WAL. Uncommitted
|
|
103
|
+
// frames never enter the visible page map. SQLite checksums cover ciphertext.
|
|
104
|
+
// https://www.sqlite.org/fileformat2.html#walformat
|
|
105
|
+
for (let offset = 32; offset + frame.length <= size; offset += frame.length) {
|
|
106
|
+
readExact(fd, frame, offset);
|
|
107
|
+
if (!frame.subarray(8, 16).equals(header.subarray(16, 24))) break;
|
|
108
|
+
// Like a salt mismatch, the first checksum mismatch ends the valid log (a
|
|
109
|
+
// torn append or crash leftover); only committed frames before it count.
|
|
110
|
+
sum = checksum(frame.subarray(24), bigEndian, checksum(frame.subarray(0, 8), bigEndian, sum));
|
|
111
|
+
if (sum[0] !== frame.readUInt32BE(16) || sum[1] !== frame.readUInt32BE(20)) break;
|
|
112
|
+
const number = frame.readUInt32BE(0);
|
|
113
|
+
const commit = frame.readUInt32BE(4);
|
|
114
|
+
if (!number || number === 0xffffffff || commit === 0xffffffff) throw error("invalid WAL page number or database size.");
|
|
115
|
+
pending.set(number, offset + 24);
|
|
116
|
+
if (pending.size + pages.size > MAX_WAL_INDEX_PAGES) throw error("WAL index exceeds the bounded-memory limit; let TRAE checkpoint its WAL and retry.");
|
|
117
|
+
if (commit) {
|
|
118
|
+
for (const [page, position] of pending) pages.set(page, position);
|
|
119
|
+
pending = new Map();
|
|
120
|
+
pageCount = commit;
|
|
121
|
+
for (const page of pages.keys()) if (page > commit) pages.delete(page);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
frame.fill(0);
|
|
125
|
+
return { pages, pageCount };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A read-only, optimistic snapshot. SQLite reads authenticated pages on demand;
|
|
129
|
+
// no decrypted database is ever written to disk or materialized in memory.
|
|
130
|
+
// The caller must discard results if isStable() fails after the SQL query.
|
|
131
|
+
function openTraeSnapshot(dbPath, env = process.env) {
|
|
132
|
+
const paths = [dbPath, `${dbPath}-wal`, `${dbPath}-journal`];
|
|
133
|
+
const before = paths.map(statOptional);
|
|
134
|
+
if (!before[0]) throw error("database no longer exists.");
|
|
135
|
+
if (before.some((stat) => stat && (!stat.isFile() || stat.size > BigInt(Number.MAX_SAFE_INTEGER)))) {
|
|
136
|
+
throw error("unsupported database file or size.");
|
|
137
|
+
}
|
|
138
|
+
const handles = [];
|
|
139
|
+
const cache = new Map();
|
|
140
|
+
let decryptor;
|
|
141
|
+
const close = () => {
|
|
142
|
+
for (const page of cache.values()) page.fill(0);
|
|
143
|
+
cache.clear();
|
|
144
|
+
if (decryptor) { decryptor.dispose(); decryptor = null; }
|
|
145
|
+
let failure;
|
|
146
|
+
while (handles.length) {
|
|
147
|
+
try { fs.closeSync(handles.pop()); }
|
|
148
|
+
catch (err) { failure ||= err; }
|
|
149
|
+
}
|
|
150
|
+
if (failure) throw failure;
|
|
151
|
+
};
|
|
152
|
+
try {
|
|
153
|
+
const dbFd = fs.openSync(dbPath, "r");
|
|
154
|
+
handles.push(dbFd);
|
|
155
|
+
if (!sameStat(before[0], fs.fstatSync(dbFd, { bigint: true }))) throw transientError("database changed while opening; retry when TRAE is idle.");
|
|
156
|
+
let walFd = null;
|
|
157
|
+
if (before[1]) {
|
|
158
|
+
walFd = fs.openSync(paths[1], "r");
|
|
159
|
+
handles.push(walFd);
|
|
160
|
+
if (!sameStat(before[1], fs.fstatSync(walFd, { bigint: true }))) throw transientError("WAL changed while opening; retry when TRAE is idle.");
|
|
161
|
+
}
|
|
162
|
+
if (before[2] && before[2].size >= 8n) {
|
|
163
|
+
const journalFd = fs.openSync(paths[2], "r");
|
|
164
|
+
handles.push(journalFd);
|
|
165
|
+
if (readExact(journalFd, Buffer.alloc(8), 0).some((byte) => byte !== 0)) throw error("a rollback transaction is active; retry when TRAE is idle.");
|
|
166
|
+
}
|
|
167
|
+
const size = Number(before[0].size);
|
|
168
|
+
if (size < 100) throw error("database is truncated.");
|
|
169
|
+
const header = readExact(dbFd, Buffer.alloc(100), 0);
|
|
170
|
+
const encrypted = !header.subarray(0, 16).equals(SQLITE_HEADER);
|
|
171
|
+
const encodedSize = encrypted ? PAGE_SIZE : header.readUInt16BE(16);
|
|
172
|
+
const pageSize = encodedSize === 1 ? 65536 : encodedSize;
|
|
173
|
+
if (pageSize < 512 || pageSize > 65536 || (pageSize & (pageSize - 1)) || size % pageSize) throw error("invalid database page size or truncated database.");
|
|
174
|
+
if (encrypted) decryptor = pageDecryptor(header, env);
|
|
175
|
+
const { pages, pageCount } = indexWal(walFd, Number(before[1]?.size || 0n), pageSize);
|
|
176
|
+
const finalPageCount = pageCount == null ? size / pageSize : pageCount;
|
|
177
|
+
if (!finalPageCount || finalPageCount > 0xfffffffe) throw error("invalid database size.");
|
|
178
|
+
const byteLength = finalPageCount * pageSize;
|
|
179
|
+
const getPage = (number) => {
|
|
180
|
+
if (cache.has(number)) {
|
|
181
|
+
const page = cache.get(number);
|
|
182
|
+
cache.delete(number);
|
|
183
|
+
cache.set(number, page);
|
|
184
|
+
return page;
|
|
185
|
+
}
|
|
186
|
+
const page = Buffer.alloc(pageSize);
|
|
187
|
+
try {
|
|
188
|
+
if (pages.has(number)) readExact(walFd, page, pages.get(number));
|
|
189
|
+
else readExact(dbFd, page, (number - 1) * pageSize);
|
|
190
|
+
if (decryptor) decryptor.decrypt(page, number);
|
|
191
|
+
if (number === 1) {
|
|
192
|
+
// Present the committed view as a standalone rollback-mode database.
|
|
193
|
+
page[18] = 1;
|
|
194
|
+
page[19] = 1;
|
|
195
|
+
page.writeUInt32BE(finalPageCount, 28);
|
|
196
|
+
page.writeUInt32BE(page.readUInt32BE(24), 92);
|
|
197
|
+
}
|
|
198
|
+
} catch (err) { page.fill(0); throw err; }
|
|
199
|
+
cache.set(number, page);
|
|
200
|
+
if (cache.size > CACHE_PAGES) {
|
|
201
|
+
const oldest = cache.keys().next().value;
|
|
202
|
+
cache.get(oldest).fill(0);
|
|
203
|
+
cache.delete(oldest);
|
|
204
|
+
}
|
|
205
|
+
return page;
|
|
206
|
+
};
|
|
207
|
+
return {
|
|
208
|
+
byteLength,
|
|
209
|
+
read(target, offset) {
|
|
210
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw error("invalid read offset.");
|
|
211
|
+
target.fill(0);
|
|
212
|
+
const count = Math.min(target.length, Math.max(0, byteLength - offset));
|
|
213
|
+
let copied = 0;
|
|
214
|
+
while (copied < count) {
|
|
215
|
+
const position = offset + copied;
|
|
216
|
+
const pageOffset = position % pageSize;
|
|
217
|
+
const length = Math.min(count - copied, pageSize - pageOffset);
|
|
218
|
+
const page = getPage(Math.floor(position / pageSize) + 1);
|
|
219
|
+
target.set(page.subarray(pageOffset, pageOffset + length), copied);
|
|
220
|
+
copied += length;
|
|
221
|
+
}
|
|
222
|
+
return count;
|
|
223
|
+
},
|
|
224
|
+
isStable() { return paths.map(statOptional).every((stat, i) => sameStat(before[i], stat)); },
|
|
225
|
+
close,
|
|
226
|
+
};
|
|
227
|
+
} catch (err) { close(); throw err; }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = { openTraeSnapshot };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
|
|
3
|
+
let enginePromise;
|
|
4
|
+
let queryQueue = Promise.resolve();
|
|
5
|
+
|
|
6
|
+
async function createEngine() {
|
|
7
|
+
const [{ default: initialize }, sqlite, { FacadeVFS }] = await Promise.all([
|
|
8
|
+
import("@journeyapps/wa-sqlite/dist/wa-sqlite.mjs"),
|
|
9
|
+
import("@journeyapps/wa-sqlite"),
|
|
10
|
+
import("@journeyapps/wa-sqlite/src/FacadeVFS.js"),
|
|
11
|
+
]);
|
|
12
|
+
const module = await initialize({
|
|
13
|
+
wasmBinary: fs.readFileSync(require.resolve("@journeyapps/wa-sqlite/dist/wa-sqlite.wasm")),
|
|
14
|
+
});
|
|
15
|
+
const api = sqlite.Factory(module);
|
|
16
|
+
const snapshots = new Map();
|
|
17
|
+
const handles = new Map();
|
|
18
|
+
|
|
19
|
+
// Register one VFS for the lifetime of the engine. Each query gets a distinct
|
|
20
|
+
// virtual filename, without accumulating WASM callbacks or touching disk.
|
|
21
|
+
class TraeVFS extends FacadeVFS {
|
|
22
|
+
constructor() {
|
|
23
|
+
super("tokentracker-trae", module);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
jOpen(name, fileId, flags, outputFlags) {
|
|
27
|
+
const state = snapshots.get(name);
|
|
28
|
+
if (!state || !(flags & sqlite.SQLITE_OPEN_MAIN_DB)) return sqlite.SQLITE_CANTOPEN;
|
|
29
|
+
handles.set(fileId, state);
|
|
30
|
+
outputFlags.setInt32(0, sqlite.SQLITE_OPEN_READONLY, true);
|
|
31
|
+
return sqlite.SQLITE_OK;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
jRead(fileId, output, offset) {
|
|
35
|
+
const state = handles.get(fileId);
|
|
36
|
+
if (!state) return sqlite.SQLITE_IOERR_READ;
|
|
37
|
+
// FacadeVFS passes a proxy over WASM memory, not a native Uint8Array.
|
|
38
|
+
// Keep the temporary buffer small and erase it after copying its page.
|
|
39
|
+
const buffer = Buffer.alloc(output.byteLength);
|
|
40
|
+
try {
|
|
41
|
+
const count = state.snapshot.read(buffer, offset);
|
|
42
|
+
if (!Number.isSafeInteger(count) || count < 0 || count > buffer.length) {
|
|
43
|
+
throw new Error("Invalid TRAE snapshot read length.");
|
|
44
|
+
}
|
|
45
|
+
buffer.fill(0, count);
|
|
46
|
+
output.set(buffer);
|
|
47
|
+
return count === buffer.length ? sqlite.SQLITE_OK : sqlite.SQLITE_IOERR_SHORT_READ;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
state.error ||= err;
|
|
50
|
+
output.fill(0);
|
|
51
|
+
return sqlite.SQLITE_IOERR_READ;
|
|
52
|
+
} finally {
|
|
53
|
+
buffer.fill(0);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
jFileSize(fileId, output) {
|
|
58
|
+
const state = handles.get(fileId);
|
|
59
|
+
if (!state) return sqlite.SQLITE_IOERR_FSTAT;
|
|
60
|
+
output.setBigInt64(0, BigInt(state.snapshot.byteLength), true);
|
|
61
|
+
return sqlite.SQLITE_OK;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
jClose(fileId) {
|
|
65
|
+
handles.delete(fileId);
|
|
66
|
+
return sqlite.SQLITE_OK;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
jAccess(name, flags, output) {
|
|
70
|
+
output.setInt32(0, snapshots.has(name) ? 1 : 0, true);
|
|
71
|
+
return sqlite.SQLITE_OK;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
jWrite() { return sqlite.SQLITE_READONLY; }
|
|
75
|
+
jTruncate() { return sqlite.SQLITE_READONLY; }
|
|
76
|
+
jDelete() { return sqlite.SQLITE_READONLY; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const vfs = new TraeVFS();
|
|
80
|
+
api.vfs_register(vfs, false);
|
|
81
|
+
return { api, sqlite, snapshots, handles, vfs, nextId: 0 };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Query a read-only, random-access snapshot without materializing its database.
|
|
86
|
+
* The caller owns snapshot cleanup; only the projected SQL rows leave this VFS.
|
|
87
|
+
*/
|
|
88
|
+
async function querySnapshot(snapshot, callback) {
|
|
89
|
+
if (!Number.isSafeInteger(snapshot.byteLength) || snapshot.byteLength < 0) {
|
|
90
|
+
throw new Error("Invalid TRAE snapshot size.");
|
|
91
|
+
}
|
|
92
|
+
enginePromise ||= createEngine().catch((err) => {
|
|
93
|
+
enginePromise = undefined;
|
|
94
|
+
throw err;
|
|
95
|
+
});
|
|
96
|
+
const engine = await enginePromise;
|
|
97
|
+
const { api, sqlite, snapshots, handles, vfs } = engine;
|
|
98
|
+
const name = `trae-${++engine.nextId}.db`;
|
|
99
|
+
const state = { snapshot, error: null };
|
|
100
|
+
snapshots.set(name, state);
|
|
101
|
+
let db;
|
|
102
|
+
let failure;
|
|
103
|
+
try {
|
|
104
|
+
db = await api.open_v2(name, sqlite.SQLITE_OPEN_READONLY, vfs.name);
|
|
105
|
+
const query = async (sql) => {
|
|
106
|
+
const rows = [];
|
|
107
|
+
try {
|
|
108
|
+
await api.exec(db, sql, (values, columns) => {
|
|
109
|
+
rows.push(Object.fromEntries(columns.map((column, index) => [column, values[index]])));
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw state.error || err;
|
|
113
|
+
}
|
|
114
|
+
return rows;
|
|
115
|
+
};
|
|
116
|
+
await query("PRAGMA query_only=ON; PRAGMA cache_size=-4096; PRAGMA temp_store=MEMORY;");
|
|
117
|
+
return await callback(query);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
failure = state.error || err;
|
|
120
|
+
throw failure;
|
|
121
|
+
} finally {
|
|
122
|
+
try {
|
|
123
|
+
if (db) await api.close(db);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (!failure) throw state.error || err;
|
|
126
|
+
} finally {
|
|
127
|
+
snapshots.delete(name);
|
|
128
|
+
for (const [fileId, opened] of handles) {
|
|
129
|
+
if (opened === state) handles.delete(fileId);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function withTraeSqlite(snapshot, callback) {
|
|
136
|
+
// The package's async convenience API shares temporary WASM allocations.
|
|
137
|
+
// Serialize access to the single engine even when callers read in parallel.
|
|
138
|
+
const operation = queryQueue.then(() => querySnapshot(snapshot, callback));
|
|
139
|
+
queryQueue = operation.catch(() => {});
|
|
140
|
+
return operation;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { withTraeSqlite };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// TRAE stores the last request's usage alongside optional whole-turn prompt
|
|
4
|
+
// and completion counters. Reconcile the request first: its total cannot
|
|
5
|
+
// validate a whole-turn count, and its cache count is not a whole-turn count.
|
|
6
|
+
function normalizeTraeUsage(raw, { model } = {}) {
|
|
7
|
+
let usage = raw;
|
|
8
|
+
if (typeof usage === "string") {
|
|
9
|
+
try { usage = JSON.parse(usage); } catch { return null; }
|
|
10
|
+
}
|
|
11
|
+
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
|
|
12
|
+
const fields = [
|
|
13
|
+
"prompt_tokens", "prompt_tokens_total", "input_tokens",
|
|
14
|
+
"completion_tokens", "completion_tokens_total", "output_tokens",
|
|
15
|
+
"cache_read_input_tokens", "cache_creation_input_tokens",
|
|
16
|
+
"reasoning_tokens", "reasoning_output_tokens", "total_tokens",
|
|
17
|
+
];
|
|
18
|
+
for (const field of fields) {
|
|
19
|
+
if (usage[field] != null && (!Number.isSafeInteger(usage[field]) || usage[field] < 0)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
for (const [left, right] of [["prompt_tokens", "input_tokens"],
|
|
24
|
+
["completion_tokens", "output_tokens"], ["reasoning_tokens", "reasoning_output_tokens"]]) {
|
|
25
|
+
if (usage[left] != null && usage[right] != null && usage[left] !== usage[right]) return null;
|
|
26
|
+
}
|
|
27
|
+
if (!["prompt_tokens", "prompt_tokens_total", "input_tokens", "completion_tokens",
|
|
28
|
+
"completion_tokens_total", "output_tokens"].some((field) => usage[field] != null)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const input = usage.prompt_tokens ?? usage.input_tokens ?? usage.prompt_tokens_total ?? 0;
|
|
32
|
+
const output = usage.completion_tokens ?? usage.output_tokens ?? usage.completion_tokens_total ?? 0;
|
|
33
|
+
const aggregateInput = usage.prompt_tokens_total > 0 ? usage.prompt_tokens_total : input;
|
|
34
|
+
const aggregateOutput = usage.completion_tokens_total > 0 ? usage.completion_tokens_total : output;
|
|
35
|
+
if (aggregateInput < input || aggregateOutput < output) return null;
|
|
36
|
+
const cached = usage.cache_read_input_tokens ?? 0;
|
|
37
|
+
let written = usage.cache_creation_input_tokens ?? 0;
|
|
38
|
+
let reasoning = usage.reasoning_tokens ?? usage.reasoning_output_tokens ?? 0;
|
|
39
|
+
let estimated = false;
|
|
40
|
+
const isGemini = /^gemini(?:-|$)/i.test(normalizeTraeModel(model ?? usage.name));
|
|
41
|
+
// Some TRAE Gemini rows copy the cache-read counter into the write field,
|
|
42
|
+
// even when their sum exceeds the entire prompt. Gemini's GenerateContent
|
|
43
|
+
// metadata reports cachedContentTokenCount, not an additional write count.
|
|
44
|
+
// Keep the reported read and mark this narrow compatibility repair estimated.
|
|
45
|
+
if (isGemini && cached > 0 && written === cached && cached + written > input) {
|
|
46
|
+
written = 0;
|
|
47
|
+
estimated = true;
|
|
48
|
+
}
|
|
49
|
+
// Older TRAE Gemini rows omit thoughts while retaining Gemini's total
|
|
50
|
+
// (prompt + candidates + thoughts). Infer only that documented residual,
|
|
51
|
+
// never an arbitrary provider's unexplained total.
|
|
52
|
+
if (isGemini && usage.reasoning_tokens == null && usage.reasoning_output_tokens == null &&
|
|
53
|
+
usage.total_tokens > input + output && written === 0 && cached <= input) {
|
|
54
|
+
reasoning = usage.total_tokens - input - output;
|
|
55
|
+
estimated = true;
|
|
56
|
+
}
|
|
57
|
+
// A total is necessary to distinguish inclusive/exclusive counters when
|
|
58
|
+
// cache or reasoning is present. Missing metadata must not imply $0 usage
|
|
59
|
+
// or guessed input. A zero *_total field means unset, not an empty turn.
|
|
60
|
+
if (usage.total_tokens == null && (cached || written || reasoning)) return null;
|
|
61
|
+
const total = usage.total_tokens ?? (input + output);
|
|
62
|
+
if (!Number.isSafeInteger(total)) return null;
|
|
63
|
+
const candidates = new Map();
|
|
64
|
+
for (const includedCache of new Set([cached + written, cached, 0])) {
|
|
65
|
+
for (const includedReasoning of new Set([reasoning, 0])) {
|
|
66
|
+
const noncached = input - includedCache;
|
|
67
|
+
const nonreasoning = output - includedReasoning;
|
|
68
|
+
if (noncached < 0 || nonreasoning < 0) continue;
|
|
69
|
+
if (noncached + cached + written + nonreasoning + reasoning !== total) continue;
|
|
70
|
+
const totals = {
|
|
71
|
+
input_tokens: noncached,
|
|
72
|
+
cached_input_tokens: cached,
|
|
73
|
+
cache_creation_input_tokens: written,
|
|
74
|
+
output_tokens: nonreasoning,
|
|
75
|
+
reasoning_output_tokens: reasoning,
|
|
76
|
+
total_tokens: total,
|
|
77
|
+
};
|
|
78
|
+
candidates.set(JSON.stringify(totals), totals);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (candidates.size !== 1) return null;
|
|
82
|
+
const totals = candidates.values().next().value;
|
|
83
|
+
if (aggregateInput !== input || aggregateOutput !== output) {
|
|
84
|
+
// The *_total counters cover the whole turn; the cache and reasoning fields
|
|
85
|
+
// describe its final request. The difference in prompt tokens has no cache
|
|
86
|
+
// split, so it counts toward total_tokens but no priced column. Completion
|
|
87
|
+
// tokens are priced the same with or without caching and stay in output.
|
|
88
|
+
const unpriced = aggregateInput - input;
|
|
89
|
+
totals.output_tokens += aggregateOutput - output;
|
|
90
|
+
totals.total_tokens += unpriced + aggregateOutput - output;
|
|
91
|
+
if (unpriced > 0) totals.unpriced_input_tokens = unpriced;
|
|
92
|
+
}
|
|
93
|
+
if (Object.values(totals).some((value) => !Number.isSafeInteger(value))) return null;
|
|
94
|
+
if (estimated) totals.usage_precision = "estimated";
|
|
95
|
+
return totals;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeTraeModel(value) {
|
|
99
|
+
if (typeof value !== "string") return "trae-unknown";
|
|
100
|
+
const model = value.trim().split("__")[0].toLowerCase().replace(/\s+/g, "-");
|
|
101
|
+
// TRAE's display alias otherwise fuzzy-matches a regional Sonnet SKU and
|
|
102
|
+
// loses cache pricing. Sonnet 4 has the fixed 2025-05-14 API model id.
|
|
103
|
+
if (model === "claude-4-sonnet") return "claude-sonnet-4-20250514";
|
|
104
|
+
return model && model !== "-" && !model.includes("|") ? model : "trae-unknown";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function traeTimestamp(value) {
|
|
108
|
+
if (value == null || value === "") return null;
|
|
109
|
+
let millis;
|
|
110
|
+
if (typeof value === "number" || (typeof value === "string" && /^\d+(\.\d+)?$/.test(value))) {
|
|
111
|
+
const numeric = Number(value);
|
|
112
|
+
millis = numeric < 100_000_000_000 ? numeric * 1000 : numeric;
|
|
113
|
+
} else if (typeof value === "string") {
|
|
114
|
+
millis = Date.parse(value);
|
|
115
|
+
}
|
|
116
|
+
if (!Number.isFinite(millis) || millis <= 0) return null;
|
|
117
|
+
const date = new Date(millis);
|
|
118
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { normalizeTraeUsage, normalizeTraeModel, traeTimestamp };
|