llm-usage-dashboard 0.1.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/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # AI Usage Dashboard
2
+
3
+ 一個在 **Windows** 本機執行的桌面 App,一次看到 **Codex (OpenAI)** 與 **Claude Code (Anthropic)** 的訂閱用量、剩餘額度、重置時間,以及各專案的 token 消耗與預估花費。
4
+
5
+ 開機自動常駐於系統匣 (tray),監看本機資料檔變化即時更新。
6
+
7
+ ---
8
+
9
+ ## 它顯示什麼
10
+
11
+ | 區塊 | 內容 |
12
+ |------|------|
13
+ | 總覽 | 本月預估花費、本月總 tokens、累計花費 |
14
+ | Codex 卡片 | **5 小時 / 每週額度**(官方精確 `used_percent` + 重置倒數)、方案(plus)、今日/本月 tokens、本月花費估算 |
15
+ | Claude 卡片 | **5 小時區塊 / 近 7 天**用量(依 token 記錄重建)、方案(max)、今日/本月 tokens、本月花費估算 |
16
+ | 近 14 天圖 | 每日 Codex/Claude tokens 堆疊長條 |
17
+ | 各專案用量 | 每個專案(依 `cwd`)的來源、累計/本月 tokens、本月花費、最後活動時間 |
18
+
19
+ ### 資料來源(全部讀本機檔案,不連網)
20
+
21
+ - **Codex** — `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`
22
+ - `token_count` 事件含 `total_token_usage` 與 **`rate_limits`**(`primary`=5h、`secondary`=weekly,含 `used_percent`、`resets_at`)→ **額度為官方精確值**。
23
+ - **Claude Code** — `~/.claude/projects/<專案>/<session>.jsonl`
24
+ - 每筆 assistant 記錄的 `message.usage`(input/output/cache),依 `requestId:messageId` 去重。
25
+ - Anthropic **本地不儲存官方額度數字**,故 5h/每週為依用量重建的估算視窗(ccusage 式 5 小時滾動區塊)。
26
+
27
+ > 同時涵蓋 CLI 與桌面版 —— 兩者共用同一份本機檔案。
28
+
29
+ ---
30
+
31
+ ## 一行安裝(最友善,給有 Node 的人)
32
+
33
+ 會用到這個 dashboard 的人通常本來就裝了 Node(因為在用 Codex / Claude Code),所以最方便的方式就是發佈到 npm,之後任何人:
34
+
35
+ ```powershell
36
+ npx llm-usage-dashboard # 不安裝,直接跑(首次會下載 Electron)
37
+ # 或裝成全域指令
38
+ npm i -g llm-usage-dashboard
39
+ llm-usage-dashboard
40
+ ```
41
+
42
+ > 發佈者(你)只需做一次:`npm login` 後 `npm publish`。改版只要把 `version` +1 再 `npm publish`。
43
+ > 套件已設定好 `bin`(`bin/cli.js`)、`files` 與把 `electron` 列為正式相依,使用者裝完即可一行啟動。
44
+
45
+ ## 從原始碼執行
46
+
47
+ 需求:Node.js(已驗證 v22)。
48
+
49
+ ```powershell
50
+ cd D:\aiworkspace\ai-usage-dashboard
51
+ npm install # 只需一次,安裝 Electron
52
+ npm start # 啟動 App
53
+ ```
54
+
55
+ 啟動後:
56
+ - 主視窗開啟;關閉視窗會**縮到系統匣**(不結束)。
57
+ - 系統匣圖示:左鍵開視窗、右鍵選單(立即重新整理、開機自動啟動開關、開啟資料夾、結束)。
58
+
59
+ ### 快速驗證資料(不開視窗)
60
+
61
+ ```powershell
62
+ npm run probe
63
+ ```
64
+
65
+ 直接在終端機印出 Codex/Claude 的額度、tokens、花費與各專案彙總。
66
+
67
+ ---
68
+
69
+ ## 開機自動啟動
70
+
71
+ 於系統匣選單或視窗右上角勾選「開機自動啟動」即可。底層使用 Electron `app.setLoginItemSettings({ openAtLogin, args:['--hidden'] })`;以 `--hidden` 啟動時不開視窗,只常駐系統匣。
72
+
73
+ ---
74
+
75
+ ## 打包成安裝檔(選用)
76
+
77
+ ```powershell
78
+ npm run dist # 透過 electron-builder 產生 Windows 安裝檔 (NSIS)
79
+ ```
80
+
81
+ ---
82
+
83
+ ## 花費估算說明
84
+
85
+ 訂閱制不是按 token 計費,所以金額是**參考值**。單價表在 `src/main/pricing.js`(USD / 1M tokens)。
86
+ 可在 `assets/pricing.json` 放覆寫值(格式同 `DEFAULT_PRICES_PER_M`,以模型名子字串比對),不需改程式。
87
+
88
+ ---
89
+
90
+ ## 架構
91
+
92
+ ```
93
+ src/
94
+ main/
95
+ main.js Electron 主程序:視窗 / 系統匣 / 自動啟動 / fs.watch / IPC
96
+ paths.js 解析 ~/.codex、~/.claude 路徑;正規化專案 cwd
97
+ pricing.js 模型單價表 + 花費計算
98
+ aggregate.js 5h / 週 / 月 視窗、各專案彙總、每日序列、花費
99
+ probe.js 不需 Electron 的命令列驗證工具
100
+ collectors/
101
+ jsonl.js 串流讀取 .jsonl
102
+ codex.js Codex 解析(token + 官方 rate_limits)
103
+ claude.js Claude Code 解析(去重 + token)
104
+ preload.js contextBridge 安全橋接
105
+ renderer/ 儀表板 UI(index.html / styles.css / app.js)
106
+ assets/ 圖示(genicon.js 產生)
107
+ ```
108
+
109
+ 更新機制:`fs.watch`(遞迴)監看兩個資料夾,變更後 1.5 秒去抖動重掃(即時);另**每 5 分鐘**自動拉取一次作為保底,畫面右上角顯示下次更新倒數。
110
+
111
+ ---
112
+
113
+ ## 已知限制
114
+
115
+ - Claude 的 5h/每週為**重建估算**,非官方配額%(Anthropic 未在本機儲存配額數字)。Codex 則為官方精確值。
116
+ - 「每月」兩者皆無官方配額,為當月日曆彙總,屬參考。
117
+ - 花費為估算,非實際帳單。
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+ // Generates assets/icon.png (256x256) and assets/tray.png (32x32) without any
3
+ // native deps — a small PNG encoder over a hand-drawn RGBA gauge motif.
4
+ const zlib = require('zlib');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ function crc32(buf) {
9
+ let c = ~0;
10
+ for (let i = 0; i < buf.length; i++) {
11
+ c ^= buf[i];
12
+ for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1));
13
+ }
14
+ return ~c >>> 0;
15
+ }
16
+ function chunk(type, data) {
17
+ const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
18
+ const t = Buffer.from(type, 'ascii');
19
+ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0);
20
+ return Buffer.concat([len, t, data, crc]);
21
+ }
22
+ function encodePNG(width, height, rgba) {
23
+ const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
24
+ const ihdr = Buffer.alloc(13);
25
+ ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
26
+ ihdr[8] = 8; ihdr[9] = 6; // 8-bit, RGBA
27
+ const raw = Buffer.alloc((width * 4 + 1) * height);
28
+ for (let y = 0; y < height; y++) {
29
+ raw[y * (width * 4 + 1)] = 0;
30
+ rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
31
+ }
32
+ const idat = zlib.deflateSync(raw, { level: 9 });
33
+ return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
34
+ }
35
+
36
+ function draw(size) {
37
+ const buf = Buffer.alloc(size * size * 4);
38
+ const cx = size / 2, cy = size / 2;
39
+ const R = size * 0.40, ring = size * 0.11;
40
+ for (let y = 0; y < size; y++) {
41
+ for (let x = 0; x < size; x++) {
42
+ const i = (y * size + x) * 4;
43
+ const dx = x + 0.5 - cx, dy = y + 0.5 - cy;
44
+ const d = Math.sqrt(dx * dx + dy * dy);
45
+ let r = 0, g = 0, b = 0, a = 0;
46
+ // rounded dark background
47
+ const bgR = size * 0.46;
48
+ if (d <= bgR) { r = 24; g = 27; b = 38; a = 255; }
49
+ // gauge ring: angle from -210deg..30deg, two-tone (codex green / claude orange)
50
+ if (d <= R + ring / 2 && d >= R - ring / 2) {
51
+ let ang = Math.atan2(dy, dx) * 180 / Math.PI; // -180..180
52
+ let t = (ang + 210) % 360; // 0 at start
53
+ if (t >= 0 && t <= 240) {
54
+ if (t < 120) { r = 52; g = 211; b = 153; } // teal/green
55
+ else { r = 251; g = 146; b = 60; } // orange
56
+ a = 255;
57
+ }
58
+ }
59
+ // center dot
60
+ if (d <= size * 0.12) { r = 96; g = 165; b = 250; a = 255; }
61
+ buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a;
62
+ }
63
+ }
64
+ return buf;
65
+ }
66
+
67
+ // Wrap a 256x256 PNG inside an .ico container (valid for modern Windows).
68
+ function encodeICO(png) {
69
+ const header = Buffer.alloc(6);
70
+ header.writeUInt16LE(0, 0); // reserved
71
+ header.writeUInt16LE(1, 2); // type = icon
72
+ header.writeUInt16LE(1, 4); // image count
73
+ const entry = Buffer.alloc(16);
74
+ entry[0] = 0; // width 0 => 256
75
+ entry[1] = 0; // height 0 => 256
76
+ entry[2] = 0; // palette
77
+ entry[3] = 0; // reserved
78
+ entry.writeUInt16LE(1, 4); // color planes
79
+ entry.writeUInt16LE(32, 6); // bits per pixel
80
+ entry.writeUInt32LE(png.length, 8); // size of PNG data
81
+ entry.writeUInt32LE(6 + 16, 12); // offset to PNG data
82
+ return Buffer.concat([header, entry, png]);
83
+ }
84
+
85
+ const outDir = __dirname;
86
+ const png256 = encodePNG(256, 256, draw(256));
87
+ fs.writeFileSync(path.join(outDir, 'icon.png'), png256);
88
+ fs.writeFileSync(path.join(outDir, 'tray.png'), encodePNG(32, 32, draw(32)));
89
+ fs.writeFileSync(path.join(outDir, 'icon.ico'), encodeICO(png256));
90
+ console.log('icons written (png + tray + ico)');
Binary file
Binary file
@@ -0,0 +1,7 @@
1
+ {
2
+ "_comment": "把這個檔複製成 pricing.json 即可覆寫預設模型單價。單位:USD / 1,000,000 tokens。key 以模型 id 子字串比對,最長相符者優先。只需列出想覆寫的項目。",
3
+ "gpt-5": { "input": 1.25, "output": 10, "cacheWrite": 1.25, "cacheRead": 0.125 },
4
+ "codex": { "input": 1.25, "output": 10, "cacheWrite": 1.25, "cacheRead": 0.125 },
5
+ "claude-opus": { "input": 15, "output": 75, "cacheWrite": 18.75, "cacheRead": 1.5 },
6
+ "claude-sonnet": { "input": 3, "output": 15, "cacheWrite": 3.75, "cacheRead": 0.3 }
7
+ }
Binary file
package/bin/cli.js ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Launcher so the app can be run via `npx ai-usage-dashboard` or, after
4
+ // `npm i -g ai-usage-dashboard`, simply `ai-usage-dashboard`.
5
+ // `require('electron')` resolves to the Electron binary path when run from Node.
6
+ const { spawn } = require('child_process');
7
+ const path = require('path');
8
+
9
+ let electronPath;
10
+ try {
11
+ electronPath = require('electron');
12
+ } catch (e) {
13
+ console.error('找不到 Electron。請先安裝:npm i -g ai-usage-dashboard(或在專案內 npm install)。');
14
+ process.exit(1);
15
+ }
16
+
17
+ const appDir = path.join(__dirname, '..');
18
+ const child = spawn(electronPath, [appDir, ...process.argv.slice(2)], {
19
+ stdio: 'inherit',
20
+ windowsHide: false,
21
+ });
22
+ child.on('close', (code) => process.exit(code == null ? 0 : code));
23
+ child.on('error', (err) => { console.error('啟動失敗:', err.message); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "llm-usage-dashboard",
3
+ "version": "0.1.0",
4
+ "description": "Local desktop dashboard for Codex (OpenAI) and Claude Code subscription usage, limits and cost",
5
+ "main": "src/main/main.js",
6
+ "bin": {
7
+ "llm-usage-dashboard": "bin/cli.js"
8
+ },
9
+ "author": "ianchen",
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "codex",
13
+ "claude",
14
+ "claude-code",
15
+ "openai",
16
+ "anthropic",
17
+ "usage",
18
+ "quota",
19
+ "rate-limit",
20
+ "dashboard",
21
+ "token",
22
+ "electron"
23
+ ],
24
+ "files": [
25
+ "src/**/*",
26
+ "assets/**/*",
27
+ "bin/**/*",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "start": "electron .",
32
+ "probe": "node src/main/probe.js",
33
+ "icon": "node assets/genicon.js",
34
+ "pack": "node scripts/build-pack.js",
35
+ "dist": "electron-builder --win nsis portable"
36
+ },
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "build": {
41
+ "appId": "com.ianchen.aiusagedashboard",
42
+ "productName": "AI Usage Dashboard",
43
+ "files": [
44
+ "src/**/*",
45
+ "assets/**/*",
46
+ "!**/*.map"
47
+ ],
48
+ "win": {
49
+ "target": [
50
+ { "target": "nsis", "arch": ["x64"] },
51
+ { "target": "portable", "arch": ["x64"] }
52
+ ],
53
+ "icon": "assets/icon.png"
54
+ },
55
+ "nsis": {
56
+ "oneClick": false,
57
+ "perMachine": false,
58
+ "allowToChangeInstallationDirectory": true,
59
+ "createDesktopShortcut": true,
60
+ "createStartMenuShortcut": true,
61
+ "shortcutName": "AI Usage Dashboard"
62
+ },
63
+ "portable": {
64
+ "artifactName": "AI-Usage-Dashboard-Portable-${version}.exe"
65
+ }
66
+ },
67
+ "dependencies": {
68
+ "electron": "^33.0.0"
69
+ },
70
+ "devDependencies": {
71
+ "@electron/packager": "^18.3.0",
72
+ "electron-builder": "^25.0.0"
73
+ }
74
+ }
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+ // Aggregation engine: turns normalized usage events + Codex rate snapshot into
3
+ // the dashboard data model (5h / weekly / monthly windows, per-project totals,
4
+ // cost estimates, and a daily time series).
5
+ const { costOf } = require('./pricing');
6
+ const { projectLabel } = require('./paths');
7
+
8
+ const FIVE_H = 5 * 3600 * 1000;
9
+ const SEVEN_D = 7 * 86400 * 1000;
10
+
11
+ function emptyAgg() {
12
+ return { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0, count: 0 };
13
+ }
14
+
15
+ function addEvent(agg, ev) {
16
+ agg.input += ev.tokens.input || 0;
17
+ agg.output += ev.tokens.output || 0;
18
+ agg.cacheWrite += ev.tokens.cacheWrite || 0;
19
+ agg.cacheRead += ev.tokens.cacheRead || 0;
20
+ agg.total += ev.tokens.total || 0;
21
+ agg.cost += costOf(ev.model, ev.tokens);
22
+ agg.count += 1;
23
+ }
24
+
25
+ function startOfMonth(now) {
26
+ const d = new Date(now);
27
+ return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
28
+ }
29
+ function startOfDay(ms) {
30
+ const d = new Date(ms);
31
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
32
+ }
33
+
34
+ // Reconstruct the current 5-hour activity block (ccusage-style) for events of
35
+ // one provider. Returns { startMs, resetMs, agg } for the block containing now,
36
+ // or null if no recent activity.
37
+ function currentBlock(events, now) {
38
+ if (!events.length) return null;
39
+ const sorted = [...events].sort((a, b) => a.tsMs - b.tsMs);
40
+ let blockStart = null;
41
+ let lastTs = null;
42
+ let blockAgg = emptyAgg();
43
+ for (const ev of sorted) {
44
+ const floored = startOfHour(ev.tsMs);
45
+ if (
46
+ blockStart === null ||
47
+ ev.tsMs - blockStart >= FIVE_H ||
48
+ (lastTs !== null && ev.tsMs - lastTs >= FIVE_H)
49
+ ) {
50
+ blockStart = floored;
51
+ blockAgg = emptyAgg();
52
+ }
53
+ addEvent(blockAgg, ev);
54
+ lastTs = ev.tsMs;
55
+ }
56
+ const resetMs = blockStart + FIVE_H;
57
+ if (now >= resetMs) return null; // block already expired -> no active 5h usage
58
+ return { startMs: blockStart, resetMs, agg: blockAgg };
59
+ }
60
+
61
+ function startOfHour(ms) {
62
+ return Math.floor(ms / 3600000) * 3600000;
63
+ }
64
+
65
+ function windowAgg(events, fromMs, toMs) {
66
+ const agg = emptyAgg();
67
+ for (const ev of events) {
68
+ if (ev.tsMs >= fromMs && (toMs == null || ev.tsMs < toMs)) addEvent(agg, ev);
69
+ }
70
+ return agg;
71
+ }
72
+
73
+ function dailySeries(events, now, days) {
74
+ const buckets = [];
75
+ const todayStart = startOfDay(now);
76
+ for (let i = days - 1; i >= 0; i--) {
77
+ const dayStart = todayStart - i * 86400000;
78
+ buckets.push({ dayStart, label: new Date(dayStart).toISOString().slice(5, 10), total: 0, cost: 0 });
79
+ }
80
+ const first = buckets[0].dayStart;
81
+ for (const ev of events) {
82
+ if (ev.tsMs < first) continue;
83
+ const idx = Math.floor((startOfDay(ev.tsMs) - first) / 86400000);
84
+ if (idx >= 0 && idx < buckets.length) {
85
+ buckets[idx].total += ev.tokens.total || 0;
86
+ buckets[idx].cost += costOf(ev.model, ev.tokens);
87
+ }
88
+ }
89
+ return buckets;
90
+ }
91
+
92
+ function buildProvider(events, now, opts = {}) {
93
+ const monthStart = startOfMonth(now);
94
+ const block = currentBlock(events, now);
95
+ return {
96
+ window5h: block ? block.agg : emptyAgg(),
97
+ block5hResetMs: block ? block.resetMs : null,
98
+ weekly: windowAgg(events, now - SEVEN_D, null),
99
+ monthly: windowAgg(events, monthStart, null),
100
+ today: windowAgg(events, startOfDay(now), null),
101
+ allTime: windowAgg(events, 0, null),
102
+ daily: dailySeries(events, now, 14),
103
+ ...opts,
104
+ };
105
+ }
106
+
107
+ function buildProjects(allEvents, now) {
108
+ const monthStart = startOfMonth(now);
109
+ const map = new Map();
110
+ for (const ev of allEvents) {
111
+ let p = map.get(ev.project);
112
+ if (!p) {
113
+ p = {
114
+ project: ev.project,
115
+ label: projectLabel(ev.project),
116
+ all: emptyAgg(),
117
+ month: emptyAgg(),
118
+ byProvider: {},
119
+ lastTs: 0,
120
+ };
121
+ map.set(ev.project, p);
122
+ }
123
+ addEvent(p.all, ev);
124
+ if (ev.tsMs >= monthStart) addEvent(p.month, ev);
125
+ if (!p.byProvider[ev.provider]) p.byProvider[ev.provider] = emptyAgg();
126
+ addEvent(p.byProvider[ev.provider], ev);
127
+ if (ev.tsMs > p.lastTs) p.lastTs = ev.tsMs;
128
+ }
129
+ return Array.from(map.values()).sort((a, b) => b.all.total - a.all.total);
130
+ }
131
+
132
+ // codexRate: { primary, secondary, plan_type } from collector (used_percent etc.)
133
+ function buildModel({ codex, claude, now = Date.now() }) {
134
+ const codexEvents = codex.events || [];
135
+ const claudeEvents = claude.events || [];
136
+ const all = [...codexEvents, ...claudeEvents];
137
+
138
+ const codexProvider = buildProvider(codexEvents, now, {
139
+ rate: codex.rate || null, // official 5h(primary)/weekly(secondary) percentages
140
+ planType: codex.planType || null,
141
+ fileCount: codex.fileCount || 0,
142
+ });
143
+ const claudeProvider = buildProvider(claudeEvents, now, {
144
+ subscription: claude.subscription || null,
145
+ fileCount: claude.fileCount || 0,
146
+ });
147
+
148
+ return {
149
+ generatedAt: now,
150
+ providers: { codex: codexProvider, claude: claudeProvider },
151
+ projects: buildProjects(all, now),
152
+ totals: {
153
+ monthCost: codexProvider.monthly.cost + claudeProvider.monthly.cost,
154
+ monthTokens: codexProvider.monthly.total + claudeProvider.monthly.total,
155
+ allCost: codexProvider.allTime.cost + claudeProvider.allTime.cost,
156
+ },
157
+ };
158
+ }
159
+
160
+ module.exports = { buildModel };
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+ // Claude Code collector.
3
+ // Source: ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl (+ subagents/*.jsonl)
4
+ // assistant records: { type:"assistant", cwd, timestamp, requestId,
5
+ // message:{ id, model, usage:{ input_tokens, output_tokens,
6
+ // cache_creation_input_tokens, cache_read_input_tokens } } }
7
+ //
8
+ // The same message is streamed across multiple lines; we de-duplicate by
9
+ // requestId:messageId and keep the LAST occurrence (final cumulative usage),
10
+ // mirroring ccusage's dedup strategy.
11
+ const fs = require('fs');
12
+ const { readJsonl, listJsonl } = require('./jsonl');
13
+ const { CLAUDE_PROJECTS, CLAUDE_CREDS, normalizeCwd, exists } = require('../paths');
14
+
15
+ function tokensFromClaude(u) {
16
+ return {
17
+ input: u.input_tokens || 0,
18
+ output: u.output_tokens || 0,
19
+ cacheWrite: u.cache_creation_input_tokens || 0,
20
+ cacheRead: u.cache_read_input_tokens || 0,
21
+ total:
22
+ (u.input_tokens || 0) +
23
+ (u.output_tokens || 0) +
24
+ (u.cache_creation_input_tokens || 0) +
25
+ (u.cache_read_input_tokens || 0),
26
+ };
27
+ }
28
+
29
+ function readSubscription() {
30
+ if (!exists(CLAUDE_CREDS)) return null;
31
+ try {
32
+ const d = JSON.parse(fs.readFileSync(CLAUDE_CREDS, 'utf8'));
33
+ const o = d.claudeAiOauth || {};
34
+ return { subscriptionType: o.subscriptionType || null, rateLimitTier: o.rateLimitTier || null };
35
+ } catch { return null; }
36
+ }
37
+
38
+ async function collectClaude() {
39
+ if (!exists(CLAUDE_PROJECTS)) return { events: [], subscription: null, fileCount: 0 };
40
+ const files = listJsonl(CLAUDE_PROJECTS);
41
+ // dedup map: key -> event (last write wins)
42
+ const dedup = new Map();
43
+
44
+ for (const file of files) {
45
+ await readJsonl(file, (o) => {
46
+ if (o.type !== 'assistant') return;
47
+ const msg = o.message;
48
+ if (!msg || !msg.usage) return;
49
+ const usage = msg.usage;
50
+ // skip synthetic/empty
51
+ const tk = tokensFromClaude(usage);
52
+ if (tk.total <= 0) return;
53
+ const key = `${o.requestId || ''}:${msg.id || ''}` || `${file}:${o.uuid}`;
54
+ dedup.set(key, {
55
+ provider: 'claude',
56
+ project: normalizeCwd(o.cwd),
57
+ model: msg.model || 'claude',
58
+ tsMs: Date.parse(o.timestamp) || 0,
59
+ tokens: tk,
60
+ });
61
+ });
62
+ }
63
+
64
+ return { events: Array.from(dedup.values()), subscription: readSubscription(), fileCount: files.length };
65
+ }
66
+
67
+ module.exports = { collectClaude };
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ // Codex (OpenAI) collector.
3
+ // Source: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
4
+ // line 1: { type:"session_meta", payload:{ cwd, cli_version, model_provider } }
5
+ // token_count events: { type:"event_msg", payload:{ type:"token_count",
6
+ // info:{ last_token_usage{...}, total_token_usage{...} },
7
+ // rate_limits:{ primary{used_percent,window_minutes,resets_at}, secondary{...}, plan_type } } }
8
+ //
9
+ // We emit one normalized usage event per token_count (using last_token_usage =
10
+ // the per-turn delta) and capture the globally newest rate_limits snapshot.
11
+ const { readJsonl, listJsonl } = require('./jsonl');
12
+ const { CODEX_SESSIONS, normalizeCwd } = require('../paths');
13
+
14
+ // Codex config.toml default model is read for labeling; per-turn model is not in
15
+ // the token_count event, so we approximate with the session model when known.
16
+ function tokensFromCodex(u) {
17
+ if (!u) return null;
18
+ const cachedIn = u.cached_input_tokens || 0;
19
+ const inputAll = u.input_tokens || 0;
20
+ return {
21
+ input: Math.max(0, inputAll - cachedIn), // uncached input
22
+ cacheRead: cachedIn,
23
+ cacheWrite: 0, // codex does not separate cache writes
24
+ output: (u.output_tokens || 0) + (u.reasoning_output_tokens || 0),
25
+ total: u.total_tokens || (inputAll + (u.output_tokens || 0)),
26
+ };
27
+ }
28
+
29
+ async function collectCodex() {
30
+ const files = listJsonl(CODEX_SESSIONS);
31
+ const events = [];
32
+ let latestRate = null; // { tsMs, primary, secondary, plan_type }
33
+ let planType = null;
34
+
35
+ for (const file of files) {
36
+ let cwd = 'unknown';
37
+ let model = 'gpt-5'; // codex default family for pricing/label
38
+ await readJsonl(file, (o) => {
39
+ if (o.type === 'session_meta' && o.payload) {
40
+ cwd = normalizeCwd(o.payload.cwd);
41
+ return;
42
+ }
43
+ if (o.type === 'turn_context' && o.payload && o.payload.model) {
44
+ model = o.payload.model;
45
+ return;
46
+ }
47
+ if (o.type === 'event_msg' && o.payload && o.payload.type === 'token_count') {
48
+ const info = o.payload.info || {};
49
+ const tsMs = Date.parse(o.timestamp) || 0;
50
+ const tk = tokensFromCodex(info.last_token_usage);
51
+ if (tk && (tk.total > 0)) {
52
+ events.push({ provider: 'codex', project: cwd, model, tsMs, tokens: tk });
53
+ }
54
+ const rl = o.payload.rate_limits;
55
+ if (rl) {
56
+ if (rl.plan_type) planType = rl.plan_type;
57
+ if (!latestRate || tsMs > latestRate.tsMs) {
58
+ latestRate = {
59
+ tsMs,
60
+ primary: rl.primary || null, // 5h window
61
+ secondary: rl.secondary || null, // weekly window
62
+ plan_type: rl.plan_type || null,
63
+ };
64
+ }
65
+ }
66
+ }
67
+ });
68
+ }
69
+
70
+ return { events, rate: latestRate, planType, fileCount: files.length };
71
+ }
72
+
73
+ module.exports = { collectCodex };
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+ // Stream a .jsonl file line-by-line, yielding parsed objects.
3
+ // Tolerates blank/corrupt lines (skips them).
4
+ const fs = require('fs');
5
+ const readline = require('readline');
6
+
7
+ async function readJsonl(filePath, onObj) {
8
+ await new Promise((resolve, reject) => {
9
+ let stream;
10
+ try {
11
+ stream = fs.createReadStream(filePath, { encoding: 'utf8' });
12
+ } catch (e) { return reject(e); }
13
+ stream.on('error', reject);
14
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
15
+ rl.on('line', (line) => {
16
+ const s = line.trim();
17
+ if (!s) return;
18
+ let obj;
19
+ try { obj = JSON.parse(s); } catch { return; }
20
+ try { onObj(obj); } catch { /* ignore handler errors per-line */ }
21
+ });
22
+ rl.on('close', resolve);
23
+ rl.on('error', reject);
24
+ });
25
+ }
26
+
27
+ // Recursively list *.jsonl files under a directory.
28
+ function listJsonl(dir) {
29
+ const out = [];
30
+ const stack = [dir];
31
+ while (stack.length) {
32
+ const d = stack.pop();
33
+ let entries;
34
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { continue; }
35
+ for (const e of entries) {
36
+ const full = require('path').join(d, e.name);
37
+ if (e.isDirectory()) stack.push(full);
38
+ else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(full);
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ module.exports = { readJsonl, listJsonl };