Initial Codex Lens dashboard
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Codex Lens
|
||||
|
||||
A detailed, local-first dashboard for Codex usage. It reads `~/.codex/sessions/**/*.jsonl` directly and never uploads session data.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd /home/louis/dev/codex-lens
|
||||
npm start
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:4173>. Set `PORT` or `CODEX_HOME` to override either default.
|
||||
|
||||
## What it measures
|
||||
|
||||
- Input, cached input, output, reasoning output, and total tokens
|
||||
- Cache hit rate, model calls, sessions, turns, and active task duration
|
||||
- Daily usage and estimated API-equivalent cost
|
||||
- Breakdowns by model, project/workspace, and session
|
||||
- Date, project, and model filtering
|
||||
|
||||
Cost is an estimate, not a bill. It applies current standard API prices per 1M tokens to the model recorded for each usage event. Unknown, local, experimental, and unpriced model slugs are excluded and pricing coverage is shown in the UI. Reasoning tokens are already part of output tokens and are not charged twice.
|
||||
|
||||
Pricing source: [OpenAI API pricing](https://developers.openai.com/api/docs/pricing), captured 2026-07-10. Edit the `prices` table in `server.js` as rates change.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "codex-lens",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "A detailed, local-first Codex usage analytics dashboard",
|
||||
"scripts": { "start": "node server.js", "test": "node --test" },
|
||||
"engines": { "node": ">=20" },
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const $=s=>document.querySelector(s), fmt=n=>new Intl.NumberFormat('en',{notation:'compact',maximumFractionDigits:1}).format(n||0), money=n=>new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:n<10?2:0}).format(n||0), pct=n=>`${(n||0).toFixed(1)}%`;
|
||||
let data, chartMode='models';
|
||||
const palette=['#7357ff','#20a4a8','#ff805d','#e4b63f','#4b8cff','#d45bba','#72a33a','#9b6b43','#32343a','#a5a2ff','#19b875','#e85d5d'];
|
||||
const modelColor=name=>palette[Math.max(0,data.models.findIndex(x=>x.name===name))%palette.length];
|
||||
async function load(){
|
||||
const q=new URLSearchParams(); for(const id of ['from','to','project','model']) if($('#'+id).value) q.set(id,$('#'+id).value);
|
||||
$('#subtitle').textContent='Reading local session history…';
|
||||
try{data=await fetch('/api/stats?'+q).then(r=>r.json());if(data.meta?.schemaVersion!==2)throw new Error('The Codex Lens server is outdated. Stop it and run npm start again.');render()}catch(e){$('#subtitle').innerHTML=`<span class="error">${e.message}</span>`}
|
||||
}
|
||||
function options(el,values,label){const old=el.value;el.innerHTML=`<option value="">All ${label}</option>`+values.map(x=>`<option>${esc(x)}</option>`).join('');el.value=old}
|
||||
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||
function render(){
|
||||
const {total:t,meta}=data; options($('#project'),data.filters.projects,'projects');options($('#model'),data.filters.models,'models');
|
||||
$('#subtitle').textContent=`${meta.files} sessions · ${meta.events.toLocaleString()} model calls · scanned ${new Date(meta.scannedAt).toLocaleTimeString()}`;
|
||||
$('#cost').textContent=money(t.cost);const coverage=t.total?t.priced/t.total*100:0;$('#coverage').textContent=`${pct(coverage)} token pricing coverage · standard API rates`;
|
||||
$('#tokens').textContent=fmt(t.total);$('#tokenMix').textContent=`${fmt(t.input)} input · ${fmt(t.output)} output`;
|
||||
const cr=t.input?t.cached/t.input*100:0;$('#cache').textContent=pct(cr);$('#cacheSaved').textContent=`${fmt(t.cached)} cached input tokens`;
|
||||
const matchedSessions=data.meta.matchedSessions??data.meta.files??data.sessions.length;$('#activity').textContent=`${matchedSessions} / ${t.turns.toLocaleString()}`;$('#duration').textContent=`${(t.duration/36e5).toFixed(1)} active task hours`;
|
||||
const uncached=Math.max(0,t.input-t.cached), peak=data.days.reduce((a,d)=>d.total>(a?.total||0)?d:a,null);
|
||||
$('#uncached').textContent=fmt(uncached);$('#uncachedShare').textContent=`${pct(t.input?uncached/t.input*100:0)} of input`;
|
||||
$('#reasoning').textContent=fmt(t.reasoning);$('#reasoningShare').textContent=`${pct(t.output?t.reasoning/t.output*100:0)} of output`;
|
||||
$('#avgSession').textContent=fmt(t.total/(matchedSessions||1));$('#avgCost').textContent=`${money(t.cost/(matchedSessions||1))} API-equiv.`;
|
||||
$('#peakDay').textContent=peak?new Date(peak.name+'T12:00:00').toLocaleDateString(undefined,{month:'short',day:'numeric'}):'—';$('#peakTokens').textContent=peak?`${fmt(peak.total)} tokens`:'No activity';
|
||||
$('#activeDays').textContent=data.days.length;$('#streak').textContent=`Longest streak ${longestStreak(data.days)} days`;
|
||||
$('#modelCount').textContent=data.models.length;$('#topModel').textContent=data.models[0]?`${data.models[0].name} leads`:'No models';
|
||||
renderChart();
|
||||
const mm=Math.max(...data.models.map(x=>x.total),1);$('#modelBars').innerHTML='<div class="bar-legend"><span class="shade-light"></span>Uncached input <span class="shade-base"></span>Cached input <span class="shade-dark"></span>Output</div>'+data.models.slice(0,9).map(m=>{const base=modelColor(m.name),uncached=Math.max(0,m.input-m.cached),sum=uncached+m.cached+m.output||1,tip=[m.name,`Uncached input ${fmt(uncached)} (${pct(uncached/sum*100)})`,`Cached input ${fmt(m.cached)} (${pct(m.cached/sum*100)})`,`Output ${fmt(m.output)} (${pct(m.output/sum*100)})`,`Reasoning output ${fmt(m.reasoning)}`,`Cache hit rate ${pct(m.input?m.cached/m.input*100:0)}`,`API-equivalent cost ${money(m.cost)}`].join(' ');return `<div class="model-row"><span class="model-pill" style="color:${base};background:color-mix(in srgb,${base} 12%,white)">${esc(m.name)}</span><div class="bar" data-tip="${tip}"><div class="bar-fill" style="width:${m.total/mm*100}%;--model:${base}"><i class="uncached-part" style="width:${uncached/sum*100}%"></i><i class="cached-part" style="width:${m.cached/sum*100}%"></i><i class="output-part" style="width:${m.output/sum*100}%"></i></div></div><b>${fmt(m.total)}</b></div>`}).join('');
|
||||
const mixSum=uncached+t.cached+t.output||1, a=uncached/mixSum*100,b=t.cached/mixSum*100;$('#donut').style.background=`conic-gradient(var(--purple) 0 ${a}%,var(--blue) ${a}% ${a+b}%,var(--orange) ${a+b}% 100%)`;$('#donutTotal').textContent=fmt(t.total);$('#mixLegend').innerHTML=[['Uncached input',uncached,'var(--purple)'],['Cached input',t.cached,'var(--blue)'],['Output',t.output,'var(--orange)'],['Reasoning output',t.reasoning,'#222']].map(x=>`<div class="mix-item"><i style="background:${x[2]}"></i>${x[0]} <b>${fmt(x[1])}</b></div>`).join('');
|
||||
$('#projectsBody').innerHTML=data.projects.slice(0,30).map(x=>`<tr><td>${esc(x.name)}</td><td>${fmt(x.total)}</td><td>${fmt(x.input)}</td><td>${fmt(x.output)}</td><td>${pct(x.input?x.cached/x.input*100:0)}</td><td>${money(x.cost)}</td><td>${x.turns}</td></tr>`).join('');
|
||||
$('#sessionsBody').innerHTML=data.sessions.map(x=>`<tr><td>${new Date(x.started).toLocaleDateString()}</td><td>${esc(x.project)}</td><td><span class="model-pill">${esc(x.model||'unknown')}</span></td><td>${fmt(x.total)}</td><td>${fmt(x.cached)}</td><td>${fmt(x.output)}</td><td>${money(x.cost)}</td></tr>`).join('');
|
||||
}
|
||||
function longestStreak(days){let best=0,run=0,prev=0;for(const d of days){const n=new Date(d.name+'T00:00:00Z').getTime()/864e5;run=n===prev+1?run+1:1;best=Math.max(best,run);prev=n}return best}
|
||||
function renderChart(){
|
||||
const max=Math.max(...data.days.map(x=>x.total),1), chart=$('#chart');
|
||||
if(chartMode==='models'){
|
||||
const shown=data.models.slice(0,11).map(x=>x.name), other=data.models.slice(11).map(x=>x.name);
|
||||
$('#modelLegend').innerHTML=shown.map(m=>`<span><i style="background:${modelColor(m)}"></i>${esc(m)}</span>`).join('')+(other.length?'<span><i style="background:#bbb"></i>Other</span>':'');
|
||||
chart.innerHTML=data.days.map(d=>{const daily=d.models||{},parts=shown.map(m=>[m,daily[m]||0,modelColor(m)]),ov=other.reduce((s,m)=>s+(daily[m]||0),0);if(ov)parts.push(['Other',ov,'#bbb']);const tip=[`${d.name} · ${fmt(d.total)} tokens`,...parts.filter(x=>x[1]).sort((a,b)=>b[1]-a[1]).slice(0,5).map(x=>`${x[0]} ${fmt(x[1])}`),`Est. ${money(d.cost)}`].join(' ');return `<div class="day" data-tip="${tip}">${parts.filter(x=>x[1]).map(x=>`<span style="height:${x[1]/max*100}%;background-color:${x[2]}!important"></span>`).join('')}</div>`}).join('')||'<p>No usage in this range</p>';
|
||||
$('#axisNote').textContent='Stacked totals by model · hover any day for details';
|
||||
}else{
|
||||
$('#modelLegend').innerHTML='<span><i style="background:var(--purple)"></i>Uncached input</span><span><i style="background:var(--blue)"></i>Cached input</span><span><i style="background:var(--orange)"></i>Output</span>';
|
||||
chart.innerHTML=data.days.map(d=>{const u=Math.max(0,d.input-d.cached),sum=u+d.cached+d.output||1,parts=[['Uncached',u,'var(--purple)'],['Cached',d.cached,'var(--blue)'],['Output',d.output,'var(--orange)']],tip=[`${d.name} · token composition`,...parts.map(x=>`${x[0]} ${fmt(x[1])} (${pct(x[1]/sum*100)})`)].join(' ');return `<div class="day" data-tip="${tip}">${parts.map(x=>`<span style="height:${Math.max(x[1]?1:0,x[1]/sum*100)}%;background:${x[2]}"></span>`).join('')}</div>`}).join('');
|
||||
$('#axisNote').textContent='100% composition per active day · output remains visible at a minimum 1px';
|
||||
}
|
||||
}
|
||||
for(const id of ['from','to','project','model']) $('#'+id).addEventListener('change',load);document.querySelectorAll('.chart-mode').forEach(b=>b.onclick=()=>{chartMode=b.dataset.mode;document.querySelectorAll('.chart-mode').forEach(x=>x.classList.toggle('active',x===b));renderChart()});$('#clear').onclick=()=>{for(const id of ['from','to','project','model'])$('#'+id).value='';load()};$('#rescan').onclick=async()=>{$('#rescan').disabled=true;$('#rescan').textContent='Scanning…';await fetch('/api/rescan',{method:'POST'});$('#rescan').disabled=false;$('#rescan').textContent='↻ Rescan data';load()};load();
|
||||
@@ -0,0 +1,32 @@
|
||||
.mini-cards{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-top:10px}
|
||||
.mini-cards article{background:var(--paper);border:1px solid var(--line);border-radius:10px;padding:15px;display:grid;gap:5px}
|
||||
.mini-cards span{font-size:11px;color:var(--muted)}
|
||||
.mini-cards b{font-family:Georgia,serif;font-size:22px;font-weight:500}
|
||||
.mini-cards small{font-size:10px;color:var(--muted)}
|
||||
.chart{height:280px}
|
||||
.day{min-width:4px;display:flex;flex-direction:column-reverse;justify-content:flex-start;align-items:stretch}
|
||||
.day span{flex:none;border-radius:1px;transition:filter .15s}
|
||||
.day:hover span{filter:brightness(.8)}
|
||||
.day:hover:after{bottom:calc(100% + 5px);white-space:pre;line-height:1.55;font-size:10px;padding:8px 10px;border-radius:6px;pointer-events:none}
|
||||
.model-legend{display:flex;gap:13px;flex-wrap:wrap;min-height:20px;margin:-8px 0 5px;font:10px ui-monospace,monospace;color:var(--muted)}
|
||||
.model-legend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px}
|
||||
.chart-actions{display:flex}
|
||||
.chart-actions button{font-size:11px;padding:7px 11px;border-radius:0}
|
||||
.chart-actions button:first-child{border-radius:7px 0 0 7px}
|
||||
.chart-actions button:last-child{border-radius:0 7px 7px 0}
|
||||
.chart-actions .active{background:#1b1c19;color:#fff;border-color:#1b1c19}
|
||||
.axis-note{text-align:right;color:var(--muted);font-size:10px;margin-top:8px}
|
||||
.bar-legend{display:flex;justify-content:flex-end;align-items:center;gap:6px;color:var(--muted);font-size:10px;margin:-12px 75px 8px 0}
|
||||
.bar-legend span{width:11px;height:7px;border-radius:2px;margin-left:8px;background:#7357ff}
|
||||
.bar-legend .shade-light{background:color-mix(in srgb,#7357ff 42%,white)}
|
||||
.bar-legend .shade-dark{background:color-mix(in srgb,#7357ff 72%,black)}
|
||||
.bar{position:relative;overflow:visible}
|
||||
.bar-fill{height:100%;display:flex;overflow:hidden;border-radius:10px}
|
||||
.bar-fill i{height:100%;display:block;flex:none;border-radius:0}
|
||||
.bar-fill .uncached-part{background:color-mix(in srgb,var(--model) 42%,white)}
|
||||
.bar-fill .cached-part{background:var(--model)}
|
||||
.bar-fill .output-part{background:color-mix(in srgb,var(--model) 72%,black);min-width:1px}
|
||||
.bar:hover:after{content:attr(data-tip);position:absolute;left:50%;bottom:calc(100% + 8px);transform:translateX(-50%);z-index:20;background:#1b1c19;color:#fff;padding:9px 11px;border-radius:6px;white-space:pre;line-height:1.6;font:10px ui-monospace,monospace;box-shadow:0 8px 24px #0003;pointer-events:none}
|
||||
.bar:hover .bar-fill{filter:saturate(1.25) brightness(.92)}
|
||||
@media(max-width:1200px){.mini-cards{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(max-width:620px){.mini-cards{grid-template-columns:1fr 1fr}}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Codex Lens</title><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="fancy.css"></head><body>
|
||||
<aside><div class="brand"><span class="mark">◉</span><div>CODEX <b>LENS</b></div></div><nav><a class="active">Overview</a><a href="#models">Models</a><a href="#projects">Projects</a><a href="#sessions">Sessions</a></nav><div class="aside-foot"><span class="pulse"></span> Local data only<br><small>Nothing leaves this machine</small></div></aside>
|
||||
<main><header><div><p class="eyebrow">LOCAL INTELLIGENCE</p><h1>Your work, in focus.</h1><p id="subtitle">Scanning Codex sessions…</p></div><button id="rescan">↻ Rescan data</button></header>
|
||||
<section class="filters"><label>From<input id="from" type="date"></label><label>To<input id="to" type="date"></label><label>Project<select id="project"><option value="">All projects</option></select></label><label>Model<select id="model"><option value="">All models</option></select></label><button id="clear">Clear</button></section>
|
||||
<section class="cards"><article class="hero-card"><span>API-equivalent cost</span><strong id="cost">—</strong><small id="coverage">Based on current standard pricing</small></article><article><span>Total tokens</span><strong id="tokens">—</strong><small id="tokenMix">—</small></article><article><span>Cache hit rate</span><strong id="cache">—</strong><small id="cacheSaved">—</small></article><article><span>Sessions / turns</span><strong id="activity">—</strong><small id="duration">—</small></article></section>
|
||||
<section class="mini-cards"><article><span>Uncached input</span><b id="uncached">—</b><small id="uncachedShare">—</small></article><article><span>Reasoning output</span><b id="reasoning">—</b><small id="reasoningShare">—</small></article><article><span>Average session</span><b id="avgSession">—</b><small id="avgCost">—</small></article><article><span>Busiest day</span><b id="peakDay">—</b><small id="peakTokens">—</small></article><article><span>Active days</span><b id="activeDays">—</b><small id="streak">—</small></article><article><span>Models used</span><b id="modelCount">—</b><small id="topModel">—</small></article></section>
|
||||
<section class="panel wide"><div class="panel-head"><div><p class="eyebrow">MODEL TIMELINE</p><h2>Which models powered each day</h2></div><div class="chart-actions"><button class="chart-mode active" data-mode="models">Models</button><button class="chart-mode" data-mode="tokens">Token mix</button></div></div><div id="modelLegend" class="model-legend"></div><div id="chart" class="chart"></div><div class="axis-note" id="axisNote">Stacked totals by model · hover any day for details</div></section>
|
||||
<div class="grid"><section class="panel" id="models"><div class="panel-head"><div><p class="eyebrow">MODEL MIX</p><h2>Models</h2></div></div><div id="modelBars"></div></section><section class="panel"><div class="panel-head"><div><p class="eyebrow">COMPOSITION</p><h2>Where tokens go</h2></div></div><div class="donut-wrap"><div id="donut" class="donut"><div><b id="donutTotal">—</b><small>tokens</small></div></div><div id="mixLegend"></div></div></section></div>
|
||||
<section class="panel wide" id="projects"><div class="panel-head"><div><p class="eyebrow">WORKSPACES</p><h2>Project leaderboard</h2></div></div><div class="table-wrap"><table><thead><tr><th>Project</th><th>Tokens</th><th>Input</th><th>Output</th><th>Cache</th><th>Est. cost</th><th>Turns</th></tr></thead><tbody id="projectsBody"></tbody></table></div></section>
|
||||
<section class="panel wide" id="sessions"><div class="panel-head"><div><p class="eyebrow">DEEP DIVE</p><h2>Heaviest sessions</h2></div><small>Top 100 for current filter</small></div><div class="table-wrap"><table><thead><tr><th>Started</th><th>Project</th><th>Model</th><th>Tokens</th><th>Cached</th><th>Output</th><th>Est. cost</th></tr></thead><tbody id="sessionsBody"></tbody></table></div></section>
|
||||
<footer>Codex Lens · Estimates are API-equivalent, not actual ChatGPT/Codex charges. Reasoning tokens are included in output token counts.</footer></main><script src="app.js"></script></body></html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,78 @@
|
||||
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';
|
||||
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?.last_token_usage){
|
||||
const t=p.info.last_token_usage, u={input:t.input_tokens||0,cached:t.cached_input_tokens||0,output:t.output_tokens||0,reasoning:t.reasoning_output_tokens||0,total:t.total_tokens||0,turns:0,duration:0};
|
||||
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};
|
||||
async function scan(){ 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};} }
|
||||
|
||||
function stats(url){
|
||||
const from=url.searchParams.get('from'), to=url.searchParams.get('to'), modelFilter=url.searchParams.get('model'), projectFilter=url.searchParams.get('project');
|
||||
const sessions=cache.sessions.filter(s=>(!from||s.started>=from)&&(!to||s.started<to+'T23:59:59.999Z')&&(!projectFilter||s.project===projectFilter));
|
||||
const events=sessions.flatMap(s=>s.events.map(e=>({...e,session:s.id,project:s.project,source:s.source}))).filter(e=>!modelFilter||e.model===modelFilter);
|
||||
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 sessionRows=sessions.map(s=>({...s,models:undefined,events:undefined,model:s.models.join(', '),usage:undefined,...s.usage})).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()},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}`)));
|
||||
@@ -0,0 +1,2 @@
|
||||
const test=require('node:test');const assert=require('node:assert/strict');
|
||||
test('API-equivalent cost formula handles cached input separately',()=>{const input=1_000_000,cached=500_000,output=100_000;const cost=((input-cached)*1.75+cached*.175+output*14)/1e6;assert.equal(cost,2.3625)});
|
||||
Reference in New Issue
Block a user