claude-code-web
Version:
Web-based interface for Claude Code CLI accessible via browser
204 lines (184 loc) • 6.48 kB
JavaScript
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const readline = require('readline');
/**
* CodexUsageReader
*
* Reads Codex CLI session logs under ~/.codex/sessions and approximates
* token usage and cost. Codex JSONL does not expose usage directly, so we
* estimate tokens as chars/4 for user (input) and assistant (output) text.
*
* Pricing (approximate, per 1M tokens):
* - GPT-5 input: $1.25
* - GPT-5 output: $10.00
*
* These are applied to the estimated token counts.
*/
class CodexUsageReader {
constructor(options = {}) {
this.sessionsRoot = path.join(process.env.HOME || '/', '.codex', 'sessions');
// Defaults to GPT-5 since codex-bridge starts with -m gpt-5
this.model = options.model || 'gpt-5';
// Pricing per token
this.inputPricePerToken = options.inputPricePerToken || (1.25 / 1_000_000);
this.outputPricePerToken = options.outputPricePerToken || (10.0 / 1_000_000);
}
// Public: usage since N hours back (default 24)
async getUsageStats(hoursBack = 24) {
try {
const cutoff = new Date(Date.now() - hoursBack * 60 * 60 * 1000);
const files = await this.findJsonlFilesSince(cutoff);
if (files.length === 0) {
return this.emptyStats();
}
let inputChars = 0;
let outputChars = 0;
let requests = 0;
let firstTs = null;
let lastTs = null;
for (const file of files) {
const res = await this.readJsonlApprox(file, cutoff);
inputChars += res.inputChars;
outputChars += res.outputChars;
requests += res.requests;
if (res.firstTs && (!firstTs || res.firstTs < firstTs)) firstTs = res.firstTs;
if (res.lastTs && (!lastTs || res.lastTs > lastTs)) lastTs = res.lastTs;
}
const inputTokens = this.charsToTokens(inputChars);
const outputTokens = this.charsToTokens(outputChars);
const totalTokens = inputTokens + outputTokens;
const totalCost = (inputTokens * this.inputPricePerToken) + (outputTokens * this.outputPricePerToken);
const stats = {
provider: 'codex',
model: this.model,
requests,
inputTokens,
outputTokens,
totalTokens,
totalCost,
firstEntry: firstTs ? new Date(firstTs).toISOString() : null,
lastEntry: lastTs ? new Date(lastTs).toISOString() : null,
// model map mirrors UsageReader structure
models: {
[this.model]: {
requests,
inputTokens,
outputTokens,
cost: totalCost
}
}
};
return stats;
} catch (err) {
// Fail closed; never throw to callers
return this.emptyStats();
}
}
emptyStats() {
return {
provider: 'codex',
model: this.model,
requests: 0,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
totalCost: 0,
firstEntry: null,
lastEntry: null,
models: { [this.model]: { requests: 0, inputTokens: 0, outputTokens: 0, cost: 0 } }
};
}
charsToTokens(chars) {
// Rough heuristic: ~4 chars per token
return Math.ceil((chars || 0) / 4);
}
async findJsonlFilesSince(cutoff) {
const files = [];
try {
if (!fs.existsSync(this.sessionsRoot)) return files;
// Iterate days between cutoff and now; limit to 7 days for safety
const now = new Date();
const days = Math.max(1, Math.min(7, Math.ceil((now - cutoff) / (24 * 60 * 60 * 1000))));
for (let i = 0; i < days; i++) {
const d = new Date(now - i * 24 * 60 * 60 * 1000);
const y = String(d.getFullYear());
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const dayDir = path.join(this.sessionsRoot, y, m, dd);
try {
const entries = await fsp.readdir(dayDir);
for (const name of entries) {
if (name.endsWith('.jsonl')) {
const full = path.join(dayDir, name);
const stat = await fsp.stat(full);
if (stat.mtime >= cutoff) files.push(full);
}
}
} catch (_) {
// ignore missing day directory
}
}
} catch (_) {
// ignore errors
}
return files;
}
async readJsonlApprox(filePath, cutoff) {
return new Promise((resolve) => {
let inputChars = 0;
let outputChars = 0;
let requests = 0;
let firstTs = null;
let lastTs = null;
const rl = readline.createInterface({
input: fs.createReadStream(filePath),
crlfDelay: Infinity
});
rl.on('line', (line) => {
try {
const obj = JSON.parse(line);
// Timestamp filter
let ts = null;
if (obj.timestamp) ts = new Date(obj.timestamp).getTime();
if (ts && cutoff && ts < cutoff.getTime()) return;
if (ts) {
if (!firstTs || ts < firstTs) firstTs = ts;
if (!lastTs || ts > lastTs) lastTs = ts;
}
if (obj.type === 'message' && obj.role && Array.isArray(obj.content)) {
// Aggregate input_text for user
if (obj.role === 'user') {
for (const part of obj.content) {
if (part && part.type === 'input_text' && typeof part.text === 'string') {
inputChars += part.text.length;
}
}
}
// Aggregate output_text for assistant and count as a request
if (obj.role === 'assistant') {
let sawOutput = false;
for (const part of obj.content) {
if (part && (part.type === 'output_text' || part.type === 'input_text') && typeof part.text === 'string') {
// Some logs mirror text in input_text too; treat any assistant text as output
outputChars += part.text.length;
sawOutput = true;
}
}
if (sawOutput) requests += 1;
}
}
} catch (_) {
// Ignore malformed lines
}
});
rl.on('close', () => {
resolve({ inputChars, outputChars, requests, firstTs, lastTs });
});
rl.on('error', () => {
resolve({ inputChars: 0, outputChars: 0, requests: 0, firstTs: null, lastTs: null });
});
});
}
}
module.exports = CodexUsageReader;