Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/aggregator.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ function normalizeEffortKey(effort) {
if (k === 'medium') return 'medium';
if (k === 'high') return 'high';
if (k === 'xhigh') return 'xhigh';
if (k === 'max') return 'max';
return k || 'unknown';
}

Expand Down
7 changes: 5 additions & 2 deletions server/cost-catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,15 @@ export function calculateCostFromUsage(modelName, usage) {

const inputTokens = usage.input_tokens || 0;
const cachedInputTokens = usage.cached_input_tokens || 0;
const cacheWriteInputTokens = usage.cache_write_input_tokens || 0;
const outputTokens = usage.output_tokens || 0;
const uncachedInputTokens = Math.max(inputTokens - cachedInputTokens, 0);
const uncachedInputTokens = Math.max(inputTokens - cachedInputTokens - cacheWriteInputTokens, 0);
const cacheWriteRate = entry.cache_write ?? entry.input;

return (
(uncachedInputTokens * entry.input) +
(cachedInputTokens * entry.cached_input) +
(cacheWriteInputTokens * cacheWriteRate) +
(outputTokens * entry.output)
) / 1_000_000;
}
Expand Down Expand Up @@ -77,7 +80,7 @@ export function getModelPricing(modelName) {
return getPricing()[modelName] || null;
}

