codeburn/tests/usage-aggregator.test.ts
Resham Joshi a3da2ded2a
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.
2026-06-18 17:03:46 +02:00

51 lines
2.4 KiB
TypeScript

import { describe, expect, it, beforeAll, afterAll } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js'
import { getDateRange } from '../src/cli-date.js'
import { loadPricing } from '../src/models.js'
describe('buildMenubarPayloadForRange', () => {
// Point HOME / config at an empty temp dir so the payload is built from an
// empty dataset. This keeps the test deterministic and fast regardless of how
// much real session data the developer's machine has for "today" (parsing a
// heavy day previously pushed this past its timeout).
const saved: Record<string, string | undefined> = {}
let tmp: string
beforeAll(async () => {
tmp = await mkdtemp(join(tmpdir(), 'codeburn-agg-test-'))
for (const key of ['HOME', 'CLAUDE_CONFIG_DIR', 'XDG_CONFIG_HOME', 'CODEBURN_CACHE_DIR']) {
saved[key] = process.env[key]
}
process.env['HOME'] = tmp
process.env['CLAUDE_CONFIG_DIR'] = join(tmp, '.claude')
process.env['XDG_CONFIG_HOME'] = join(tmp, '.config')
process.env['CODEBURN_CACHE_DIR'] = join(tmp, 'cache')
await loadPricing()
})
afterAll(async () => {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
await rm(tmp, { recursive: true, force: true })
})
it('returns a valid payload and skips optimize findings when optimize:false', async () => {
const payload = await buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false })
expect(typeof payload.current.label).toBe('string')
expect(payload.current.cost).toBeGreaterThanOrEqual(0)
expect(Array.isArray(payload.current.topProjects)).toBe(true)
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: [] })
})
})