@zhangferry-dev/tokendash 1.0.1 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-BaY47OM2.css +1 -0
- package/dist/client/assets/{index-DOeZtR1c.js → index-CVEOcjaP.js} +50 -50
- package/dist/client/index.html +3 -3
- package/dist/server/ccusage.d.ts +0 -1
- package/dist/server/ccusage.js +6 -10
- package/dist/server/claudeBlocksParser.d.ts +6 -0
- package/dist/server/claudeBlocksParser.js +150 -0
- package/dist/server/codexParser.d.ts +47 -0
- package/dist/server/codexParser.js +379 -0
- package/dist/server/codexPricing.d.ts +32 -0
- package/dist/server/codexPricing.js +43 -0
- package/dist/server/index.js +97 -12
- package/dist/server/routes/blocks.js +28 -3
- package/dist/server/routes/daily.js +5 -7
- package/dist/server/routes/projects.js +5 -7
- package/dist/shared/schemas.d.ts +17 -17
- package/package.json +2 -2
- package/dist/client/assets/index-C9UxEhwo.css +0 -1
- package/dist/server/codexNormalizer.d.ts +0 -4
- package/dist/server/codexNormalizer.js +0 -50
package/dist/client/index.html
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>
|
|
6
|
+
<title>TokenDash</title>
|
|
7
7
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='28' font-size='28'>⚡</text></svg>" />
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CVEOcjaP.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BaY47OM2.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body class="antialiased" style="background:#faf9f7">
|
|
12
12
|
<div id="root"></div>
|
package/dist/server/ccusage.d.ts
CHANGED
package/dist/server/ccusage.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
+
import { isSessionsDirAccessible } from './codexParser.js';
|
|
3
4
|
const execFileAsync = promisify(execFile);
|
|
4
5
|
function withJsonFlag(args, asJson) {
|
|
5
6
|
if (!asJson || args.includes('--json')) {
|
|
@@ -39,19 +40,14 @@ async function runCcusageCommand(args, timeout, asJson) {
|
|
|
39
40
|
throw error;
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
|
-
async function runCodexCommand(args, timeout, asJson) {
|
|
43
|
-
return runCommand({
|
|
44
|
-
command: 'npx',
|
|
45
|
-
args: ['--yes', '@ccusage/codex@latest', ...withJsonFlag(args, asJson)],
|
|
46
|
-
}, timeout);
|
|
47
|
-
}
|
|
48
43
|
export async function runCcusage(args, timeout = 30_000) {
|
|
49
44
|
return runCcusageCommand(args, timeout, true);
|
|
50
45
|
}
|
|
51
|
-
export async function runCodex(args, timeout = 30_000) {
|
|
52
|
-
return runCodexCommand(args, timeout, true);
|
|
53
|
-
}
|
|
54
46
|
export async function ensureUsageToolsReady() {
|
|
47
|
+
// Claude Code: check ccusage CLI
|
|
55
48
|
await runCcusageCommand(['--version'], 120_000, false);
|
|
56
|
-
|
|
49
|
+
// Codex: check local sessions directory (instant, no npm subprocess)
|
|
50
|
+
if (!isSessionsDirAccessible()) {
|
|
51
|
+
throw new Error('Codex sessions directory not found at ~/.codex/sessions/');
|
|
52
|
+
}
|
|
57
53
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BlockEntry } from '../shared/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parse Claude Code JSONL files and return hourly blocks, optionally filtered by project.
|
|
4
|
+
* Only returns blocks for the specified project (or all if project is empty).
|
|
5
|
+
*/
|
|
6
|
+
export declare function getClaudeBlocksByProject(project: string): BlockEntry[];
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Zod schemas for Claude JSONL usage validation
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
const ClaudeUsageSchema = z.object({
|
|
9
|
+
input_tokens: z.number().default(0),
|
|
10
|
+
output_tokens: z.number().default(0),
|
|
11
|
+
cache_creation_input_tokens: z.number().default(0),
|
|
12
|
+
cache_read_input_tokens: z.number().default(0),
|
|
13
|
+
}).passthrough().default({});
|
|
14
|
+
const ClaudeMessageSchema = z.object({
|
|
15
|
+
usage: ClaudeUsageSchema,
|
|
16
|
+
model: z.string().optional(),
|
|
17
|
+
}).passthrough();
|
|
18
|
+
const ClaudeEventSchema = z.object({
|
|
19
|
+
type: z.string(),
|
|
20
|
+
timestamp: z.string(),
|
|
21
|
+
message: ClaudeMessageSchema.optional(),
|
|
22
|
+
}).passthrough();
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
27
|
+
/** Extract project display name from encoded directory path.
|
|
28
|
+
* -Users-zhangferry-AI-Ideas → Ideas
|
|
29
|
+
* -Users-zhangferry-Desktop-Develop-DailyNewsReport → DailyNewsReport
|
|
30
|
+
*/
|
|
31
|
+
function extractProjectName(dirName) {
|
|
32
|
+
const parts = dirName.replace(/^-/, '').split('-');
|
|
33
|
+
return parts[parts.length - 1] || dirName;
|
|
34
|
+
}
|
|
35
|
+
/** Match project display name against a filter (also normalizes the filter) */
|
|
36
|
+
function matchesProject(dirName, filter) {
|
|
37
|
+
return extractProjectName(dirName) === extractProjectName(filter);
|
|
38
|
+
}
|
|
39
|
+
function getHourKey(timestamp) {
|
|
40
|
+
const d = new Date(timestamp);
|
|
41
|
+
const yyyy = d.getFullYear();
|
|
42
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
43
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
44
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
45
|
+
return `${yyyy}-${mm}-${dd}T${hh}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Parse Claude Code JSONL files and return hourly blocks, optionally filtered by project.
|
|
49
|
+
* Only returns blocks for the specified project (or all if project is empty).
|
|
50
|
+
*/
|
|
51
|
+
export function getClaudeBlocksByProject(project) {
|
|
52
|
+
if (!existsSync(CLAUDE_PROJECTS_DIR))
|
|
53
|
+
return [];
|
|
54
|
+
const hourMap = new Map();
|
|
55
|
+
const projectDirs = readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true })
|
|
56
|
+
.filter(d => d.isDirectory())
|
|
57
|
+
.map(d => d.name);
|
|
58
|
+
for (const dirName of projectDirs) {
|
|
59
|
+
// If a project filter is set, skip non-matching directories
|
|
60
|
+
if (project && !matchesProject(dirName, project))
|
|
61
|
+
continue;
|
|
62
|
+
const dirPath = join(CLAUDE_PROJECTS_DIR, dirName);
|
|
63
|
+
let files;
|
|
64
|
+
try {
|
|
65
|
+
files = readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
const filePath = join(dirPath, file);
|
|
72
|
+
let content;
|
|
73
|
+
try {
|
|
74
|
+
content = readFileSync(filePath, 'utf-8');
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (const line of content.split('\n')) {
|
|
80
|
+
const trimmed = line.trim();
|
|
81
|
+
if (!trimmed)
|
|
82
|
+
continue;
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(trimmed);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
// Only process assistant events with usage data
|
|
91
|
+
const result = ClaudeEventSchema.safeParse(parsed);
|
|
92
|
+
if (!result.success)
|
|
93
|
+
continue;
|
|
94
|
+
const event = result.data;
|
|
95
|
+
if (event.type !== 'assistant' || !event.message)
|
|
96
|
+
continue;
|
|
97
|
+
const usage = event.message.usage;
|
|
98
|
+
const inputTokens = usage.input_tokens;
|
|
99
|
+
const outputTokens = usage.output_tokens;
|
|
100
|
+
const cacheCreationTokens = usage.cache_creation_input_tokens;
|
|
101
|
+
const cacheReadTokens = usage.cache_read_input_tokens;
|
|
102
|
+
const totalTokens = inputTokens + outputTokens + cacheReadTokens;
|
|
103
|
+
if (totalTokens === 0)
|
|
104
|
+
continue;
|
|
105
|
+
const hourKey = getHourKey(event.timestamp);
|
|
106
|
+
if (!hourMap.has(hourKey)) {
|
|
107
|
+
hourMap.set(hourKey, {
|
|
108
|
+
inputTokens: 0, outputTokens: 0,
|
|
109
|
+
cacheCreationTokens: 0, cacheReadTokens: 0,
|
|
110
|
+
models: new Set(),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const bucket = hourMap.get(hourKey);
|
|
114
|
+
bucket.inputTokens += inputTokens;
|
|
115
|
+
bucket.outputTokens += outputTokens;
|
|
116
|
+
bucket.cacheCreationTokens += cacheCreationTokens;
|
|
117
|
+
bucket.cacheReadTokens += cacheReadTokens;
|
|
118
|
+
if (event.message.model)
|
|
119
|
+
bucket.models.add(event.message.model);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Convert hour map to BlockEntry[]
|
|
124
|
+
const blocks = [];
|
|
125
|
+
let idx = 0;
|
|
126
|
+
for (const [hourKey, bucket] of hourMap) {
|
|
127
|
+
const totalTokens = bucket.inputTokens + bucket.outputTokens + bucket.cacheReadTokens;
|
|
128
|
+
blocks.push({
|
|
129
|
+
id: `claude-project-${idx}`,
|
|
130
|
+
startTime: `${hourKey}:00:00.000Z`,
|
|
131
|
+
endTime: `${hourKey}:59:59.999Z`,
|
|
132
|
+
actualEndTime: null,
|
|
133
|
+
isActive: false,
|
|
134
|
+
isGap: false,
|
|
135
|
+
entries: totalTokens > 0 ? 1 : 0,
|
|
136
|
+
tokenCounts: {
|
|
137
|
+
inputTokens: bucket.inputTokens,
|
|
138
|
+
outputTokens: bucket.outputTokens,
|
|
139
|
+
cacheCreationInputTokens: bucket.cacheCreationTokens,
|
|
140
|
+
cacheReadInputTokens: bucket.cacheReadTokens,
|
|
141
|
+
},
|
|
142
|
+
totalTokens,
|
|
143
|
+
costUSD: 0,
|
|
144
|
+
models: [...bucket.models],
|
|
145
|
+
});
|
|
146
|
+
idx++;
|
|
147
|
+
}
|
|
148
|
+
blocks.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
|
149
|
+
return blocks;
|
|
150
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { DailyResponse, ProjectsResponse, BlocksResponse } from '../shared/types.js';
|
|
2
|
+
interface ParsedTokenEvent {
|
|
3
|
+
timestamp: string;
|
|
4
|
+
inputTokens: number;
|
|
5
|
+
cachedInputTokens: number;
|
|
6
|
+
outputTokens: number;
|
|
7
|
+
reasoningOutputTokens: number;
|
|
8
|
+
totalTokens: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ParsedSession {
|
|
11
|
+
id: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
model: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
tokenEvents: ParsedTokenEvent[];
|
|
16
|
+
}
|
|
17
|
+
export interface AggregateOptions {
|
|
18
|
+
groupBy: 'day' | 'hour' | 'month' | 'session' | 'project';
|
|
19
|
+
project?: string | null;
|
|
20
|
+
since?: Date | null;
|
|
21
|
+
until?: Date | null;
|
|
22
|
+
timezone?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function isSessionsDirAccessible(): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Recursively find all .jsonl files under ~/.codex/sessions/
|
|
27
|
+
*/
|
|
28
|
+
export declare function scanCodexSessions(): string[];
|
|
29
|
+
/**
|
|
30
|
+
* Parse a single Codex session JSONL file.
|
|
31
|
+
*
|
|
32
|
+
* CRITICAL INVARIANT: Sum ALL token_count events without any deduplication.
|
|
33
|
+
* Each Codex turn emits TWO token_count events with identical last_token_usage
|
|
34
|
+
* values (~1-2s apart) — one for reasoning, one for output completion.
|
|
35
|
+
* Both are distinct billable events. Deduplicating would produce the wrong
|
|
36
|
+
* total (4.7M instead of the correct 9.2M).
|
|
37
|
+
*/
|
|
38
|
+
export declare function parseCodexSession(filepath: string): ParsedSession | null;
|
|
39
|
+
/** Parse all Codex sessions. */
|
|
40
|
+
export declare function parseAllSessions(): ParsedSession[];
|
|
41
|
+
/** Aggregate and return DailyResponse format (for /daily?agent=codex) */
|
|
42
|
+
export declare function getDailyResponse(options?: Partial<AggregateOptions>): DailyResponse;
|
|
43
|
+
/** Aggregate and return ProjectsResponse format (for /projects?agent=codex) */
|
|
44
|
+
export declare function getProjectsResponse(options?: Partial<AggregateOptions>): ProjectsResponse;
|
|
45
|
+
/** Aggregate and return BlocksResponse format (hourly, for /blocks?agent=codex) */
|
|
46
|
+
export declare function getBlocksResponse(options?: Partial<AggregateOptions>): BlocksResponse;
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync, accessSync, constants } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { calculateCost } from './codexPricing.js';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Zod schemas for JSONL event validation (format change detector)
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const TokenUsageSchema = z.object({
|
|
10
|
+
input_tokens: z.number().default(0),
|
|
11
|
+
cached_input_tokens: z.number().default(0),
|
|
12
|
+
output_tokens: z.number().default(0),
|
|
13
|
+
reasoning_output_tokens: z.number().default(0),
|
|
14
|
+
total_tokens: z.number().default(0),
|
|
15
|
+
}).default({ input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0, total_tokens: 0 });
|
|
16
|
+
const TokenCountInfoSchema = z.object({
|
|
17
|
+
total_token_usage: TokenUsageSchema,
|
|
18
|
+
last_token_usage: TokenUsageSchema,
|
|
19
|
+
}).nullable().default(null);
|
|
20
|
+
const TokenCountPayloadSchema = z.object({
|
|
21
|
+
type: z.literal('token_count'),
|
|
22
|
+
info: TokenCountInfoSchema,
|
|
23
|
+
});
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
function getSessionsDir() {
|
|
28
|
+
return join(homedir(), '.codex', 'sessions');
|
|
29
|
+
}
|
|
30
|
+
export function isSessionsDirAccessible() {
|
|
31
|
+
try {
|
|
32
|
+
accessSync(getSessionsDir(), constants.R_OK);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Recursively find all .jsonl files under ~/.codex/sessions/
|
|
41
|
+
*/
|
|
42
|
+
export function scanCodexSessions() {
|
|
43
|
+
const sessionsDir = getSessionsDir();
|
|
44
|
+
const results = [];
|
|
45
|
+
function walk(dir) {
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = readdirSync(dir);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
const full = join(dir, entry);
|
|
55
|
+
let st;
|
|
56
|
+
try {
|
|
57
|
+
st = statSync(full);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (st.isDirectory()) {
|
|
63
|
+
walk(full);
|
|
64
|
+
}
|
|
65
|
+
else if (entry.endsWith('.jsonl')) {
|
|
66
|
+
results.push(full);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
walk(sessionsDir);
|
|
71
|
+
return results.sort();
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Parse a single Codex session JSONL file.
|
|
75
|
+
*
|
|
76
|
+
* CRITICAL INVARIANT: Sum ALL token_count events without any deduplication.
|
|
77
|
+
* Each Codex turn emits TWO token_count events with identical last_token_usage
|
|
78
|
+
* values (~1-2s apart) — one for reasoning, one for output completion.
|
|
79
|
+
* Both are distinct billable events. Deduplicating would produce the wrong
|
|
80
|
+
* total (4.7M instead of the correct 9.2M).
|
|
81
|
+
*/
|
|
82
|
+
export function parseCodexSession(filepath) {
|
|
83
|
+
let content;
|
|
84
|
+
try {
|
|
85
|
+
content = readFileSync(filepath, 'utf-8');
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const lines = content.split('\n');
|
|
91
|
+
let sessionId = '';
|
|
92
|
+
let cwd = '';
|
|
93
|
+
let model = '';
|
|
94
|
+
let createdAt = '';
|
|
95
|
+
const tokenEvents = [];
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
const trimmed = line.trim();
|
|
98
|
+
if (!trimmed)
|
|
99
|
+
continue;
|
|
100
|
+
let obj;
|
|
101
|
+
try {
|
|
102
|
+
obj = JSON.parse(trimmed);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const type = obj.type;
|
|
108
|
+
if (type === 'session_meta') {
|
|
109
|
+
const payload = obj.payload || {};
|
|
110
|
+
sessionId = payload.id || '';
|
|
111
|
+
cwd = payload.cwd || '';
|
|
112
|
+
createdAt = payload.timestamp || '';
|
|
113
|
+
}
|
|
114
|
+
if (type === 'turn_context') {
|
|
115
|
+
const payload = obj.payload || {};
|
|
116
|
+
if (!model && payload.model) {
|
|
117
|
+
model = payload.model;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Extract token counts from event_msg with nested token_count payload.
|
|
121
|
+
// NEVER deduplicate — see invariant comment above.
|
|
122
|
+
if (type === 'event_msg') {
|
|
123
|
+
const payload = obj.payload || {};
|
|
124
|
+
if (payload.type === 'token_count') {
|
|
125
|
+
const timestamp = obj.timestamp || '';
|
|
126
|
+
const parseResult = TokenCountPayloadSchema.safeParse(payload);
|
|
127
|
+
if (!parseResult.success) {
|
|
128
|
+
console.warn(`[codexParser] Schema validation failed in ${filepath}:`, parseResult.error.message);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const info = parseResult.data.info;
|
|
132
|
+
if (!info)
|
|
133
|
+
continue;
|
|
134
|
+
const last = info.last_token_usage;
|
|
135
|
+
tokenEvents.push({
|
|
136
|
+
timestamp,
|
|
137
|
+
inputTokens: last.input_tokens,
|
|
138
|
+
cachedInputTokens: last.cached_input_tokens,
|
|
139
|
+
outputTokens: last.output_tokens,
|
|
140
|
+
reasoningOutputTokens: last.reasoning_output_tokens,
|
|
141
|
+
totalTokens: last.total_tokens,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!sessionId)
|
|
147
|
+
return null;
|
|
148
|
+
return { id: sessionId, cwd, model, createdAt, tokenEvents };
|
|
149
|
+
}
|
|
150
|
+
/** Parse all Codex sessions. */
|
|
151
|
+
export function parseAllSessions() {
|
|
152
|
+
return scanCodexSessions()
|
|
153
|
+
.map(parseCodexSession)
|
|
154
|
+
.filter((s) => s !== null);
|
|
155
|
+
}
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Date/timezone helpers
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
const TZ_OFFSETS = {
|
|
160
|
+
'Asia/Shanghai': 8,
|
|
161
|
+
'Asia/Tokyo': 9,
|
|
162
|
+
'America/New_York': -5,
|
|
163
|
+
'America/Los_Angeles': -8,
|
|
164
|
+
'Europe/London': 0,
|
|
165
|
+
'UTC': 0,
|
|
166
|
+
};
|
|
167
|
+
function getTzOffsetHours(tz) {
|
|
168
|
+
return TZ_OFFSETS[tz] ?? 8; // Default Asia/Shanghai
|
|
169
|
+
}
|
|
170
|
+
function toLocalISO(ts, tz) {
|
|
171
|
+
const d = new Date(ts);
|
|
172
|
+
return new Date(d.getTime() + getTzOffsetHours(tz) * 3600_000);
|
|
173
|
+
}
|
|
174
|
+
function getDateKey(ts, tz) {
|
|
175
|
+
return toLocalISO(ts, tz).toISOString().slice(0, 10);
|
|
176
|
+
}
|
|
177
|
+
function getHourKey(ts, tz) {
|
|
178
|
+
const local = toLocalISO(ts, tz);
|
|
179
|
+
return local.toISOString().slice(0, 13).replace('T', ' ') + ':00';
|
|
180
|
+
}
|
|
181
|
+
function getMonthKey(ts, tz) {
|
|
182
|
+
return getDateKey(ts, tz).slice(0, 7);
|
|
183
|
+
}
|
|
184
|
+
function extractProjectName(cwd) {
|
|
185
|
+
if (!cwd)
|
|
186
|
+
return 'unknown';
|
|
187
|
+
const parts = cwd.replace(/\/+$/, '').split('/');
|
|
188
|
+
return parts[parts.length - 1] || 'unknown';
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Core aggregation
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
function emptyAcc() {
|
|
194
|
+
return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0 };
|
|
195
|
+
}
|
|
196
|
+
function addAcc(a, ev) {
|
|
197
|
+
a.inputTokens += ev.inputTokens;
|
|
198
|
+
a.cachedInputTokens += ev.cachedInputTokens;
|
|
199
|
+
a.outputTokens += ev.outputTokens;
|
|
200
|
+
a.reasoningOutputTokens += ev.reasoningOutputTokens;
|
|
201
|
+
a.totalTokens += ev.totalTokens;
|
|
202
|
+
}
|
|
203
|
+
function mergeAcc(a, b) {
|
|
204
|
+
a.inputTokens += b.inputTokens;
|
|
205
|
+
a.cachedInputTokens += b.cachedInputTokens;
|
|
206
|
+
a.outputTokens += b.outputTokens;
|
|
207
|
+
a.reasoningOutputTokens += b.reasoningOutputTokens;
|
|
208
|
+
a.totalTokens += b.totalTokens;
|
|
209
|
+
}
|
|
210
|
+
function accToEntry(date, acc, models) {
|
|
211
|
+
const cost = calculateCost(acc, models);
|
|
212
|
+
return {
|
|
213
|
+
date,
|
|
214
|
+
inputTokens: acc.inputTokens,
|
|
215
|
+
outputTokens: acc.outputTokens,
|
|
216
|
+
cacheCreationTokens: 0,
|
|
217
|
+
cacheReadTokens: acc.cachedInputTokens,
|
|
218
|
+
totalTokens: acc.totalTokens,
|
|
219
|
+
totalCost: cost,
|
|
220
|
+
modelsUsed: [...models],
|
|
221
|
+
modelBreakdowns: buildModelBreakdowns(acc, models, cost),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function buildModelBreakdowns(acc, models, totalCost) {
|
|
225
|
+
const modelList = [...models];
|
|
226
|
+
if (modelList.length === 0)
|
|
227
|
+
return [];
|
|
228
|
+
const costPerModel = totalCost / modelList.length;
|
|
229
|
+
return modelList.map(name => ({
|
|
230
|
+
modelName: name,
|
|
231
|
+
inputTokens: acc.inputTokens,
|
|
232
|
+
outputTokens: acc.outputTokens,
|
|
233
|
+
cacheCreationTokens: 0,
|
|
234
|
+
cacheReadTokens: acc.cachedInputTokens,
|
|
235
|
+
cost: costPerModel,
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
function groupSessions(sessions, options) {
|
|
239
|
+
const tz = options.timezone || 'Asia/Shanghai';
|
|
240
|
+
const grouped = new Map();
|
|
241
|
+
for (const session of sessions) {
|
|
242
|
+
if (options.project && extractProjectName(session.cwd) !== options.project)
|
|
243
|
+
continue;
|
|
244
|
+
for (const ev of session.tokenEvents) {
|
|
245
|
+
const evDate = new Date(ev.timestamp);
|
|
246
|
+
if (options.since && evDate < options.since)
|
|
247
|
+
continue;
|
|
248
|
+
if (options.until && evDate > options.until)
|
|
249
|
+
continue;
|
|
250
|
+
let key;
|
|
251
|
+
switch (options.groupBy) {
|
|
252
|
+
case 'hour':
|
|
253
|
+
key = getHourKey(ev.timestamp, tz);
|
|
254
|
+
break;
|
|
255
|
+
case 'month':
|
|
256
|
+
key = getMonthKey(ev.timestamp, tz);
|
|
257
|
+
break;
|
|
258
|
+
case 'session':
|
|
259
|
+
key = session.id;
|
|
260
|
+
break;
|
|
261
|
+
case 'project':
|
|
262
|
+
key = extractProjectName(session.cwd);
|
|
263
|
+
break;
|
|
264
|
+
default:
|
|
265
|
+
key = getDateKey(ev.timestamp, tz);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
if (!grouped.has(key)) {
|
|
269
|
+
grouped.set(key, { acc: emptyAcc(), models: new Set() });
|
|
270
|
+
}
|
|
271
|
+
const entry = grouped.get(key);
|
|
272
|
+
addAcc(entry.acc, ev);
|
|
273
|
+
if (session.model)
|
|
274
|
+
entry.models.add(session.model);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return grouped;
|
|
278
|
+
}
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Public API — response builders for route handlers
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
/** Aggregate and return DailyResponse format (for /daily?agent=codex) */
|
|
283
|
+
export function getDailyResponse(options) {
|
|
284
|
+
const sessions = parseAllSessions();
|
|
285
|
+
const grouped = groupSessions(sessions, { groupBy: 'day', ...options });
|
|
286
|
+
const daily = [];
|
|
287
|
+
const totalsAcc = emptyAcc();
|
|
288
|
+
for (const [date, { acc, models }] of grouped) {
|
|
289
|
+
daily.push(accToEntry(date, acc, models));
|
|
290
|
+
mergeAcc(totalsAcc, acc);
|
|
291
|
+
}
|
|
292
|
+
// Sort by date ascending
|
|
293
|
+
daily.sort((a, b) => a.date.localeCompare(b.date));
|
|
294
|
+
const models = new Set();
|
|
295
|
+
for (const s of sessions)
|
|
296
|
+
if (s.model)
|
|
297
|
+
models.add(s.model);
|
|
298
|
+
const totalCost = calculateCost(totalsAcc, models);
|
|
299
|
+
return {
|
|
300
|
+
daily,
|
|
301
|
+
totals: {
|
|
302
|
+
inputTokens: totalsAcc.inputTokens,
|
|
303
|
+
outputTokens: totalsAcc.outputTokens,
|
|
304
|
+
cacheCreationTokens: 0,
|
|
305
|
+
cacheReadTokens: totalsAcc.cachedInputTokens,
|
|
306
|
+
totalTokens: totalsAcc.totalTokens,
|
|
307
|
+
totalCost,
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** Aggregate and return ProjectsResponse format (for /projects?agent=codex) */
|
|
312
|
+
export function getProjectsResponse(options) {
|
|
313
|
+
const sessions = parseAllSessions();
|
|
314
|
+
const tz = options?.timezone || 'Asia/Shanghai';
|
|
315
|
+
const projects = {};
|
|
316
|
+
for (const session of sessions) {
|
|
317
|
+
const projectName = extractProjectName(session.cwd);
|
|
318
|
+
// Per-project daily grouping
|
|
319
|
+
const dailyMap = new Map();
|
|
320
|
+
for (const ev of session.tokenEvents) {
|
|
321
|
+
const evDate = new Date(ev.timestamp);
|
|
322
|
+
if (options?.since && evDate < options.since)
|
|
323
|
+
continue;
|
|
324
|
+
if (options?.until && evDate > options.until)
|
|
325
|
+
continue;
|
|
326
|
+
const dayKey = getDateKey(ev.timestamp, tz);
|
|
327
|
+
if (!dailyMap.has(dayKey)) {
|
|
328
|
+
dailyMap.set(dayKey, { acc: emptyAcc(), models: new Set() });
|
|
329
|
+
}
|
|
330
|
+
addAcc(dailyMap.get(dayKey).acc, ev);
|
|
331
|
+
if (session.model)
|
|
332
|
+
dailyMap.get(dayKey).models.add(session.model);
|
|
333
|
+
}
|
|
334
|
+
if (!projects[projectName])
|
|
335
|
+
projects[projectName] = [];
|
|
336
|
+
for (const [date, { acc, models }] of dailyMap) {
|
|
337
|
+
projects[projectName].push(accToEntry(date, acc, models));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Sort each project's daily entries
|
|
341
|
+
for (const key of Object.keys(projects)) {
|
|
342
|
+
projects[key].sort((a, b) => a.date.localeCompare(b.date));
|
|
343
|
+
}
|
|
344
|
+
return { projects };
|
|
345
|
+
}
|
|
346
|
+
/** Aggregate and return BlocksResponse format (hourly, for /blocks?agent=codex) */
|
|
347
|
+
export function getBlocksResponse(options) {
|
|
348
|
+
const sessions = parseAllSessions();
|
|
349
|
+
const grouped = groupSessions(sessions, { groupBy: 'hour', ...options });
|
|
350
|
+
const blocks = [];
|
|
351
|
+
let idx = 0;
|
|
352
|
+
for (const [hourKey, { acc, models }] of grouped) {
|
|
353
|
+
const cost = calculateCost(acc, models);
|
|
354
|
+
const [datePart, timePart] = hourKey.split(' ');
|
|
355
|
+
const hour = timePart.split(':')[0];
|
|
356
|
+
blocks.push({
|
|
357
|
+
id: `codex-hour-${idx}`,
|
|
358
|
+
startTime: `${datePart}T${hour}:00:00`,
|
|
359
|
+
endTime: `${datePart}T${hour}:59:59`,
|
|
360
|
+
actualEndTime: null,
|
|
361
|
+
isActive: false,
|
|
362
|
+
isGap: false,
|
|
363
|
+
entries: acc.totalTokens > 0 ? 1 : 0,
|
|
364
|
+
tokenCounts: {
|
|
365
|
+
inputTokens: acc.inputTokens,
|
|
366
|
+
outputTokens: acc.outputTokens,
|
|
367
|
+
cacheCreationInputTokens: 0,
|
|
368
|
+
cacheReadInputTokens: acc.cachedInputTokens,
|
|
369
|
+
},
|
|
370
|
+
totalTokens: acc.totalTokens,
|
|
371
|
+
costUSD: cost,
|
|
372
|
+
models: [...models],
|
|
373
|
+
});
|
|
374
|
+
idx++;
|
|
375
|
+
}
|
|
376
|
+
// Sort by startTime
|
|
377
|
+
blocks.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
|
378
|
+
return { blocks };
|
|
379
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex token pricing configuration.
|
|
3
|
+
*
|
|
4
|
+
* Pricing formula (confirmed by reverse-engineering @ccusage/codex):
|
|
5
|
+
* cost = (inputTokens - cachedInputTokens) * input_rate
|
|
6
|
+
* + cachedInputTokens * cached_rate
|
|
7
|
+
* + outputTokens * output_rate
|
|
8
|
+
*
|
|
9
|
+
* Reasoning tokens are NOT billed separately (included in outputTokens).
|
|
10
|
+
*
|
|
11
|
+
* Update rates from https://openai.com/api/pricing/ when models change.
|
|
12
|
+
* All prices are USD per 1M tokens.
|
|
13
|
+
*/
|
|
14
|
+
interface ModelPricing {
|
|
15
|
+
inputPer1M: number;
|
|
16
|
+
cachedInputPer1M: number;
|
|
17
|
+
outputPer1M: number;
|
|
18
|
+
}
|
|
19
|
+
interface TokenCounts {
|
|
20
|
+
inputTokens: number;
|
|
21
|
+
cachedInputTokens: number;
|
|
22
|
+
outputTokens: number;
|
|
23
|
+
reasoningOutputTokens: number;
|
|
24
|
+
totalTokens: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Calculate cost in USD from token counts and model pricing.
|
|
28
|
+
* Matches the @ccusage/codex calculateCostUSD function exactly.
|
|
29
|
+
*/
|
|
30
|
+
export declare function calculateCost(tokens: TokenCounts, models: Set<string>): number;
|
|
31
|
+
export declare function getModelPricing(model: string): ModelPricing;
|
|
32
|
+
export {};
|