mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-09 17:19:15 +00:00
* 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:
parent
96d74a04b3
commit
a3da2ded2a
11 changed files with 186 additions and 2 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: []),
|
||||
|
|
|
|||
|
|
@ -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
57
src/codex-credits.ts
Normal 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
|
||||
)
|
||||
}
|
||||
|
|
@ -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: [] },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
53
tests/codex-credits.test.ts
Normal file
53
tests/codex-credits.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [] })
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue