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
2 changes: 1 addition & 1 deletion mac/Sources/CodeBurnMenubar/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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: [])
Expand Down Expand Up @@ -368,6 +371,7 @@ extension MenubarPayload {
inputTokens: 0,
outputTokens: 0,
cacheHitPercent: 0,
codexCredits: nil,
topActivities: [],
topModels: [],
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
Expand Down
1 change: 1 addition & 0 deletions mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
57 changes: 57 additions & 0 deletions src/codex-credits.ts
Original file line number Diff line number Diff line change
@@ -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
)
}
6 changes: 6 additions & 0 deletions src/menubar-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> }> }>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: [] },
Expand Down
15 changes: 15 additions & 0 deletions src/models-report.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -553,6 +567,7 @@ export function renderJson(rows: ModelReportRow[]): string {
costUSD: r.costUSD,
savingsUSD: r.savingsUSD,
savingsBaselineModel: r.savingsBaselineModel,
credits: r.credits,
})),
null,
2,
Expand Down
10 changes: 10 additions & 0 deletions src/usage-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions tests/codex-credits.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
32 changes: 32 additions & 0 deletions tests/models-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -231,6 +262,7 @@ describe('renderTable', () => {
savingsUSD: 0,
savingsBaselineModel: '',
calls: 0,
credits: null,
...partial,
}
}
Expand Down
3 changes: 3 additions & 0 deletions tests/usage-aggregator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] })
})
Expand Down