From a3da2ded2ac4081d5c7b919d75ee7777f441eccf Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:03:46 +0200 Subject: [PATCH] feat(codex): compute Codex credit usage (#408, #495) (#510) * feat(codex): compute Codex credit usage (#408, #495) Codex/ChatGPT subscription users consume credits, a unit separate from API dollars: usage is billed as credits-per-million-tokens at per-model rates that differ from the API USD pricing CodeBurn uses for cost. So the reported dollar cost does not match what credits actually consume. Add a credit engine sourced from the official Codex credit rates (developers.openai.com/codex/pricing): GPT-5.5 125/12.5/750, GPT-5.4 62.5/6.25/375, GPT-5.4 mini 18.75/1.875/113 credits per 1M input/cached/output tokens. Surface per-model credit usage in `codeburn models` JSON output (credits field; null for non-Codex or unknown models). models-report already folds reasoning into output and keeps non-cached input + cached-read separately, which is exactly what the credit rates expect, so the figure is exact. Engine + computation are unit-tested. UI display surfaces (the models table, the TUI dashboard, the menubar "credits" view) are intentionally left for a follow-up so the display choice can be decided. * feat(menubar): opt-in Codex credits display metric (#408, #495) Surface Codex credit usage in the menubar as a selectable metric, without changing the default. Cost ($) stays the default in both the menubar and the CLI; credits only appear when explicitly chosen. - TS: buildMenubarPayloadForRange computes the period's Codex credits (via the tested aggregateModels, so reasoning/cached are handled) and exposes current.codexCredits in the menubar JSON. - Swift: new DisplayMetric.credits, a "Credits (Codex)" option in the metric picker, decodes codexCredits, and renders it in the menu-bar title. Default metric remains .cost. --- mac/Sources/CodeBurnMenubar/AppStore.swift | 2 +- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 3 + .../CodeBurnMenubar/Data/MenubarPayload.swift | 6 +- .../CodeBurnMenubar/Views/SettingsView.swift | 1 + src/codex-credits.ts | 57 +++++++++++++++++++ src/menubar-json.ts | 6 ++ src/models-report.ts | 15 +++++ src/usage-aggregator.ts | 10 ++++ tests/codex-credits.test.ts | 53 +++++++++++++++++ tests/models-report.test.ts | 32 +++++++++++ tests/usage-aggregator.test.ts | 3 + 11 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 src/codex-credits.ts create mode 100644 tests/codex-credits.test.ts diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 52dbcfa..9f76136 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -1142,7 +1142,7 @@ enum SubscriptionLoadState: Sendable, Equatable { } enum DisplayMetric: String { - case cost, tokens, totalTokens, iconOnly + case cost, tokens, totalTokens, credits, iconOnly } enum InsightMode: String, CaseIterable, Identifiable { diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 5655f25..f357a8b 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -735,6 +735,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } else if store.displayMetric == .totalTokens, let p = menubarPayload?.current { let total = formatTokensMenubar(Double(p.inputTokens + p.outputTokens)) valueText = compact ? "\(total)\(suffix)" : " \(total) tok\(suffix)" + } else if store.displayMetric == .credits, let p = menubarPayload?.current { + let credits = formatTokensMenubar((p.codexCredits ?? 0).rounded()) + valueText = compact ? "\(credits)cr\(suffix)" : " \(credits) credits\(suffix)" } else { let fallback = compact ? "$-" : "$—" let formatted = menubarPayload?.current.cost diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 42b2b49..50507c4 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -113,6 +113,8 @@ struct CurrentBlock: Codable, Sendable { let inputTokens: Int let outputTokens: Int let cacheHitPercent: Double + /// Codex credits consumed in the period (nil on payloads from older builds). + let codexCredits: Double? let topActivities: [ActivityEntry] let topModels: [ModelEntry] let localModelSavings: LocalModelSavings @@ -131,7 +133,7 @@ struct CurrentBlock: Codable, Sendable { extension CurrentBlock { enum CodingKeys: String, CodingKey { case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens, - cacheHitPercent, topActivities, topModels, localModelSavings, providers, topProjects, + cacheHitPercent, codexCredits, topActivities, topModels, localModelSavings, providers, topProjects, modelEfficiency, topSessions, retryTax, routingWaste, tools, skills, subagents, mcpServers } @@ -145,6 +147,7 @@ extension CurrentBlock { inputTokens = try c.decode(Int.self, forKey: .inputTokens) outputTokens = try c.decode(Int.self, forKey: .outputTokens) cacheHitPercent = try c.decodeIfPresent(Double.self, forKey: .cacheHitPercent) ?? 0 + codexCredits = try c.decodeIfPresent(Double.self, forKey: .codexCredits) topActivities = try c.decodeIfPresent([ActivityEntry].self, forKey: .topActivities) ?? [] topModels = try c.decodeIfPresent([ModelEntry].self, forKey: .topModels) ?? [] localModelSavings = try c.decodeIfPresent(LocalModelSavings.self, forKey: .localModelSavings) ?? LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []) @@ -368,6 +371,7 @@ extension MenubarPayload { inputTokens: 0, outputTokens: 0, cacheHitPercent: 0, + codexCredits: nil, topActivities: [], topModels: [], localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []), diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index 0f5d030..b613510 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -91,6 +91,7 @@ private struct GeneralSettingsTab: View { Text("Cost ($)").tag(DisplayMetric.cost) Text("Tokens (↑↓)").tag(DisplayMetric.tokens) Text("Total Tokens").tag(DisplayMetric.totalTokens) + Text("Credits (Codex)").tag(DisplayMetric.credits) Text("Icon Only").tag(DisplayMetric.iconOnly) } Picker("Period", selection: Binding( diff --git a/src/codex-credits.ts b/src/codex-credits.ts new file mode 100644 index 0000000..10d5b1f --- /dev/null +++ b/src/codex-credits.ts @@ -0,0 +1,57 @@ +// Codex credit pricing. ChatGPT/Codex subscription users consume *credits*, a +// separate unit from API dollars: usage is billed as "credits per million +// tokens" at per-model rates that differ from the API USD pricing CodeBurn uses +// for cost. This module computes credit consumption from token counts so the +// app can show usage in credits (issues #408 and #495). +// +// Rates are credits per 1,000,000 tokens, from +// https://developers.openai.com/codex/pricing#credits-overview +// (cached input is the cheaper rate applied to cache-read tokens). + +export type CodexCreditRate = { + input: number + cachedInput: number + output: number +} + +const CREDITS_PER_MILLION: Record = { + 'gpt-5.5': { input: 125, cachedInput: 12.5, output: 750 }, + 'gpt-5.4': { input: 62.5, cachedInput: 6.25, output: 375 }, + 'gpt-5.4-mini': { input: 18.75, cachedInput: 1.875, output: 113 }, +} + +/// Resolve the credit rate for a Codex model name, tolerating suffix variants +/// (e.g. "gpt-5.5-codex"). Returns null when the model has no known credit rate. +export function codexCreditRate(model: string): CodexCreditRate | null { + const m = model.toLowerCase() + if (m.includes('5.4') && m.includes('mini')) return CREDITS_PER_MILLION['gpt-5.4-mini']! + if (m.includes('5.4')) return CREDITS_PER_MILLION['gpt-5.4']! + if (m.includes('5.5')) return CREDITS_PER_MILLION['gpt-5.5']! + return null +} + +export type CodexCreditTokens = { + /// Non-cached input tokens (CodeBurn normalizes Codex to Anthropic semantics, + /// so this excludes cache-read tokens). + inputTokens: number + /// Cache-read (cached input) tokens, billed at the cheaper cached rate. + cachedReadTokens: number + outputTokens: number + /// Reasoning tokens are billed as output, matching CodeBurn's cost model. + reasoningTokens?: number +} + +/// Credits consumed for one Codex usage record. Returns null when the model has +/// no known credit rate (caller decides how to surface "unknown"). +export function codexCredits(model: string, tokens: CodexCreditTokens): number | null { + const rate = codexCreditRate(model) + if (!rate) return null + const safe = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0) + const PER_MILLION = 1_000_000 + const output = safe(tokens.outputTokens) + safe(tokens.reasoningTokens ?? 0) + return ( + (safe(tokens.inputTokens) / PER_MILLION) * rate.input + + (safe(tokens.cachedReadTokens) / PER_MILLION) * rate.cachedInput + + (output / PER_MILLION) * rate.output + ) +} diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 3555f40..9fe3bec 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -16,6 +16,9 @@ export type PeriodData = { outputTokens: number cacheReadTokens: number cacheWriteTokens: number + /// Total Codex credits consumed in the period (issues #408/#495). Optional so + /// non-menubar PeriodData producers don't have to compute it. + codexCredits?: number categories: Array<{ name: string; cost: number; savingsUSD: number; turns: number; editTurns: number; oneShotTurns: number }> models: Array<{ name: string; cost: number; savingsUSD: number; calls: number }> projects?: Array<{ name: string; cost: number; savingsUSD: number; sessions: number; sessionDetails?: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }> }> @@ -85,6 +88,8 @@ export type MenubarPayload = { inputTokens: number outputTokens: number cacheHitPercent: number + /// Codex credits consumed in the period; 0 when there is no Codex usage. + codexCredits: number topActivities: Array<{ name: string cost: number @@ -321,6 +326,7 @@ export function buildMenubarPayload( inputTokens: current.inputTokens, outputTokens: current.outputTokens, cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens), + codexCredits: current.codexCredits ?? 0, topActivities: buildTopActivities(current.categories), topModels: buildTopModels(current.models), localModelSavings: breakdowns?.localModelSavings ?? { totalUSD: 0, calls: 0, byModel: [], byProvider: [] }, diff --git a/src/models-report.ts b/src/models-report.ts index e0e3e82..fc041ec 100644 --- a/src/models-report.ts +++ b/src/models-report.ts @@ -1,6 +1,7 @@ import chalk from 'chalk' import stripAnsi from 'strip-ansi' +import { codexCredits } from './codex-credits.js' import { formatCost, formatTokens } from './format.js' import { getProvider } from './providers/index.js' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' @@ -20,6 +21,9 @@ export type ModelReportRow = { savingsUSD: number savingsBaselineModel: string calls: number + /// Codex credit consumption (issues #408/#495). null for non-Codex models or + /// Codex models without a known credit rate. + credits: number | null topCategory?: TaskCategory topCategoryCost?: number topCategoryShare?: number @@ -156,6 +160,16 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat savingsUSD: bucket.savingsUSD, savingsBaselineModel: bucket.savingsBaselineModel, calls: bucket.calls, + // outputTokens already includes reasoning (folded in above), and for Codex + // inputTokens is non-cached with cacheReadTokens holding cached input, which + // is exactly what the credit rates expect. + credits: bucket.provider === 'codex' + ? codexCredits(bucket.model, { + inputTokens: bucket.inputTokens, + cachedReadTokens: bucket.cacheReadTokens, + outputTokens: bucket.outputTokens, + }) + : null, } if (!opts.byTask) { @@ -553,6 +567,7 @@ export function renderJson(rows: ModelReportRow[]): string { costUSD: r.costUSD, savingsUSD: r.savingsUSD, savingsBaselineModel: r.savingsBaselineModel, + credits: r.credits, })), null, 2, diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 7038cbf..ad15d0e 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -6,6 +6,7 @@ import { getLocalModelSavingsConfigHash, getShortModelName } from './models.js' import { getAllProviders } from './providers/index.js' import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js' import { aggregateModelEfficiency } from './model-efficiency.js' +import { aggregateModels } from './models-report.js' import { scanAndDetect } from './optimize.js' import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js' @@ -157,6 +158,15 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: currentData = buildPeriodData(periodInfo.label, scanProjects) } + // Codex credits for the period. Reuses the models aggregation (folds reasoning + // into output, keeps non-cached input + cached-read separate) so the figure + // matches the official credit rates. + const modelRows = await aggregateModels(scanProjects) + currentData.codexCredits = modelRows.reduce( + (sum, r) => sum + (r.provider === 'codex' && r.credits != null ? r.credits : 0), + 0, + ) + // PROVIDERS // For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero. // For specific: just this single provider with its scoped cost. diff --git a/tests/codex-credits.test.ts b/tests/codex-credits.test.ts new file mode 100644 index 0000000..cfa6c48 --- /dev/null +++ b/tests/codex-credits.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { codexCredits, codexCreditRate } from '../src/codex-credits.js' + +describe('codexCreditRate', () => { + it('resolves the documented per-model rates', () => { + expect(codexCreditRate('gpt-5.5')).toEqual({ input: 125, cachedInput: 12.5, output: 750 }) + expect(codexCreditRate('gpt-5.4')).toEqual({ input: 62.5, cachedInput: 6.25, output: 375 }) + expect(codexCreditRate('gpt-5.4-mini')).toEqual({ input: 18.75, cachedInput: 1.875, output: 113 }) + }) + + it('tolerates codex suffix variants and casing', () => { + expect(codexCreditRate('GPT-5.5-codex')?.input).toBe(125) + expect(codexCreditRate('gpt-5.4-codex-mini')?.input).toBe(18.75) + }) + + it('returns null for models with no known credit rate', () => { + expect(codexCreditRate('gpt-4o')).toBeNull() + expect(codexCreditRate('claude-opus-4-8')).toBeNull() + }) +}) + +describe('codexCredits', () => { + it('charges 1M input tokens at the input rate', () => { + expect(codexCredits('gpt-5.5', { inputTokens: 1_000_000, cachedReadTokens: 0, outputTokens: 0 })).toBe(125) + }) + + it('charges 1M output tokens at the output rate', () => { + expect(codexCredits('gpt-5.5', { inputTokens: 0, cachedReadTokens: 0, outputTokens: 1_000_000 })).toBe(750) + }) + + it('charges cache-read tokens at the cheaper cached rate', () => { + expect(codexCredits('gpt-5.5', { inputTokens: 0, cachedReadTokens: 1_000_000, outputTokens: 0 })).toBe(12.5) + }) + + it('folds reasoning tokens into the output rate', () => { + // 500k output + 500k reasoning = 1M output-billed => 750 credits. + expect(codexCredits('gpt-5.5', { inputTokens: 0, cachedReadTokens: 0, outputTokens: 500_000, reasoningTokens: 500_000 })).toBe(750) + }) + + it('sums a mixed record (gpt-5.4)', () => { + // 2M input (125) + 1M cached (6.25) + 0.5M output (187.5) = 318.75 + const credits = codexCredits('gpt-5.4', { inputTokens: 2_000_000, cachedReadTokens: 1_000_000, outputTokens: 500_000 }) + expect(credits).toBeCloseTo(125 + 6.25 + 187.5, 6) + }) + + it('clamps negative / non-finite token counts to 0', () => { + expect(codexCredits('gpt-5.5', { inputTokens: -100, cachedReadTokens: NaN, outputTokens: 1_000_000 })).toBe(750) + }) + + it('returns null for an unknown model', () => { + expect(codexCredits('gpt-4o', { inputTokens: 1_000_000, cachedReadTokens: 0, outputTokens: 0 })).toBeNull() + }) +}) diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index 5b7bcca..0d3c780 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -125,6 +125,37 @@ describe('aggregateModels', () => { expect(claudeRow.totalTokens).toBe(1800 + 300 + 800 + 13000) }) + it('computes Codex credits per model and leaves non-Codex / unknown models null', async () => { + const rows = await aggregateModels([makeProject([ + // gpt-5.5: 1M non-cached input (125) + 1M cached read (12.5) + 1M output (750) = 887.5 credits + makeTurn('feature', [ + makeCall({ provider: 'codex', model: 'gpt-5.5', input: 1_000_000, output: 1_000_000, cacheRead: 1_000_000, costUSD: 9 }), + ]), + // codex but no known credit rate -> null + makeTurn('feature', [ + makeCall({ provider: 'codex', model: 'gpt-5', input: 1000, output: 80, costUSD: 1.2 }), + ]), + // non-codex provider -> null even if tokens present + makeTurn('feature', [ + makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', input: 1000, output: 200, costUSD: 5 }), + ]), + ])]) + const byKey = Object.fromEntries(rows.map(r => [`${r.provider}:${r.model}`, r])) + expect(byKey['codex:gpt-5.5']!.credits).toBeCloseTo(887.5, 6) + expect(byKey['codex:gpt-5']!.credits).toBeNull() + expect(byKey['claude:claude-sonnet-4-6']!.credits).toBeNull() + }) + + it('includes credits in the JSON output', async () => { + const rows = await aggregateModels([makeProject([ + makeTurn('feature', [ + makeCall({ provider: 'codex', model: 'gpt-5.5', input: 0, output: 1_000_000, cacheRead: 0, costUSD: 9 }), + ]), + ])]) + const parsed = JSON.parse(renderJson(rows)) + expect(parsed[0].credits).toBeCloseTo(750, 6) + }) + it('does not double-count cache reads when a provider sets both cache fields', async () => { // Providers like codex/mux/codebuff populate cacheReadInputTokens AND // cachedInputTokens with the same value (Anthropic vs OpenAI vocabulary for @@ -231,6 +262,7 @@ describe('renderTable', () => { savingsUSD: 0, savingsBaselineModel: '', calls: 0, + credits: null, ...partial, } } diff --git a/tests/usage-aggregator.test.ts b/tests/usage-aggregator.test.ts index 8125bde..8a577dc 100644 --- a/tests/usage-aggregator.test.ts +++ b/tests/usage-aggregator.test.ts @@ -42,6 +42,9 @@ describe('buildMenubarPayloadForRange', () => { expect(Array.isArray(payload.current.topModels)).toBe(true) expect(Array.isArray(payload.history.daily)).toBe(true) expect(payload.current.retryTax.totalUSD).toBeGreaterThanOrEqual(0) + // Codex credits are always present in the payload (display gates them); 0 with no data. + expect(typeof payload.current.codexCredits).toBe('number') + expect(payload.current.codexCredits).toBeGreaterThanOrEqual(0) // optimize:false => scanAndDetect skipped => empty optimize block regardless of data expect(payload.optimize).toEqual({ findingCount: 0, savingsUSD: 0, topFindings: [] }) })