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 +117 -0
- package/assets/genicon.js +90 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/assets/pricing.json.example +7 -0
- package/assets/tray.png +0 -0
- package/bin/cli.js +23 -0
- package/package.json +74 -0
- package/src/main/aggregate.js +160 -0
- package/src/main/collectors/claude.js +67 -0
- package/src/main/collectors/codex.js +73 -0
- package/src/main/collectors/jsonl.js +44 -0
- package/src/main/main.js +216 -0
- package/src/main/paths.js +50 -0
- package/src/main/pricing.js +72 -0
- package/src/main/probe.js +44 -0
- package/src/preload.js +19 -0
- package/src/renderer/app.js +186 -0
- package/src/renderer/index.html +78 -0
- package/src/renderer/styles.css +94 -0
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)');
|
package/assets/icon.ico
ADDED
|
Binary file
|
package/assets/icon.png
ADDED
|
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
|
+
}
|
package/assets/tray.png
ADDED
|
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 };
|