95 lines
7.8 KiB
JavaScript
95 lines
7.8 KiB
JavaScript
const http = require('node:http');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const readline = require('node:readline');
|
|
|
|
const PORT = Number(process.env.PORT || 4173);
|
|
const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
const SESSIONS = path.join(CODEX_HOME, 'sessions');
|
|
const PUBLIC = path.join(__dirname, 'public');
|
|
|
|
// USD per 1M tokens. Source: official OpenAI pricing page, fetched 2026-07-10.
|
|
// Aliases are intentionally explicit: unknown/local models remain unpriced.
|
|
const prices = {
|
|
'gpt-5.6-sol': [5, .5, 30], 'gpt-5.6-terra': [2.5, .25, 15], 'gpt-5.6-luna': [1, .1, 6],
|
|
'gpt-5.5': [5, .5, 30], 'gpt-5.4': [2.5, .25, 15], 'gpt-5.4-mini': [.75, .075, 4.5],
|
|
'gpt-5.3-codex': [1.75, .175, 14], 'gpt-5.2-codex': [1.75, .175, 14],
|
|
'gpt-5.2': [1.75, .175, 14], 'gpt-5.1-codex-max': [1.25, .125, 10],
|
|
'gpt-5.1-codex': [1.25, .125, 10], 'gpt-5.1-codex-mini': [.25, .025, 2],
|
|
'gpt-5-codex': [1.25, .125, 10]
|
|
};
|
|
|
|
const empty = () => ({ input: 0, cached: 0, output: 0, reasoning: 0, total: 0, turns: 0, duration: 0, cost: 0, priced: 0 });
|
|
function add(a, b) { for (const k of ['input','cached','output','reasoning','total','turns','duration','cost','priced']) a[k] += b[k] || 0; return a; }
|
|
function cost(u, model) { const p = prices[model]; if (!p) return 0; return ((u.input-u.cached)*p[0] + u.cached*p[1] + u.output*p[2]) / 1e6; }
|
|
|
|
async function files(dir) {
|
|
const out=[]; if (!fs.existsSync(dir)) return out;
|
|
for (const e of await fs.promises.readdir(dir,{withFileTypes:true})) {
|
|
const p=path.join(dir,e.name); if(e.isDirectory()) out.push(...await files(p)); else if(e.name.endsWith('.jsonl')) out.push(p);
|
|
} return out;
|
|
}
|
|
|
|
async function parse(file) {
|
|
let session={ id:path.basename(file,'.jsonl'), started:'', cwd:'unknown', project:'unknown', source:'unknown', models:new Set(), usage:empty(), events:[] };
|
|
let model='unknown', previousTotal=null;
|
|
const rl=readline.createInterface({input:fs.createReadStream(file),crlfDelay:Infinity});
|
|
for await (const line of rl) try {
|
|
const x=JSON.parse(line), p=x.payload||{};
|
|
if(x.type==='session_meta'){ session.id=p.id||session.id; session.started=p.timestamp||x.timestamp; session.cwd=p.cwd||'unknown'; session.project=path.basename(session.cwd)||session.cwd; session.source=typeof p.source==='string'?p.source:(p.source?.subagent?'subagent':'app'); }
|
|
if(x.type==='turn_context' && p.model){ model=p.model; session.models.add(model); }
|
|
if(x.type==='event_msg' && p.type==='token_count' && (p.info?.total_token_usage||p.info?.last_token_usage)){
|
|
// `total_token_usage` is Codex's authoritative session counter and is what
|
|
// the CLI prints on exit. Deriving deltas from it avoids double-counting
|
|
// occasional replayed/revised `last_token_usage` records.
|
|
const t=p.info.total_token_usage||p.info.last_token_usage, cumulative=!!p.info.total_token_usage;
|
|
const value=k=>Math.max(0,(t[k]||0)-(cumulative&&previousTotal?(previousTotal[k]||0):0));
|
|
const u={input:value('input_tokens'),cached:value('cached_input_tokens'),output:value('output_tokens'),reasoning:value('reasoning_output_tokens'),total:value('total_tokens'),turns:0,duration:0};
|
|
if(cumulative) previousTotal=t;
|
|
u.cost=cost(u,model); u.priced=prices[model]?u.total:0; add(session.usage,u); session.events.push({at:x.timestamp||session.started,model,...u});
|
|
}
|
|
if(x.type==='event_msg' && p.type==='task_complete'){ session.usage.turns++; session.usage.duration+=(p.duration_ms||0); }
|
|
} catch {}
|
|
session.models=[...session.models]; return session;
|
|
}
|
|
|
|
let cache={loading:true,scannedAt:null,sessions:[],error:null}, scanPromise=null;
|
|
async function scan(){
|
|
if(scanPromise)return scanPromise;
|
|
scanPromise=(async()=>{cache.loading=true;try{const list=await files(SESSIONS);const sessions=[];for(let i=0;i<list.length;i+=12)sessions.push(...await Promise.all(list.slice(i,i+12).map(parse)));cache={loading:false,scannedAt:new Date().toISOString(),sessions,error:null};}catch(e){cache={...cache,loading:false,error:e.message};}finally{scanPromise=null;}})();
|
|
return scanPromise;
|
|
}
|
|
|
|
function stats(url){
|
|
const from=url.searchParams.get('from'), to=url.searchParams.get('to'), modelFilter=url.searchParams.get('model'), projectFilter=url.searchParams.get('project'), sourceFilter=url.searchParams.get('source');
|
|
// Usage belongs to the time of the model call, not the time the chat began.
|
|
// A Codex session can remain active across several days, so filtering sessions
|
|
// by `started` silently drops later usage from long-running chats.
|
|
const allSessions=cache.sessions.filter(s=>!projectFilter||s.project===projectFilter);
|
|
const events=allSessions.flatMap(s=>s.events.map(e=>({...e,session:s.id,project:s.project,source:s.source}))).filter(e=>(!from||e.at>=from)&&(!to||e.at<to+'T23:59:59.999Z')&&(!modelFilter||e.model===modelFilter)&&(!sourceFilter||e.source===sourceFilter));
|
|
const matchedIds=new Set(events.map(e=>e.session));
|
|
const sessions=allSessions.filter(s=>matchedIds.has(s.id));
|
|
const total=empty(), byDay={}, byModel={}, byProject={}, dayModels={};
|
|
for(const e of events){ add(total,e); const day=(e.at||'unknown').slice(0,10); add(byDay[day]??=empty(),e); add(byModel[e.model]??=empty(),e); add(byProject[e.project]??=empty(),e); add((dayModels[day]??={})[e.model]??=empty(),e); }
|
|
for(const s of sessions){ if(modelFilter && !s.models.includes(modelFilter)) continue; total.turns+=s.usage.turns; total.duration+=s.usage.duration; (byProject[s.project]??=empty()).turns+=s.usage.turns; (byProject[s.project]).duration+=s.usage.duration; }
|
|
const rows=o=>Object.entries(o).map(([name,value])=>({name,...value})).filter(x=>x.total>0).sort((a,b)=>b.total-a.total);
|
|
const bySession={}; for(const e of events){ const row=bySession[e.session]??={id:e.session,started:e.at,project:e.project,source:e.source,models:new Set(),...empty()}; row.models.add(e.model); add(row,e); if(e.at<row.started)row.started=e.at; }
|
|
const sessionRows=Object.values(bySession).map(s=>({...s,models:undefined,model:[...s.models].join(', ')})).sort((a,b)=>b.total-a.total);
|
|
const days=rows(byDay).sort((a,b)=>a.name.localeCompare(b.name)).map(d=>({...d,models:Object.fromEntries(Object.entries(dayModels[d.name]||{}).map(([m,u])=>[m,u.total]))}));
|
|
const modelRows=rows(byModel);
|
|
return {meta:{schemaVersion:2,scannedAt:cache.scannedAt,files:cache.sessions.length,matchedSessions:sessions.length,events:events.length,pricingSource:'https://developers.openai.com/api/docs/pricing'},total,days,models:modelRows,projects:rows(byProject),sessions:sessionRows.slice(0,100),filters:{models:modelRows.map(x=>x.name).sort(),projects:[...new Set(cache.sessions.filter(s=>s.usage.total>0).map(s=>s.project))].sort(),sources:[...new Set(cache.sessions.map(s=>s.source))].sort()},prices:Object.fromEntries(Object.entries(prices).map(([k,v])=>[k,{input:v[0],cached:v[1],output:v[2]}]))};
|
|
}
|
|
|
|
function send(res,code,body,type='application/json'){res.writeHead(code,{'content-type':type,'cache-control':'no-store'});res.end(type==='application/json'?JSON.stringify(body):body)}
|
|
const server=http.createServer(async(req,res)=>{
|
|
const url=new URL(req.url,'http://localhost');
|
|
if(url.pathname==='/api/stats') return send(res,200,stats(url));
|
|
if(url.pathname==='/api/rescan'&&req.method==='POST'){await scan();return send(res,200,{ok:!cache.error,error:cache.error});}
|
|
const file=path.join(PUBLIC,url.pathname==='/'?'index.html':url.pathname.slice(1));
|
|
if(!file.startsWith(PUBLIC)||!fs.existsSync(file)) return send(res,404,{error:'Not found'});
|
|
const types={'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript; charset=utf-8'};
|
|
send(res,200,fs.readFileSync(file),types[path.extname(file)]||'application/octet-stream');
|
|
});
|
|
scan().then(()=>{server.listen(PORT,'127.0.0.1',()=>console.log(`Codex Lens → http://127.0.0.1:${PORT}`));setInterval(scan,30_000).unref();});
|