diff --git a/server/aggregator.js b/server/aggregator.js index 9836fbe..bb8f641 100644 --- a/server/aggregator.js +++ b/server/aggregator.js @@ -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'; } diff --git a/server/cost-catalog.js b/server/cost-catalog.js index 56a1312..eb8b258 100644 --- a/server/cost-catalog.js +++ b/server/cost-catalog.js @@ -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; } @@ -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, diff --git a/server/ingest.js b/server/ingest.js index 58ec231..ed5f542 100644 --- a/server/ingest.js +++ b/server/ingest.js @@ -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, @@ -658,6 +659,7 @@ 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); @@ -665,6 +667,7 @@ function subtractUsageFromDayBuckets(usageByDay, inheritedUsage) { 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); @@ -672,17 +675,22 @@ function subtractUsageFromDayBuckets(usageByDay, inheritedUsage) { 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]; diff --git a/server/live-state.js b/server/live-state.js index f5edee9..0b24c8a 100644 --- a/server/live-state.js +++ b/server/live-state.js @@ -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'; } diff --git a/server/normalize.js b/server/normalize.js index 61b5c71..c342282 100644 --- a/server/normalize.js +++ b/server/normalize.js @@ -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', diff --git a/server/pricing-fetch.js b/server/pricing-fetch.js index 1c4650c..92962b9 100644 --- a/server/pricing-fetch.js +++ b/server/pricing-fetch.js @@ -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}, @@ -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) { diff --git a/server/rollout-reader.js b/server/rollout-reader.js index 2735479..18ffe65 100644 --- a/server/rollout-reader.js +++ b/server/rollout-reader.js @@ -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; @@ -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 && @@ -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; @@ -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) { @@ -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); @@ -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 @@ -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 ); } @@ -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; +} diff --git a/src/components/Models.jsx b/src/components/Models.jsx index 4ba9a5b..ac31810 100644 --- a/src/components/Models.jsx +++ b/src/components/Models.jsx @@ -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]) => { diff --git a/src/utils/colors.js b/src/utils/colors.js index 0e8dc1b..331e742 100644 --- a/src/utils/colors.js +++ b/src/utils/colors.js @@ -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', @@ -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', @@ -71,6 +83,7 @@ const EFFORT_COLORS = { high: '#f59e0b', xhigh: '#a855f7', 'x-high': '#a855f7', + max: '#f6c453', unknown: '#64748b', }; @@ -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; } diff --git a/test/forkUsageAttribution.test.js b/test/forkUsageAttribution.test.js index 773db69..121aaa4 100644 --- a/test/forkUsageAttribution.test.js +++ b/test/forkUsageAttribution.test.js @@ -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'); diff --git a/test/liveStateServer.test.js b/test/liveStateServer.test.js index 048f2b1..f6477e4 100644 --- a/test/liveStateServer.test.js +++ b/test/liveStateServer.test.js @@ -36,3 +36,30 @@ test('live top summaries include drilldown breakdowns', () => { assert.equal(model.by_effort.xhigh.tokens, 1200); assert.equal(model.by_effort.xhigh.exact_priced, 1); }); + +test('live model summaries preserve max reasoning effort', () => { + const now = Math.floor(Date.now() / 1000); + const session = { + thread_id: 'thread-56', + root_thread_id: 'thread-56', + repo_key: 'repo:demo', + repo_label: 'demo', + model_name: 'gpt-5.6-sol', + agent_family: 'review', + reasoning_effort: 'max', + started_at: now - 60, + ended_at: now, + elapsed_seconds: 60, + tokens_used: 500, + cost: 0.5, + cost_source: 'exact', + has_usage_by_day: false, + }; + const live = createLiveAggregateState('UTC'); + + applySessionToLiveState(live, session); + const model = buildLiveBootstrap(live).models.total[0]; + + assert.equal(model.model_name, 'gpt-5.6-sol'); + assert.equal(model.by_effort.max.tokens, 500); +}); diff --git a/test/modelColorNormalization.test.js b/test/modelColorNormalization.test.js index 65e322b..3952561 100644 --- a/test/modelColorNormalization.test.js +++ b/test/modelColorNormalization.test.js @@ -13,3 +13,10 @@ test('model colors canonicalize gpt-5.4 mini variants before palette lookup', () assert.equal(normalizeModelName('GPT 5.4 mini'), 'gpt-5.4-mini'); assert.equal(getModelColor('GPT 5.4 mini'), '#fb7185'); }); + +test('model colors include the gpt-5.6 family tiers', () => { + assert.equal(getModelColor('gpt-5.6-sol'), '#f6c453'); + assert.equal(getModelColor('gpt-5.6-terra'), '#36b37e'); + assert.equal(getModelColor('gpt-5.6-luna'), '#8ab4ff'); + assert.equal(getModelColor('openai/gpt-5.6-sol'), '#f6c453'); +}); diff --git a/test/modelPricing.test.js b/test/modelPricing.test.js index f837018..1592b6d 100644 --- a/test/modelPricing.test.js +++ b/test/modelPricing.test.js @@ -31,6 +31,41 @@ test('gpt-5.5 is recognized as a canonical model name with pricing at exactly 2x }); }); +test('gpt-5.6 family pricing tracks cache writes separately', async () => { + await initPricing(); + + assert.equal(normalizeModelName('openai/gpt-5.6-sol'), 'gpt-5.6-sol'); + assert.equal(normalizeModelName('gpt 5.6 terra'), 'gpt-5.6-terra'); + assert.equal(normalizeModelName('gpt-5.6 luna'), 'gpt-5.6-luna'); + + assert.deepEqual(getModelPricing('gpt-5.6-sol'), { + input: 5, + output: 30, + cached_input: 0.5, + cache_write: 6.25, + }); + assert.deepEqual(getModelPricing('gpt-5.6-terra'), { + input: 2.5, + output: 15, + cached_input: 0.25, + cache_write: 3.125, + }); + assert.deepEqual(getModelPricing('gpt-5.6-luna'), { + input: 1, + output: 6, + cached_input: 0.1, + cache_write: 1.25, + }); + + assert.equal(calculateCostFromUsage('gpt-5.6-sol', { + input_tokens: 1_000_000, + cached_input_tokens: 300_000, + cache_write_input_tokens: 200_000, + output_tokens: 100_000, + }), 6.9); + assert.equal(CATALOG_VERSION, '2026-07-08'); +}); + test('gpt-5 mini and gpt-5.4 mini remain distinct canonical models with matching pricing', async () => { await initPricing(); @@ -63,7 +98,7 @@ test('gpt-5 mini and gpt-5.4 mini remain distinct canonical models with matching assert.equal(cost, 1.245); assert.equal(cost54, 1.245); - assert.equal(CATALOG_VERSION, '2026-04-23'); + assert.equal(CATALOG_VERSION, '2026-07-08'); }); test('gpt-5 nano and gpt-5.4 nano remain distinct canonical models with matching pricing', async () => { @@ -99,7 +134,7 @@ test('gpt-5 nano and gpt-5.4 nano remain distinct canonical models with matching assert.equal(cost, 0.189); assert.equal(cost54, 0.189); - assert.equal(CATALOG_VERSION, '2026-04-23'); + assert.equal(CATALOG_VERSION, '2026-07-08'); }); test('gpt-5.2 remains distinct from gpt-5.2-codex', async () => {