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.
This commit is contained in:
Resham Joshi 2026-06-18 17:03:46 +02:00 committed by GitHub
parent 96d74a04b3
commit a3da2ded2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 186 additions and 2 deletions

View file

@ -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 {

View file

@ -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

View file

@ -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: []),

View file

@ -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(

57
src/codex-credits.ts Normal file
View file

@ -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<string, CodexCreditRate> = {
'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
)
}

View file

@ -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: [] },

View file

@ -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,

View file

@ -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.

View file

@ -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()
})
})

View file

@ -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,
}
}

View file

@ -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: [] })
})