@arraystar/tokenscope 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 +87 -0
- package/bun.lock +1170 -0
- package/components.json +25 -0
- package/eslint.config.js +23 -0
- package/index.html +13 -0
- package/package.json +46 -0
- package/public/data.json +41 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/src/App.css +184 -0
- package/src/App.tsx +98 -0
- package/src/PricingContext.tsx +131 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/cli.ts +98 -0
- package/src/components/Overview.tsx +468 -0
- package/src/components/PricingSheet.tsx +209 -0
- package/src/components/SessionDetail.tsx +244 -0
- package/src/components/ui/badge.tsx +52 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/card.tsx +103 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/separator.tsx +23 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +721 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/table.tsx +114 -0
- package/src/components/ui/tooltip.tsx +64 -0
- package/src/data.ts +90 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/i18n.tsx +148 -0
- package/src/index.css +138 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +10 -0
- package/src/parser/claude.ts +225 -0
- package/src/parser/codex.ts +181 -0
- package/src/parser/index.ts +83 -0
- package/src/parser/types.ts +35 -0
- package/src/pricing.ts +139 -0
- package/src/types.ts +56 -0
- package/tsconfig.app.json +30 -0
- package/tsconfig.json +13 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +13 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { ParsedSession, PricingTriple } from "./types";
|
|
4
|
+
|
|
5
|
+
const CLAUDE_DIR = path.join(
|
|
6
|
+
process.env.HOME || process.env.USERPROFILE || "~",
|
|
7
|
+
".claude",
|
|
8
|
+
"projects"
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const PRICING: Record<string, PricingTriple> = {
|
|
12
|
+
"glm-5.1": { input: 4.0, output: 18.0, cacheRead: 1.0 },
|
|
13
|
+
"glm-4.5-air": { input: 0.8, output: 3.0, cacheRead: 0.08 },
|
|
14
|
+
"claude-sonnet-4-20250514": { input: 3.0, output: 15.0, cacheRead: 0.3 },
|
|
15
|
+
"claude-opus-4-20250514": { input: 15.0, output: 75.0, cacheRead: 1.5 },
|
|
16
|
+
"claude-haiku-4-5-20251001": { input: 0.8, output: 4.0, cacheRead: 0.08 },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function getPrice(model: string): PricingTriple {
|
|
20
|
+
for (const [k, v] of Object.entries(PRICING)) {
|
|
21
|
+
if (model.includes(k)) return v;
|
|
22
|
+
}
|
|
23
|
+
return { input: 0, output: 0, cacheRead: 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function extractUserText(msg: Record<string, unknown>): string {
|
|
27
|
+
const content = msg.content;
|
|
28
|
+
if (typeof content === "string") return content.slice(0, 100);
|
|
29
|
+
if (Array.isArray(content)) {
|
|
30
|
+
for (const c of content) {
|
|
31
|
+
if (
|
|
32
|
+
typeof c === "object" &&
|
|
33
|
+
c !== null &&
|
|
34
|
+
(c as Record<string, unknown>).type === "text"
|
|
35
|
+
) {
|
|
36
|
+
return String((c as Record<string, unknown>).text ?? "").slice(0, 100);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface RawLine {
|
|
44
|
+
type: string;
|
|
45
|
+
message?: Record<string, unknown>;
|
|
46
|
+
timestamp?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseClaudeSessions(
|
|
50
|
+
claudeDir?: string
|
|
51
|
+
): ParsedSession[] {
|
|
52
|
+
const dir = claudeDir ?? CLAUDE_DIR;
|
|
53
|
+
if (!fs.existsSync(dir)) return [];
|
|
54
|
+
|
|
55
|
+
const sessions: ParsedSession[] = [];
|
|
56
|
+
|
|
57
|
+
for (const projDir of fs.readdirSync(dir).sort()) {
|
|
58
|
+
const projPath = path.join(dir, projDir);
|
|
59
|
+
if (!fs.statSync(projPath).isDirectory()) continue;
|
|
60
|
+
|
|
61
|
+
const parts = projDir.split("-");
|
|
62
|
+
const short =
|
|
63
|
+
parts.length > 2 ? parts.slice(2).join("/") : projDir;
|
|
64
|
+
|
|
65
|
+
for (const f of fs.readdirSync(projPath).sort()) {
|
|
66
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
67
|
+
|
|
68
|
+
const sid = f.slice(0, 8);
|
|
69
|
+
const filePath = path.join(projPath, f);
|
|
70
|
+
const lines: RawLine[] = [];
|
|
71
|
+
|
|
72
|
+
for (const line of fs.readFileSync(filePath, "utf-8").split("\n")) {
|
|
73
|
+
const trimmed = line.trim();
|
|
74
|
+
if (!trimmed) continue;
|
|
75
|
+
try {
|
|
76
|
+
lines.push(JSON.parse(trimmed));
|
|
77
|
+
} catch {
|
|
78
|
+
// skip malformed
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Build turns: group user text + following assistant messages
|
|
83
|
+
let currentUserText = "";
|
|
84
|
+
let currentAssistantMsgs: {
|
|
85
|
+
model: string;
|
|
86
|
+
input: number;
|
|
87
|
+
output: number;
|
|
88
|
+
cache_read: number;
|
|
89
|
+
cache_write: number;
|
|
90
|
+
total: number;
|
|
91
|
+
cost: number;
|
|
92
|
+
timestamp: string;
|
|
93
|
+
}[] = [];
|
|
94
|
+
|
|
95
|
+
const flushTurn = (): void => {
|
|
96
|
+
if (currentAssistantMsgs.length === 0) return;
|
|
97
|
+
const tIn = currentAssistantMsgs.reduce((s, a) => s + a.input, 0);
|
|
98
|
+
const tOut = currentAssistantMsgs.reduce((s, a) => s + a.output, 0);
|
|
99
|
+
const tCR = currentAssistantMsgs.reduce(
|
|
100
|
+
(s, a) => s + a.cache_read,
|
|
101
|
+
0
|
|
102
|
+
);
|
|
103
|
+
const tCW = currentAssistantMsgs.reduce(
|
|
104
|
+
(s, a) => s + a.cache_write,
|
|
105
|
+
0
|
|
106
|
+
);
|
|
107
|
+
const tTot = currentAssistantMsgs.reduce((s, a) => s + a.total, 0);
|
|
108
|
+
const tCost = currentAssistantMsgs.reduce((s, a) => s + a.cost, 0);
|
|
109
|
+
const ts =
|
|
110
|
+
currentAssistantMsgs[0]?.timestamp ?? "";
|
|
111
|
+
|
|
112
|
+
// Group by model — pick the first model for the session
|
|
113
|
+
const model = currentAssistantMsgs[0]?.model ?? "unknown";
|
|
114
|
+
|
|
115
|
+
turns.push({
|
|
116
|
+
user: currentUserText.slice(0, 200),
|
|
117
|
+
time: ts,
|
|
118
|
+
input: tIn,
|
|
119
|
+
output: tOut,
|
|
120
|
+
cache_read: tCR,
|
|
121
|
+
cache_write: tCW,
|
|
122
|
+
total: tTot,
|
|
123
|
+
cost: Math.round(tCost * 10000) / 10000,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Aggregate per-model stats
|
|
127
|
+
const key = model;
|
|
128
|
+
if (!modelStats[key]) {
|
|
129
|
+
modelStats[key] = {
|
|
130
|
+
input: 0,
|
|
131
|
+
output: 0,
|
|
132
|
+
cache_read: 0,
|
|
133
|
+
cache_write: 0,
|
|
134
|
+
total: 0,
|
|
135
|
+
cost: 0,
|
|
136
|
+
msgs: 0,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const ms = modelStats[key];
|
|
140
|
+
ms.input += tIn;
|
|
141
|
+
ms.output += tOut;
|
|
142
|
+
ms.cache_read += tCR;
|
|
143
|
+
ms.cache_write += tCW;
|
|
144
|
+
ms.total += tTot;
|
|
145
|
+
ms.cost += tCost;
|
|
146
|
+
ms.msgs += currentAssistantMsgs.length;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const turns: ParsedSession["turns"] = [];
|
|
150
|
+
const modelStats: Record<
|
|
151
|
+
string,
|
|
152
|
+
ParsedSession["stats"]
|
|
153
|
+
> = {};
|
|
154
|
+
|
|
155
|
+
for (const d of lines) {
|
|
156
|
+
if (d.type === "user") {
|
|
157
|
+
const msg = (d.message ?? {}) as Record<string, unknown>;
|
|
158
|
+
const text = extractUserText(msg);
|
|
159
|
+
if (
|
|
160
|
+
text &&
|
|
161
|
+
!text.startsWith("[{") &&
|
|
162
|
+
!text.startsWith("Continue from")
|
|
163
|
+
) {
|
|
164
|
+
flushTurn();
|
|
165
|
+
currentUserText = text;
|
|
166
|
+
currentAssistantMsgs = [];
|
|
167
|
+
}
|
|
168
|
+
} else if (d.type === "assistant") {
|
|
169
|
+
const msg = (d.message ?? {}) as Record<string, unknown>;
|
|
170
|
+
const u = (msg.usage ?? {}) as Record<string, number>;
|
|
171
|
+
const inp = u.input_tokens ?? 0;
|
|
172
|
+
const out = u.output_tokens ?? 0;
|
|
173
|
+
const cr = u.cache_read_input_tokens ?? 0;
|
|
174
|
+
const cw = u.cache_creation_input_tokens ?? 0;
|
|
175
|
+
if (inp === 0 && out === 0 && cr === 0 && cw === 0) continue;
|
|
176
|
+
|
|
177
|
+
const model = String(msg.model ?? "unknown");
|
|
178
|
+
const ts = String(d.timestamp ?? "");
|
|
179
|
+
const price = getPrice(model);
|
|
180
|
+
const cost =
|
|
181
|
+
(inp * price.input +
|
|
182
|
+
out * price.output +
|
|
183
|
+
cr * price.cacheRead) /
|
|
184
|
+
1_000_000;
|
|
185
|
+
|
|
186
|
+
currentAssistantMsgs.push({
|
|
187
|
+
model,
|
|
188
|
+
input: inp,
|
|
189
|
+
output: out,
|
|
190
|
+
cache_read: cr,
|
|
191
|
+
cache_write: cw,
|
|
192
|
+
total: inp + out + cr + cw,
|
|
193
|
+
cost,
|
|
194
|
+
timestamp: ts,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
flushTurn();
|
|
199
|
+
|
|
200
|
+
// For simplicity: each session file → one ParsedSession per dominant model
|
|
201
|
+
const dominantModel =
|
|
202
|
+
Object.entries(modelStats).sort(
|
|
203
|
+
(a, b) => b[1].cost - a[1].cost
|
|
204
|
+
)[0]?.[0] ?? "unknown";
|
|
205
|
+
const ms = modelStats[dominantModel];
|
|
206
|
+
if (!ms || ms.msgs === 0) continue;
|
|
207
|
+
|
|
208
|
+
sessions.push({
|
|
209
|
+
source: "claude",
|
|
210
|
+
project: short,
|
|
211
|
+
sid,
|
|
212
|
+
date: turns[0]?.time?.slice(0, 10) ?? "",
|
|
213
|
+
first_ts: turns[0]?.time ?? "",
|
|
214
|
+
model: dominantModel,
|
|
215
|
+
stats: {
|
|
216
|
+
...ms,
|
|
217
|
+
cost: Math.round(ms.cost * 10000) / 10000,
|
|
218
|
+
},
|
|
219
|
+
turns,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return sessions;
|
|
225
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { ParsedSession, PricingTriple } from "./types";
|
|
4
|
+
|
|
5
|
+
const CODEX_DIR = path.join(
|
|
6
|
+
process.env.HOME || process.env.USERPROFILE || "~",
|
|
7
|
+
".codex"
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
const PRICING: Record<string, PricingTriple> = {
|
|
11
|
+
"gpt-5.4": { input: 2.5, output: 10.0, cacheRead: 1.25 },
|
|
12
|
+
"gpt-4o": { input: 2.5, output: 10.0, cacheRead: 1.25 },
|
|
13
|
+
"o3": { input: 2.0, output: 8.0, cacheRead: 0.2 },
|
|
14
|
+
"o4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075 },
|
|
15
|
+
"o3-mini": { input: 1.1, output: 4.4, cacheRead: 0 },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function getPrice(model: string): PricingTriple {
|
|
19
|
+
for (const [k, v] of Object.entries(PRICING)) {
|
|
20
|
+
if (model.includes(k)) return v;
|
|
21
|
+
}
|
|
22
|
+
return { input: 0, output: 0, cacheRead: 0 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface TurnBuilder {
|
|
26
|
+
userText: string;
|
|
27
|
+
startTime: string;
|
|
28
|
+
model: string;
|
|
29
|
+
tokenCalls: { input: number; output: number; cached: number; reasoning: number }[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function walkDir(dir: string): string[] {
|
|
33
|
+
const results: string[] = [];
|
|
34
|
+
if (!fs.existsSync(dir)) return results;
|
|
35
|
+
for (const entry of fs.readdirSync(dir).sort()) {
|
|
36
|
+
const full = path.join(dir, entry);
|
|
37
|
+
const stat = fs.statSync(full);
|
|
38
|
+
if (stat.isDirectory()) {
|
|
39
|
+
results.push(...walkDir(full));
|
|
40
|
+
} else if (full.endsWith(".jsonl")) {
|
|
41
|
+
results.push(full);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readJsonl(filePath: string): Record<string, unknown>[] {
|
|
48
|
+
const results: Record<string, unknown>[] = [];
|
|
49
|
+
for (const line of fs.readFileSync(filePath, "utf-8").split("\n")) {
|
|
50
|
+
const trimmed = line.trim();
|
|
51
|
+
if (!trimmed) continue;
|
|
52
|
+
try { results.push(JSON.parse(trimmed)); } catch { /* skip */ }
|
|
53
|
+
}
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseCodexSessions(codexDir?: string): ParsedSession[] {
|
|
58
|
+
const dir = codexDir ?? CODEX_DIR;
|
|
59
|
+
const sessionsDir = path.join(dir, "sessions");
|
|
60
|
+
if (!fs.existsSync(sessionsDir)) return [];
|
|
61
|
+
|
|
62
|
+
const sessions: ParsedSession[] = [];
|
|
63
|
+
const files = walkDir(sessionsDir);
|
|
64
|
+
|
|
65
|
+
for (const filePath of files) {
|
|
66
|
+
const lines = readJsonl(filePath);
|
|
67
|
+
|
|
68
|
+
// Extract session metadata
|
|
69
|
+
let cwd = "";
|
|
70
|
+
let sessionId = "";
|
|
71
|
+
let defaultModel = "unknown";
|
|
72
|
+
|
|
73
|
+
for (const d of lines) {
|
|
74
|
+
if (d.type === "session_meta" && d.payload) {
|
|
75
|
+
const p = d.payload as Record<string, unknown>;
|
|
76
|
+
cwd = String(p.cwd ?? "");
|
|
77
|
+
sessionId = String(p.id ?? "");
|
|
78
|
+
}
|
|
79
|
+
if (d.type === "turn_context" && d.payload) {
|
|
80
|
+
const p = d.payload as Record<string, unknown>;
|
|
81
|
+
if (p.model) defaultModel = String(p.model);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Build turns: user_message starts a turn, collect token_counts until next user_message
|
|
86
|
+
const turnBuilders: TurnBuilder[] = [];
|
|
87
|
+
let current: TurnBuilder | null = null;
|
|
88
|
+
|
|
89
|
+
for (const d of lines) {
|
|
90
|
+
if (d.type !== "event_msg" || !d.payload) continue;
|
|
91
|
+
const payload = d.payload as Record<string, unknown>;
|
|
92
|
+
const pt = String(payload.type ?? "");
|
|
93
|
+
|
|
94
|
+
if (pt === "user_message") {
|
|
95
|
+
if (current) turnBuilders.push(current);
|
|
96
|
+
current = {
|
|
97
|
+
userText: String(payload.message ?? "").slice(0, 200),
|
|
98
|
+
startTime: String(d.timestamp ?? ""),
|
|
99
|
+
model: defaultModel,
|
|
100
|
+
tokenCalls: [],
|
|
101
|
+
};
|
|
102
|
+
} else if (pt === "turn_context" && current) {
|
|
103
|
+
const m = (d.payload as Record<string, unknown>).model;
|
|
104
|
+
if (m) current.model = String(m);
|
|
105
|
+
} else if (pt === "token_count" && payload.info && current) {
|
|
106
|
+
const info = payload.info as Record<string, unknown>;
|
|
107
|
+
const last = info.last_token_usage as Record<string, number> | undefined;
|
|
108
|
+
if (last) {
|
|
109
|
+
current.tokenCalls.push({
|
|
110
|
+
input: last.input_tokens ?? 0,
|
|
111
|
+
output: last.output_tokens ?? 0,
|
|
112
|
+
cached: last.cached_input_tokens ?? 0,
|
|
113
|
+
reasoning: last.reasoning_output_tokens ?? 0,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (current) turnBuilders.push(current);
|
|
119
|
+
|
|
120
|
+
if (turnBuilders.length === 0) continue;
|
|
121
|
+
|
|
122
|
+
// Determine project from cwd
|
|
123
|
+
const project = cwd ? cwd.split("/").pop() || "unknown" : "unknown";
|
|
124
|
+
const sid = sessionId.slice(0, 8) || path.basename(filePath).slice(0, 8);
|
|
125
|
+
const date = turnBuilders[0].startTime.slice(0, 10);
|
|
126
|
+
const model = turnBuilders[0].model;
|
|
127
|
+
const price = getPrice(model);
|
|
128
|
+
|
|
129
|
+
let totalInput = 0;
|
|
130
|
+
let totalOutput = 0;
|
|
131
|
+
let totalCR = 0;
|
|
132
|
+
let totalCost = 0;
|
|
133
|
+
let totalMsgs = 0;
|
|
134
|
+
|
|
135
|
+
const parsedTurns = turnBuilders.map((t) => {
|
|
136
|
+
const tIn = t.tokenCalls.reduce((s, c) => s + c.input, 0);
|
|
137
|
+
const tOut = t.tokenCalls.reduce((s, c) => s + c.output + c.reasoning, 0);
|
|
138
|
+
const tCR = t.tokenCalls.reduce((s, c) => s + c.cached, 0);
|
|
139
|
+
const tTot = tIn + tOut + tCR;
|
|
140
|
+
const tCost = (tIn * price.input + tOut * price.output + tCR * price.cacheRead) / 1_000_000;
|
|
141
|
+
|
|
142
|
+
totalInput += tIn;
|
|
143
|
+
totalOutput += tOut;
|
|
144
|
+
totalCR += tCR;
|
|
145
|
+
totalCost += tCost;
|
|
146
|
+
totalMsgs += t.tokenCalls.length;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
user: t.userText,
|
|
150
|
+
time: t.startTime,
|
|
151
|
+
input: tIn,
|
|
152
|
+
output: tOut,
|
|
153
|
+
cache_read: tCR,
|
|
154
|
+
cache_write: 0,
|
|
155
|
+
total: tTot,
|
|
156
|
+
cost: Math.round(tCost * 10000) / 10000,
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
sessions.push({
|
|
161
|
+
source: "codex",
|
|
162
|
+
project,
|
|
163
|
+
sid,
|
|
164
|
+
date,
|
|
165
|
+
first_ts: turnBuilders[0].startTime,
|
|
166
|
+
model,
|
|
167
|
+
stats: {
|
|
168
|
+
input: totalInput,
|
|
169
|
+
output: totalOutput,
|
|
170
|
+
cache_read: totalCR,
|
|
171
|
+
cache_write: 0,
|
|
172
|
+
total: totalInput + totalOutput + totalCR,
|
|
173
|
+
cost: Math.round(totalCost * 10000) / 10000,
|
|
174
|
+
msgs: totalMsgs,
|
|
175
|
+
},
|
|
176
|
+
turns: parsedTurns,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return sessions;
|
|
181
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ParsedSession } from "./types.ts";
|
|
2
|
+
import { parseClaudeSessions } from "./claude.ts";
|
|
3
|
+
import { parseCodexSessions } from "./codex.ts";
|
|
4
|
+
import type { DashboardData, ModelData, SessionData } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
export { parseClaudeSessions, parseCodexSessions };
|
|
7
|
+
|
|
8
|
+
export interface ParseOptions {
|
|
9
|
+
claude?: boolean;
|
|
10
|
+
codex?: boolean;
|
|
11
|
+
jsonOnly?: boolean;
|
|
12
|
+
claudeDir?: string;
|
|
13
|
+
codexDir?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseAllSessions(options: ParseOptions = {}): ParsedSession[] {
|
|
17
|
+
const sessions: ParsedSession[] = [];
|
|
18
|
+
const wantClaude = options.codex !== true;
|
|
19
|
+
const wantCodex = options.claude !== true;
|
|
20
|
+
|
|
21
|
+
if (wantClaude) {
|
|
22
|
+
try { sessions.push(...parseClaudeSessions(options.claudeDir)); } catch { /* skip */ }
|
|
23
|
+
}
|
|
24
|
+
if (wantCodex) {
|
|
25
|
+
try { sessions.push(...parseCodexSessions(options.codexDir)); } catch { /* skip */ }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return sessions;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildDashboardData(sessions: ParsedSession[]): DashboardData {
|
|
32
|
+
const sources = new Set<"claude" | "codex">();
|
|
33
|
+
const modelsMap: Record<string, ModelData> = {};
|
|
34
|
+
|
|
35
|
+
for (const s of sessions) {
|
|
36
|
+
sources.add(s.source);
|
|
37
|
+
const modelKey = s.model;
|
|
38
|
+
if (!modelsMap[modelKey]) {
|
|
39
|
+
modelsMap[modelKey] = {
|
|
40
|
+
stats: { input: 0, output: 0, cache_read: 0, cache_write: 0, total: 0, cost: 0, msgs: 0 },
|
|
41
|
+
sessions: {},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const sessionKey = `${s.project}|${s.sid}`;
|
|
45
|
+
const sessionData: SessionData = {
|
|
46
|
+
source: s.source,
|
|
47
|
+
project: s.project,
|
|
48
|
+
sid: s.sid,
|
|
49
|
+
date: s.date,
|
|
50
|
+
first_ts: s.first_ts,
|
|
51
|
+
stats: s.stats,
|
|
52
|
+
turns: s.turns,
|
|
53
|
+
};
|
|
54
|
+
modelsMap[modelKey].sessions[sessionKey] = sessionData;
|
|
55
|
+
const ms = modelsMap[modelKey].stats;
|
|
56
|
+
ms.input += s.stats.input;
|
|
57
|
+
ms.output += s.stats.output;
|
|
58
|
+
ms.cache_read += s.stats.cache_read;
|
|
59
|
+
ms.cache_write += s.stats.cache_write;
|
|
60
|
+
ms.total += s.stats.total;
|
|
61
|
+
ms.cost += s.stats.cost;
|
|
62
|
+
ms.msgs += s.stats.msgs;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const grandTotal = { input: 0, output: 0, cache_read: 0, cache_write: 0, total: 0, cost: 0, msgs: 0 };
|
|
66
|
+
for (const md of Object.values(modelsMap)) {
|
|
67
|
+
grandTotal.input += md.stats.input;
|
|
68
|
+
grandTotal.output += md.stats.output;
|
|
69
|
+
grandTotal.cache_read += md.stats.cache_read;
|
|
70
|
+
grandTotal.cache_write += md.stats.cache_write;
|
|
71
|
+
grandTotal.total += md.stats.total;
|
|
72
|
+
grandTotal.cost += md.stats.cost;
|
|
73
|
+
grandTotal.msgs += md.stats.msgs;
|
|
74
|
+
}
|
|
75
|
+
grandTotal.cost = Math.round(grandTotal.cost * 10000) / 10000;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
generated: new Date().toISOString().slice(0, 16).replace("T", " "),
|
|
79
|
+
sources: Array.from(sources),
|
|
80
|
+
models: modelsMap,
|
|
81
|
+
grand_total: grandTotal,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface ParsedTurn {
|
|
2
|
+
user: string;
|
|
3
|
+
time: string;
|
|
4
|
+
input: number;
|
|
5
|
+
output: number;
|
|
6
|
+
cache_read: number;
|
|
7
|
+
cache_write: number;
|
|
8
|
+
total: number;
|
|
9
|
+
cost: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ParsedSession {
|
|
13
|
+
source: "claude" | "codex";
|
|
14
|
+
project: string;
|
|
15
|
+
sid: string;
|
|
16
|
+
date: string;
|
|
17
|
+
first_ts: string;
|
|
18
|
+
model: string;
|
|
19
|
+
stats: {
|
|
20
|
+
input: number;
|
|
21
|
+
output: number;
|
|
22
|
+
cache_read: number;
|
|
23
|
+
cache_write: number;
|
|
24
|
+
total: number;
|
|
25
|
+
cost: number;
|
|
26
|
+
msgs: number;
|
|
27
|
+
};
|
|
28
|
+
turns: ParsedTurn[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PricingTriple {
|
|
32
|
+
input: number;
|
|
33
|
+
output: number;
|
|
34
|
+
cacheRead: number;
|
|
35
|
+
}
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { DashboardData } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface PricingEntry {
|
|
4
|
+
id: string;
|
|
5
|
+
provider: string;
|
|
6
|
+
model: string;
|
|
7
|
+
input: number;
|
|
8
|
+
output: number;
|
|
9
|
+
cacheRead: number;
|
|
10
|
+
currency: "usd" | "cny";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CustomPrice {
|
|
14
|
+
input: number;
|
|
15
|
+
output: number;
|
|
16
|
+
cacheRead: number;
|
|
17
|
+
currency: "usd" | "cny";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PricingConfig {
|
|
21
|
+
modelMappings: Record<string, string>;
|
|
22
|
+
customPricing: Record<string, CustomPrice>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LS_KEY = "token-dashboard-pricing-config";
|
|
26
|
+
|
|
27
|
+
export const PRICING_DB: PricingEntry[] = [
|
|
28
|
+
// Anthropic (USD)
|
|
29
|
+
{ id: "anthropic/claude-opus-4.6", provider: "Anthropic", model: "Claude Opus 4.6", input: 15.0, output: 75.0, cacheRead: 1.50, currency: "usd" },
|
|
30
|
+
{ id: "anthropic/claude-sonnet-4.6", provider: "Anthropic", model: "Claude Sonnet 4.6", input: 3.0, output: 15.0, cacheRead: 0.30, currency: "usd" },
|
|
31
|
+
{ id: "anthropic/claude-haiku-4.5", provider: "Anthropic", model: "Claude Haiku 4.5", input: 0.80, output: 4.0, cacheRead: 0.08, currency: "usd" },
|
|
32
|
+
// OpenAI (USD)
|
|
33
|
+
{ id: "openai/gpt-4o", provider: "OpenAI", model: "GPT-4o", input: 2.50, output: 10.0, cacheRead: 1.25, currency: "usd" },
|
|
34
|
+
{ id: "openai/gpt-4o-mini", provider: "OpenAI", model: "GPT-4o mini", input: 0.15, output: 0.60, cacheRead: 0, currency: "usd" },
|
|
35
|
+
{ id: "openai/gpt-5.4", provider: "OpenAI", model: "GPT-5.4", input: 2.50, output: 10.0, cacheRead: 1.25, currency: "usd" },
|
|
36
|
+
{ id: "openai/o1", provider: "OpenAI", model: "o1", input: 15.0, output: 60.0, cacheRead: 0, currency: "usd" },
|
|
37
|
+
{ id: "openai/o3", provider: "OpenAI", model: "o3", input: 2.0, output: 8.0, cacheRead: 0.20, currency: "usd" },
|
|
38
|
+
{ id: "openai/o3-mini", provider: "OpenAI", model: "o3-mini", input: 1.10, output: 4.40, cacheRead: 0, currency: "usd" },
|
|
39
|
+
{ id: "openai/o4-mini", provider: "OpenAI", model: "o4-mini", input: 0.75, output: 4.50, cacheRead: 0.075, currency: "usd" },
|
|
40
|
+
// Google (USD)
|
|
41
|
+
{ id: "google/gemini-2.5-pro", provider: "Google", model: "Gemini 2.5 Pro", input: 1.25, output: 10.0, cacheRead: 0.125, currency: "usd" },
|
|
42
|
+
{ id: "google/gemini-2.5-flash", provider: "Google", model: "Gemini 2.5 Flash", input: 0.15, output: 1.25, cacheRead: 0.03, currency: "usd" },
|
|
43
|
+
{ id: "google/gemini-2.0-flash", provider: "Google", model: "Gemini 2.0 Flash", input: 0.10, output: 0.40, cacheRead: 0.025, currency: "usd" },
|
|
44
|
+
// Zhipu (CNY)
|
|
45
|
+
{ id: "zhipu/glm-5-turbo", provider: "Zhipu", model: "GLM-5-Turbo", input: 5.0, output: 22.0, cacheRead: 1.20, currency: "cny" },
|
|
46
|
+
{ id: "zhipu/glm-5", provider: "Zhipu", model: "GLM-5", input: 4.0, output: 18.0, cacheRead: 1.0, currency: "cny" },
|
|
47
|
+
{ id: "zhipu/glm-5.1", provider: "Zhipu", model: "GLM-5.1", input: 4.0, output: 18.0, cacheRead: 1.0, currency: "cny" },
|
|
48
|
+
{ id: "zhipu/glm-4.7", provider: "Zhipu", model: "GLM-4.7", input: 2.0, output: 8.0, cacheRead: 0.40, currency: "cny" },
|
|
49
|
+
{ id: "zhipu/glm-4.5-air", provider: "Zhipu", model: "GLM-4.5-Air", input: 0.80, output: 2.0, cacheRead: 0.16, currency: "cny" },
|
|
50
|
+
// DeepSeek (CNY)
|
|
51
|
+
{ id: "deepseek/v3", provider: "DeepSeek", model: "DeepSeek-V3", input: 2.0, output: 3.0, cacheRead: 0.20, currency: "cny" },
|
|
52
|
+
{ id: "deepseek/r1", provider: "DeepSeek", model: "DeepSeek-R1", input: 2.0, output: 3.0, cacheRead: 0.20, currency: "cny" },
|
|
53
|
+
// Mistral (USD)
|
|
54
|
+
{ id: "mistral/large", provider: "Mistral", model: "Mistral Large", input: 2.0, output: 6.0, cacheRead: 0, currency: "usd" },
|
|
55
|
+
{ id: "mistral/medium", provider: "Mistral", model: "Mistral Medium", input: 1.0, output: 3.0, cacheRead: 0, currency: "usd" },
|
|
56
|
+
{ id: "mistral/codestral", provider: "Mistral", model: "Codestral", input: 0.20, output: 0.60, cacheRead: 0, currency: "usd" },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export function calculateCost(
|
|
60
|
+
input: number,
|
|
61
|
+
output: number,
|
|
62
|
+
cacheRead: number,
|
|
63
|
+
price: { input: number; output: number; cacheRead: number }
|
|
64
|
+
): number {
|
|
65
|
+
return (input * price.input + output * price.output + cacheRead * price.cacheRead) / 1_000_000;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function findPricingEntry(id: string): PricingEntry | undefined {
|
|
69
|
+
return PRICING_DB.find((e) => e.id === id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function detectModels(data: DashboardData): string[] {
|
|
73
|
+
return Object.keys(data.models);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function fuzzyMatchModel(modelName: string): PricingEntry | undefined {
|
|
77
|
+
const lower = modelName.toLowerCase();
|
|
78
|
+
// Try exact id match first (e.g. "zhipu/glm-5.1")
|
|
79
|
+
const exact = PRICING_DB.find((e) => e.id === lower || e.model.toLowerCase() === lower);
|
|
80
|
+
if (exact) return exact;
|
|
81
|
+
// Substring matching: check if the model name contains or is contained in an entry's id/model
|
|
82
|
+
for (const entry of PRICING_DB) {
|
|
83
|
+
const idParts = entry.id.split("/")[1] || entry.id;
|
|
84
|
+
if (lower.includes(idParts) || idParts.includes(lower)) return entry;
|
|
85
|
+
}
|
|
86
|
+
// Broader matching by provider
|
|
87
|
+
for (const entry of PRICING_DB) {
|
|
88
|
+
if (lower.includes(entry.provider.toLowerCase())) return entry;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getEffectivePricing(
|
|
94
|
+
modelName: string,
|
|
95
|
+
config: PricingConfig
|
|
96
|
+
): { input: number; output: number; cacheRead: number; currency: "usd" | "cny" } {
|
|
97
|
+
// 1. Custom pricing override
|
|
98
|
+
const custom = config.customPricing[modelName];
|
|
99
|
+
if (custom) return { input: custom.input, output: custom.output, cacheRead: custom.cacheRead, currency: custom.currency };
|
|
100
|
+
// 2. Mapped pricing entry
|
|
101
|
+
const mappedId = config.modelMappings[modelName];
|
|
102
|
+
if (mappedId) {
|
|
103
|
+
const entry = findPricingEntry(mappedId);
|
|
104
|
+
if (entry) return { input: entry.input, output: entry.output, cacheRead: entry.cacheRead, currency: entry.currency };
|
|
105
|
+
}
|
|
106
|
+
// 3. Fuzzy match
|
|
107
|
+
const fuzzy = fuzzyMatchModel(modelName);
|
|
108
|
+
if (fuzzy) return { input: fuzzy.input, output: fuzzy.output, cacheRead: fuzzy.cacheRead, currency: fuzzy.currency };
|
|
109
|
+
// 4. Fallback
|
|
110
|
+
return { input: 0, output: 0, cacheRead: 0, currency: "usd" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getDefaultConfig(data: DashboardData): PricingConfig {
|
|
114
|
+
const config: PricingConfig = { modelMappings: {}, customPricing: {} };
|
|
115
|
+
for (const modelName of detectModels(data)) {
|
|
116
|
+
const match = fuzzyMatchModel(modelName);
|
|
117
|
+
if (match) {
|
|
118
|
+
config.modelMappings[modelName] = match.id;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return config;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function loadConfig(data: DashboardData): PricingConfig {
|
|
125
|
+
try {
|
|
126
|
+
const raw = localStorage.getItem(LS_KEY);
|
|
127
|
+
if (raw) {
|
|
128
|
+
const parsed = JSON.parse(raw) as PricingConfig;
|
|
129
|
+
if (parsed.modelMappings && parsed.customPricing) return parsed;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// ignore
|
|
133
|
+
}
|
|
134
|
+
return getDefaultConfig(data);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function saveConfig(config: PricingConfig): void {
|
|
138
|
+
localStorage.setItem(LS_KEY, JSON.stringify(config));
|
|
139
|
+
}
|