export const CATALOG_VERSION = '2026-04-23';
export const CATALOG_VERSION = '2026-07-08';
export const CACHE_ASSUMPTIONS = {
input_fraction: INPUT_FRACTION,
output_fraction: OUTPUT_FRACTION,
Expand Down
12 changes: 10 additions & 2 deletions server/ingest.js
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,7 @@ function subtractUsageFromDayBuckets(usageByDay, inheritedUsage) {
const remaining = {
input_tokens: inheritedUsage?.input_tokens || 0,
cached_input_tokens: inheritedUsage?.cached_input_tokens || 0,
cache_write_input_tokens: inheritedUsage?.cache_write_input_tokens || 0,
output_tokens: inheritedUsage?.output_tokens || 0,
reasoning_output_tokens: inheritedUsage?.reasoning_output_tokens || 0,
total_tokens: inheritedUsage?.total_tokens || 0,
Expand All @@ -658,31 +659,38 @@ function subtractUsageFromDayBuckets(usageByDay, inheritedUsage) {
const adjusted = subtractUsageTotals({
input_tokens: current.input_tokens || 0,
cached_input_tokens: current.cached_input_tokens || 0,
cache_write_input_tokens: current.cache_write_input_tokens || 0,
output_tokens: current.output_tokens || 0,
total_tokens: (current.input_tokens || 0) + (current.output_tokens || 0),
}, remaining);

const consumed = subtractUsageTotals({
input_tokens: current.input_tokens || 0,
cached_input_tokens: current.cached_input_tokens || 0,
cache_write_input_tokens: current.cache_write_input_tokens || 0,
output_tokens: current.output_tokens || 0,
total_tokens: (current.input_tokens || 0) + (current.output_tokens || 0),
}, adjusted);

Object.assign(remaining, subtractUsageTotals(remaining, consumed));

if (adjusted.input_tokens > 0 || adjusted.cached_input_tokens > 0 || adjusted.output_tokens > 0) {
next[dayKey] = {
next[dayKey] = maybeWithCacheWrite({
input_tokens: adjusted.input_tokens,
cached_input_tokens: adjusted.cached_input_tokens,
output_tokens: adjusted.output_tokens,
};
}, adjusted.cache_write_input_tokens);
}
}

return next;
}

function maybeWithCacheWrite(usage, cacheWriteInputTokens) {
if (cacheWriteInputTokens > 0) usage.cache_write_input_tokens = cacheWriteInputTokens;
return usage;
}

function deriveLiveSortDay(session, toDayKey) {
if (session.has_usage_by_day && session.usage_by_day?.length) {
let best = session.usage_by_day[0];
Expand Down
1 change: 1 addition & 0 deletions server/live-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ function normalizeEffortKey(effort) {
if (k === 'medium') return 'medium';
if (k === 'high') return 'high';
if (k === 'xhigh') return 'xhigh';
if (k === 'max') return 'max';
return k || 'unknown';
}

Expand Down
9 changes: 9 additions & 0 deletions server/normalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,15 @@ function isLauncherSourceKind(sourceKind) {

const MODEL_ALIASES = {
'codex-mini-latest': 'o4-mini',
'gpt 5.6 sol': 'gpt-5.6-sol',
'gpt-5.6 sol': 'gpt-5.6-sol',
'openai/gpt-5.6-sol': 'gpt-5.6-sol',
'gpt 5.6 terra': 'gpt-5.6-terra',
'gpt-5.6 terra': 'gpt-5.6-terra',
'openai/gpt-5.6-terra': 'gpt-5.6-terra',
'gpt 5.6 luna': 'gpt-5.6-luna',
'gpt-5.6 luna': 'gpt-5.6-luna',
'openai/gpt-5.6-luna': 'gpt-5.6-luna',
'gpt 5.5': 'gpt-5.5',
'gpt-5.5': 'gpt-5.5',
'gpt 5 mini': 'gpt-5-mini',
Expand Down
8 changes: 7 additions & 1 deletion server/pricing-fetch.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// Local pricing catalog for models used in codex-cli/codex app lifetime.
const FALLBACK = {
'gpt-5.6-sol': { input: 5.00, output: 30.00, cached_input: 0.50, cache_write: 6.25 },
'gpt-5.6-terra': { input: 2.50, output: 15.00, cached_input: 0.25, cache_write: 3.125 },
'gpt-5.6-luna': { input: 1.00, output: 6.00, cached_input: 0.10, cache_write: 1.25 },
'gpt-5.5': { input: 5.00, output: 30.00, cached_input: 0.50 },
'gpt-5.4': { input: 2.50, output: 15.00, cached_input: 0.25 },
'gpt-5-mini': { input: 0.75, output: 4.50, cached_input: 0.075},
Expand Down Expand Up @@ -29,7 +32,10 @@ const PRICING_URL = process.env.CODEXMETER_PRICING_URL || null;
function normalizeEntry(entry) {
if (!entry || typeof entry !== 'object' || entry.input == null || entry.output == null) return null;
const cached = entry.cached_input ?? entry.cachedInput ?? entry.input * 0.1;
return { input: entry.input, output: entry.output, cached_input: cached };
const normalized = { input: entry.input, output: entry.output, cached_input: cached };
const cacheWrite = entry.cache_write ?? entry.cacheWrite ?? entry.cache_write_input;
if (cacheWrite != null) normalized.cache_write = cacheWrite;
return normalized;
}

function normalizePricing(raw) {
Expand Down
60 changes: 41 additions & 19 deletions server/rollout-reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export async function enrichFromRollout(rolloutPath, opts = {}) {
const usageByDay = new Map();
let lastInputTokens = 0;
let lastCachedInputTokens = 0;
let lastCacheWriteInputTokens = 0;
let lastOutputTokens = 0;
let lastReasoningOutputTokens = 0;
let lastTotalTokens = 0;
Expand Down Expand Up @@ -113,19 +114,21 @@ export async function enrichFromRollout(rolloutPath, opts = {}) {
if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
const usage = obj.payload.info?.total_token_usage;
if (usage) {
const inputTokens = usage.input_tokens || 0;
const cachedInputTokens = usage.cached_input_tokens || 0;
const outputTokens = usage.output_tokens || 0;
const reasoningOutputTokens = usage.reasoning_output_tokens || 0;
const totalTokens = usage.total_tokens || 0;

result.usage_total = {
const normalizedUsage = normalizeUsageTotals(usage);
const inputTokens = normalizedUsage.input_tokens;
const cachedInputTokens = normalizedUsage.cached_input_tokens;
const cacheWriteInputTokens = normalizedUsage.cache_write_input_tokens || 0;
const outputTokens = normalizedUsage.output_tokens;
const reasoningOutputTokens = normalizedUsage.reasoning_output_tokens;
const totalTokens = normalizedUsage.total_tokens;

result.usage_total = withCacheWrite({
input_tokens: inputTokens,
cached_input_tokens: cachedInputTokens,
output_tokens: outputTokens,
reasoning_output_tokens: reasoningOutputTokens,
total_tokens: totalTokens,
};
}, cacheWriteInputTokens);

const hasReset =
hasSeenUsage &&
Expand All @@ -138,30 +141,34 @@ export async function enrichFromRollout(rolloutPath, opts = {}) {
hasSeenUsage = false;
lastInputTokens = 0;
lastCachedInputTokens = 0;
lastCacheWriteInputTokens = 0;
lastOutputTokens = 0;
lastReasoningOutputTokens = 0;
lastTotalTokens = 0;
}

const usageDelta = hasSeenUsage
? {
? withCacheWrite({
input_tokens: Math.max(inputTokens - lastInputTokens, 0),
cached_input_tokens: Math.max(cachedInputTokens - lastCachedInputTokens, 0),
cache_write_input_tokens: Math.max(cacheWriteInputTokens - lastCacheWriteInputTokens, 0),
output_tokens: Math.max(outputTokens - lastOutputTokens, 0),
reasoning_output_tokens: Math.max(reasoningOutputTokens - lastReasoningOutputTokens, 0),
total_tokens: Math.max(totalTokens - lastTotalTokens, 0),
}
: {
}, Math.max(cacheWriteInputTokens - lastCacheWriteInputTokens, 0))
: withCacheWrite({
input_tokens: inputTokens,
cached_input_tokens: cachedInputTokens,
cache_write_input_tokens: cacheWriteInputTokens,
output_tokens: outputTokens,
reasoning_output_tokens: reasoningOutputTokens,
total_tokens: totalTokens,
};
}, cacheWriteInputTokens);

hasSeenUsage = true;
lastInputTokens = inputTokens;
lastCachedInputTokens = cachedInputTokens;
lastCacheWriteInputTokens = cacheWriteInputTokens;
lastOutputTokens = outputTokens;
lastReasoningOutputTokens = reasoningOutputTokens;
lastTotalTokens = totalTokens;
Expand Down Expand Up @@ -448,36 +455,39 @@ function isTokenCountEventLine(line) {
}

function normalizeUsageTotals(usage) {
return {
return withCacheWrite({
input_tokens: usage.input_tokens || 0,
cached_input_tokens: usage.cached_input_tokens || 0,
cached_input_tokens: usage.cached_input_tokens ?? usage.input_tokens_details?.cached_tokens ?? 0,
output_tokens: usage.output_tokens || 0,
reasoning_output_tokens: usage.reasoning_output_tokens || 0,
total_tokens: usage.total_tokens || 0,
};
}, usage.cache_write_input_tokens ?? usage.cache_write_tokens ?? usage.input_tokens_details?.cache_write_tokens ?? 0);
}

function splitUsageTotals(usage) {
const normalized = normalizeUsageTotals(usage || {});
const cachedInputTokens = Math.min(normalized.cached_input_tokens, normalized.input_tokens);
const cacheWriteInputTokens = Math.min(normalized.cache_write_input_tokens || 0, Math.max(normalized.input_tokens - cachedInputTokens, 0));
return {
uncached_input_tokens: Math.max(normalized.input_tokens - cachedInputTokens, 0),
uncached_input_tokens: Math.max(normalized.input_tokens - cachedInputTokens - cacheWriteInputTokens, 0),
cached_input_tokens: cachedInputTokens,
cache_write_input_tokens: cacheWriteInputTokens,
output_tokens: normalized.output_tokens,
reasoning_output_tokens: normalized.reasoning_output_tokens,
};
}

function combineUsageTotals(parts, currentTotalTokens = 0, previousTotalTokens = 0) {
const inputTokens = (parts.uncached_input_tokens || 0) + (parts.cached_input_tokens || 0);
const cacheWriteInputTokens = parts.cache_write_input_tokens || 0;
const inputTokens = (parts.uncached_input_tokens || 0) + (parts.cached_input_tokens || 0) + cacheWriteInputTokens;
const outputTokens = parts.output_tokens || 0;
return {
return withCacheWrite({
input_tokens: inputTokens,
cached_input_tokens: parts.cached_input_tokens || 0,
output_tokens: outputTokens,
reasoning_output_tokens: parts.reasoning_output_tokens || 0,
total_tokens: Math.max(inputTokens + outputTokens, Math.max(currentTotalTokens - previousTotalTokens, 0)),
};
}, cacheWriteInputTokens);
}

export function subtractUsageTotals(current, previous) {
Expand All @@ -486,6 +496,7 @@ export function subtractUsageTotals(current, previous) {
return combineUsageTotals({
uncached_input_tokens: Math.max(currentParts.uncached_input_tokens - previousParts.uncached_input_tokens, 0),
cached_input_tokens: Math.max(currentParts.cached_input_tokens - previousParts.cached_input_tokens, 0),
cache_write_input_tokens: Math.max(currentParts.cache_write_input_tokens - previousParts.cache_write_input_tokens, 0),
output_tokens: Math.max(currentParts.output_tokens - previousParts.output_tokens, 0),
reasoning_output_tokens: Math.max(currentParts.reasoning_output_tokens - previousParts.reasoning_output_tokens, 0),
}, current?.total_tokens || 0, previous?.total_tokens || 0);
Expand All @@ -495,6 +506,7 @@ export function hasUsageTotals(usage) {
return !!usage && (
(usage.input_tokens || 0) > 0 ||
(usage.cached_input_tokens || 0) > 0 ||
(usage.cache_write_input_tokens || 0) > 0 ||
(usage.output_tokens || 0) > 0 ||
(usage.reasoning_output_tokens || 0) > 0 ||
(usage.total_tokens || 0) > 0
Expand All @@ -505,6 +517,7 @@ function hasUsage(usage) {
return !!usage && (
usage.input_tokens > 0 ||
usage.cached_input_tokens > 0 ||
(usage.cache_write_input_tokens || 0) > 0 ||
usage.output_tokens > 0
);
}
Expand All @@ -521,5 +534,14 @@ function mergeUsageTotals(target, dayKey, usage) {
}
previous.input_tokens += usage.input_tokens || 0;
previous.cached_input_tokens += usage.cached_input_tokens || 0;
if (usage.cache_write_input_tokens) {
previous.cache_write_input_tokens = (previous.cache_write_input_tokens || 0) + usage.cache_write_input_tokens;
}
previous.output_tokens += usage.output_tokens || 0;
}

function withCacheWrite(usage, cacheWriteInputTokens) {
const value = Math.max(0, cacheWriteInputTokens || 0);
if (value > 0) usage.cache_write_input_tokens = value;
return usage;
}
2 changes: 1 addition & 1 deletion src/components/Models.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function fmtCost(n) {
return '$' + n.toFixed(2);
}

const EFFORT_ORDER = ['low', 'medium', 'high', 'xhigh', 'unknown'];
const EFFORT_ORDER = ['low', 'medium', 'high', 'xhigh', 'max', 'unknown'];

function sortEffortEntries(entries) {
return [...entries].sort(([a], [b]) => {
Expand Down
14 changes: 14 additions & 0 deletions src/utils/colors.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const REPO_PALETTE = [

// Curated colors for common models (consistent across machines)
const MODEL_COLORS = {
'gpt-5.6-sol': '#f6c453',
'gpt-5.6-terra': '#36b37e',
'gpt-5.6-luna': '#8ab4ff',
'gpt-5.5': '#22c790',
'gpt-5.4': '#fa4141',
'gpt-5-mini': '#fb7185',
Expand Down Expand Up @@ -43,6 +46,15 @@ const MODEL_COLORS = {

const MODEL_COLOR_ALIASES = {
'codex-mini-latest': 'o4-mini',
'gpt 5.6 sol': 'gpt-5.6-sol',
'gpt-5.6 sol': 'gpt-5.6-sol',
'openai/gpt-5.6-sol': 'gpt-5.6-sol',
'gpt 5.6 terra': 'gpt-5.6-terra',
'gpt-5.6 terra': 'gpt-5.6-terra',
'openai/gpt-5.6-terra': 'gpt-5.6-terra',
'gpt 5.6 luna': 'gpt-5.6-luna',
'gpt-5.6 luna': 'gpt-5.6-luna',
'openai/gpt-5.6-luna': 'gpt-5.6-luna',
'gpt 5.5': 'gpt-5.5',
'gpt-5.5': 'gpt-5.5',
'gpt 5 mini': 'gpt-5-mini',
Expand Down Expand Up @@ -71,6 +83,7 @@ const EFFORT_COLORS = {
high: '#f59e0b',
xhigh: '#a855f7',
'x-high': '#a855f7',
max: '#f6c453',
unknown: '#64748b',
};

Expand All @@ -82,6 +95,7 @@ export function normalizeEffortKey(effort) {
if (k === 'medium') return 'medium';
if (k === 'high') return 'high';
if (k === 'xhigh') return 'xhigh';
if (k === 'max') return 'max';
if (k === 'unknown') return 'unknown';
return k;
}
Expand Down
49 changes: 49 additions & 0 deletions test/forkUsageAttribution.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,55 @@ test('reasoning-only token events still establish the first usage timestamp', as
}
});

test('rollout usage reads cache write tokens from Responses input details', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'codexmeter-rollout-'));
const rolloutPath = path.join(dir, 'rollout.jsonl');
try {
await writeFile(rolloutPath, [
JSON.stringify({
timestamp: '2026-07-08T10:00:00.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: {
total_token_usage: {
input_tokens: 100,
input_tokens_details: {
cached_tokens: 30,
cache_write_tokens: 20,
},
output_tokens: 5,
total_tokens: 105,
},
},
},
}),
'',
].join('\n'));

const data = await enrichFromRollout(rolloutPath, { timezone: 'UTC' });

assert.deepEqual(data.usage_total, {
input_tokens: 100,
cached_input_tokens: 30,
cache_write_input_tokens: 20,
output_tokens: 5,
reasoning_output_tokens: 0,
total_tokens: 105,
});
assert.deepEqual(data.usage_by_day, {
'2026-07-08': {
input_tokens: 100,
cached_input_tokens: 30,
cache_write_input_tokens: 20,
output_tokens: 5,
},
});
} finally {
await rm(dir, { recursive: true, force: true });
}
});

test('rollout active seconds split across timezone midnight', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'codexmeter-rollout-'));
const rolloutPath = path.join(dir, 'rollout.jsonl');
Expand Down
Loading
Loading