From 623b4d70f8957e5c4cd36d98bfeeb782f931c230 Mon Sep 17 00:00:00 2001 From: jaycdave88 Date: Sun, 19 Apr 2026 20:47:18 -0400 Subject: [PATCH 1/9] feat: add billing engine with USD and credit calculation Agent-Id: agent-1f44ae93-640e-45ab-972e-abbc420123b4 Linked-Note-Id: 34df5bc6-6be4-4e58-a22b-acbdb0bcbd29 --- src/billing.ts | 143 +++++++++++++++++++++++++++++++++++++ tests/billing.test.ts | 161 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 src/billing.ts create mode 100644 tests/billing.test.ts diff --git a/src/billing.ts b/src/billing.ts new file mode 100644 index 0000000..ef7cc95 --- /dev/null +++ b/src/billing.ts @@ -0,0 +1,143 @@ +/** + * Billing engine for CodeBurn v2.0.0 + * + * Supports dual billing: estimated USD (via LiteLLM pricing) and Augment credits. + * The credit formula matches Augment's internal billing: + * credits = ⌈costUSD × BASE_RATE × (1 + surchargeRate)⌉ + * + * Environment variables: + * CODEBURN_BILLING_MODE: 'usd' | 'credits' | 'dual' (default: 'dual') + * CODEBURN_SURCHARGE_RATE: decimal surcharge (default: 0) + * + * Activity and model multipliers are hardcoded to 1.0 per spec. + */ + +import { calculateCost } from './models.js' + +// ============================================================================ +// Constants (module-internal, not configurable via env) +// ============================================================================ + +/** Base conversion rate: 1 USD = 100 credits */ +const BASE_RATE = 100 + +/** Activity multiplier - hardcoded per spec */ +const ACTIVITY_MULTIPLIER = 1.0 + +/** Model multiplier - hardcoded per spec */ +const MODEL_MULTIPLIER = 1.0 + +// ============================================================================ +// Types +// ============================================================================ + +export type BillingMode = 'usd' | 'credits' | 'dual' + +export type BillingConfig = { + mode: BillingMode + surchargeRate: number +} + +export type BillingResult = { + costUSD: number + credits: number | null +} + +// ============================================================================ +// Config loading +// ============================================================================ + +function parseBillingMode(value: string | undefined): BillingMode { + if (value === 'usd' || value === 'credits' || value === 'dual') { + return value + } + return 'dual' +} + +function parseSurchargeRate(value: string | undefined): number { + if (!value) return 0 + const parsed = parseFloat(value) + if (Number.isNaN(parsed) || parsed < 0) return 0 + return parsed +} + +/** + * Load billing configuration from environment variables. + */ +export function loadBillingConfig(): BillingConfig { + return { + mode: parseBillingMode(process.env['CODEBURN_BILLING_MODE']), + surchargeRate: parseSurchargeRate(process.env['CODEBURN_SURCHARGE_RATE']), + } +} + +// ============================================================================ +// Credit calculation +// ============================================================================ + +/** + * Calculate credits from USD cost using Augment's formula: + * credits = ⌈costUSD × BASE_RATE × activityMultiplier × modelMultiplier × (1 + surchargeRate)⌉ + * + * Activity and model multipliers are hardcoded to 1.0. + */ +export function calculateCredits(costUSD: number, surchargeRate: number): number { + if (costUSD <= 0) return 0 + const raw = costUSD * BASE_RATE * ACTIVITY_MULTIPLIER * MODEL_MULTIPLIER * (1 + surchargeRate) + return Math.ceil(raw) +} + +// ============================================================================ +// Billing calculation +// ============================================================================ + +/** + * Calculate billing for a single API call. + * + * Uses calculateCost from models.ts for the USD calculation, then converts to credits + * based on the billing mode. + */ +export function calculateBilling( + config: BillingConfig, + model: string, + inputTokens: number, + outputTokens: number, + cacheCreationTokens: number, + cacheReadTokens: number, + webSearchRequests: number, + speed: 'standard' | 'fast' = 'standard', +): BillingResult { + const costUSD = calculateCost( + model, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + webSearchRequests, + speed, + ) + + let credits: number | null = null + + if (config.mode === 'credits' || config.mode === 'dual') { + credits = calculateCredits(costUSD, config.surchargeRate) + } + + return { costUSD, credits } +} + +/** + * Format billing result for display based on mode. + */ +export function formatBillingResult(result: BillingResult, mode: BillingMode): string { + switch (mode) { + case 'usd': + return `$${result.costUSD.toFixed(4)}` + case 'credits': + return result.credits !== null ? `${result.credits} credits` : 'N/A' + case 'dual': + return result.credits !== null + ? `$${result.costUSD.toFixed(4)} (${result.credits} credits)` + : `$${result.costUSD.toFixed(4)}` + } +} diff --git a/tests/billing.test.ts b/tests/billing.test.ts new file mode 100644 index 0000000..2de07cd --- /dev/null +++ b/tests/billing.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { + loadBillingConfig, + calculateCredits, + calculateBilling, + formatBillingResult, + type BillingConfig, + type BillingMode, +} from '../src/billing.js' + +describe('billing module', () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv } + delete process.env['CODEBURN_BILLING_MODE'] + delete process.env['CODEBURN_SURCHARGE_RATE'] + }) + + afterEach(() => { + process.env = originalEnv + }) + + // ========================================================================= + // Test 1: Config loader defaults + // ========================================================================= + describe('loadBillingConfig', () => { + it('returns default config when no env vars are set', () => { + const config = loadBillingConfig() + expect(config.mode).toBe('dual') + expect(config.surchargeRate).toBe(0) + }) + + it('parses CODEBURN_BILLING_MODE correctly', () => { + process.env['CODEBURN_BILLING_MODE'] = 'usd' + expect(loadBillingConfig().mode).toBe('usd') + + process.env['CODEBURN_BILLING_MODE'] = 'credits' + expect(loadBillingConfig().mode).toBe('credits') + + process.env['CODEBURN_BILLING_MODE'] = 'dual' + expect(loadBillingConfig().mode).toBe('dual') + }) + + it('falls back to dual for invalid billing mode', () => { + process.env['CODEBURN_BILLING_MODE'] = 'invalid' + expect(loadBillingConfig().mode).toBe('dual') + }) + + it('parses CODEBURN_SURCHARGE_RATE correctly', () => { + process.env['CODEBURN_SURCHARGE_RATE'] = '0.15' + expect(loadBillingConfig().surchargeRate).toBe(0.15) + + process.env['CODEBURN_SURCHARGE_RATE'] = '0.5' + expect(loadBillingConfig().surchargeRate).toBe(0.5) + }) + + it('returns 0 surcharge for invalid values', () => { + process.env['CODEBURN_SURCHARGE_RATE'] = 'invalid' + expect(loadBillingConfig().surchargeRate).toBe(0) + + process.env['CODEBURN_SURCHARGE_RATE'] = '-0.1' + expect(loadBillingConfig().surchargeRate).toBe(0) + }) + }) + + // ========================================================================= + // Test 2: Credit calculation with zero cost + // ========================================================================= + describe('calculateCredits', () => { + it('returns 0 for zero cost', () => { + expect(calculateCredits(0, 0)).toBe(0) + expect(calculateCredits(0, 0.15)).toBe(0) + }) + + // Test 3: Credit calculation with no surcharge + it('calculates credits with no surcharge (BASE_RATE = 100)', () => { + // $1.00 × 100 = 100 credits + expect(calculateCredits(1.0, 0)).toBe(100) + // $0.50 × 100 = 50 credits + expect(calculateCredits(0.5, 0)).toBe(50) + // $0.01 × 100 = 1 credit + expect(calculateCredits(0.01, 0)).toBe(1) + }) + + // Test 4: Credit calculation with surcharge + it('calculates credits with surcharge using Math.ceil', () => { + // $1.00 × 100 × (1 + 0.15) = 115 credits + expect(calculateCredits(1.0, 0.15)).toBe(115) + // $0.50 × 100 × (1 + 0.15) = 57.5 → ceil → 58 credits + expect(calculateCredits(0.5, 0.15)).toBe(58) + // $0.01 × 100 × (1 + 0.15) = 1.15 → ceil → 2 credits + expect(calculateCredits(0.01, 0.15)).toBe(2) + }) + + // Test 5: Math.ceil rounding behavior + it('always rounds up with Math.ceil', () => { + // $0.001 × 100 = 0.1 → ceil → 1 credit + expect(calculateCredits(0.001, 0)).toBe(1) + // $0.0001 × 100 = 0.01 → ceil → 1 credit + expect(calculateCredits(0.0001, 0)).toBe(1) + // $1.001 × 100 = 100.1 → ceil → 101 credits + expect(calculateCredits(1.001, 0)).toBe(101) + }) + }) + + // ========================================================================= + // Test 6: Billing result with dual mode + // ========================================================================= + describe('calculateBilling', () => { + it('returns both costUSD and credits in dual mode', () => { + const config: BillingConfig = { mode: 'dual', surchargeRate: 0 } + // Using claude-opus-4-5: input=$5e-6/token, output=$25e-6/token + const result = calculateBilling(config, 'claude-opus-4-5', 1000, 500, 0, 0, 0) + expect(result.costUSD).toBeGreaterThan(0) + expect(result.credits).not.toBeNull() + expect(result.credits).toBeGreaterThan(0) + }) + + // Test 7: Billing result with usd-only mode + it('returns null credits in usd mode', () => { + const config: BillingConfig = { mode: 'usd', surchargeRate: 0 } + const result = calculateBilling(config, 'claude-opus-4-5', 1000, 500, 0, 0, 0) + expect(result.costUSD).toBeGreaterThan(0) + expect(result.credits).toBeNull() + }) + + // Test 8: Billing result with credits-only mode + it('returns credits in credits mode', () => { + const config: BillingConfig = { mode: 'credits', surchargeRate: 0 } + const result = calculateBilling(config, 'claude-opus-4-5', 1000, 500, 0, 0, 0) + expect(result.costUSD).toBeGreaterThan(0) + expect(result.credits).not.toBeNull() + }) + }) + + // ========================================================================= + // Additional tests: formatBillingResult + // ========================================================================= + describe('formatBillingResult', () => { + it('formats usd mode correctly', () => { + const result = formatBillingResult({ costUSD: 1.2345, credits: 124 }, 'usd') + expect(result).toBe('$1.2345') + }) + + it('formats credits mode correctly', () => { + const result = formatBillingResult({ costUSD: 1.2345, credits: 124 }, 'credits') + expect(result).toBe('124 credits') + }) + + it('formats dual mode correctly', () => { + const result = formatBillingResult({ costUSD: 1.2345, credits: 124 }, 'dual') + expect(result).toBe('$1.2345 (124 credits)') + }) + + it('handles null credits gracefully', () => { + expect(formatBillingResult({ costUSD: 1.0, credits: null }, 'credits')).toBe('N/A') + expect(formatBillingResult({ costUSD: 1.0, credits: null }, 'dual')).toBe('$1.0000') + }) + }) +}) From 1875bbb45e33047806cbee0f15b7e793da99348f Mon Sep 17 00:00:00 2001 From: jaycdave88 Date: Sun, 19 Apr 2026 20:54:59 -0400 Subject: [PATCH 2/9] refactor: pivot billing to mutually exclusive credits and token_plus mod Agent-Id: agent-1f44ae93-640e-45ab-972e-abbc420123b4 Linked-Note-Id: 34df5bc6-6be4-4e58-a22b-acbdb0bcbd29 --- src/billing.ts | 183 +++++++++++++------------- tests/billing.test.ts | 290 +++++++++++++++++++++--------------------- 2 files changed, 244 insertions(+), 229 deletions(-) diff --git a/src/billing.ts b/src/billing.ts index ef7cc95..014caad 100644 --- a/src/billing.ts +++ b/src/billing.ts @@ -1,143 +1,154 @@ /** * Billing engine for CodeBurn v2.0.0 * - * Supports dual billing: estimated USD (via LiteLLM pricing) and Augment credits. - * The credit formula matches Augment's internal billing: - * credits = ⌈costUSD × BASE_RATE × (1 + surchargeRate)⌉ + * Two mutually exclusive billing modes: + * - credits: Only Augment credits (ground-truth or synthesized), never USD + * - token_plus: Only USD (base + surcharge = billed), never credits * * Environment variables: - * CODEBURN_BILLING_MODE: 'usd' | 'credits' | 'dual' (default: 'dual') - * CODEBURN_SURCHARGE_RATE: decimal surcharge (default: 0) + * CODEBURN_BILLING_MODE: 'credits' | 'token_plus' (default: 'credits') + * CODEBURN_SURCHARGE_RATE: decimal surcharge for token_plus mode (default: 0.3 = 30%) * - * Activity and model multipliers are hardcoded to 1.0 per spec. + * Credit formula (no surcharge): Math.ceil(baseCostUsd × 1600 × 1.0 × 1.0) */ -import { calculateCost } from './models.js' +import type { ModelCosts } from './models.js' // ============================================================================ -// Constants (module-internal, not configurable via env) +// Constants // ============================================================================ -/** Base conversion rate: 1 USD = 100 credits */ -const BASE_RATE = 100 +/** Augment credits per dollar */ +export const CREDITS_PER_DOLLAR = 1600 -/** Activity multiplier - hardcoded per spec */ +/** Activity multiplier - hardcoded per spec (module-internal) */ const ACTIVITY_MULTIPLIER = 1.0 -/** Model multiplier - hardcoded per spec */ +/** Model multiplier - hardcoded per spec (module-internal) */ const MODEL_MULTIPLIER = 1.0 // ============================================================================ // Types // ============================================================================ -export type BillingMode = 'usd' | 'credits' | 'dual' +export type BillingMode = 'credits' | 'token_plus' export type BillingConfig = { mode: BillingMode - surchargeRate: number + surchargeRate: number // only used in token_plus mode } export type BillingResult = { - costUSD: number - credits: number | null + mode: BillingMode + baseCostUsd: number // always computed + surchargeUsd: number | null // null in credits mode + billedAmountUsd: number | null // null in credits mode + creditsAugment: number | null // null in token_plus mode or when model unknown + creditsSynthesized: number | null // null unless synthesized in credits mode + synthesized: boolean // true iff credits were synthesized (no ground truth) } // ============================================================================ // Config loading // ============================================================================ -function parseBillingMode(value: string | undefined): BillingMode { - if (value === 'usd' || value === 'credits' || value === 'dual') { - return value - } - return 'dual' -} +export function loadBillingConfig(env: NodeJS.ProcessEnv = process.env): BillingConfig { + const rawMode = env.CODEBURN_BILLING_MODE + const mode: BillingMode = rawMode === 'token_plus' ? 'token_plus' : 'credits' // invalid → 'credits' -function parseSurchargeRate(value: string | undefined): number { - if (!value) return 0 - const parsed = parseFloat(value) - if (Number.isNaN(parsed) || parsed < 0) return 0 - return parsed -} + const rawSurcharge = env.CODEBURN_SURCHARGE_RATE + const parsed = rawSurcharge !== undefined ? Number(rawSurcharge) : NaN + const surchargeRate = Number.isFinite(parsed) && parsed >= 0 ? parsed : 0.3 -/** - * Load billing configuration from environment variables. - */ -export function loadBillingConfig(): BillingConfig { - return { - mode: parseBillingMode(process.env['CODEBURN_BILLING_MODE']), - surchargeRate: parseSurchargeRate(process.env['CODEBURN_SURCHARGE_RATE']), - } + return { mode, surchargeRate } } // ============================================================================ -// Credit calculation +// Credit synthesis // ============================================================================ /** - * Calculate credits from USD cost using Augment's formula: - * credits = ⌈costUSD × BASE_RATE × activityMultiplier × modelMultiplier × (1 + surchargeRate)⌉ - * - * Activity and model multipliers are hardcoded to 1.0. + * Synthesize credits from base USD cost. + * Formula: Math.ceil(baseCostUsd × CREDITS_PER_DOLLAR × ACTIVITY_MULTIPLIER × MODEL_MULTIPLIER) + * Note: NO surcharge applied to credits - surcharge is a token_plus concept only. */ -export function calculateCredits(costUSD: number, surchargeRate: number): number { - if (costUSD <= 0) return 0 - const raw = costUSD * BASE_RATE * ACTIVITY_MULTIPLIER * MODEL_MULTIPLIER * (1 + surchargeRate) - return Math.ceil(raw) +export function synthesizeCredits(baseCostUsd: number): number { + return Math.ceil(baseCostUsd * CREDITS_PER_DOLLAR * ACTIVITY_MULTIPLIER * MODEL_MULTIPLIER) } // ============================================================================ -// Billing calculation +// Core billing computation // ============================================================================ /** - * Calculate billing for a single API call. + * Calculate base USD cost from tokens and model costs. + */ +function calculateBaseCost( + tokens: { input: number; output: number; cacheRead?: number; cacheWrite?: number }, + modelCosts: ModelCosts, +): number { + return ( + tokens.input * modelCosts.inputCostPerToken + + tokens.output * modelCosts.outputCostPerToken + + (tokens.cacheRead ?? 0) * modelCosts.cacheReadCostPerToken + + (tokens.cacheWrite ?? 0) * modelCosts.cacheWriteCostPerToken + ) +} + +/** + * Compute billing for a single API call. * - * Uses calculateCost from models.ts for the USD calculation, then converts to credits - * based on the billing mode. + * @param tokens - Token counts (input, output, cacheRead, cacheWrite) + * @param modelCosts - Model pricing info, or null for unknown/legacy models + * @param config - Billing configuration (mode and surchargeRate) + * @param groundTruthCredits - Credits from type-9 BILLING_METADATA (optional) */ -export function calculateBilling( +export function computeBilling( + tokens: { input: number; output: number; cacheRead?: number; cacheWrite?: number }, + modelCosts: ModelCosts | null, config: BillingConfig, - model: string, - inputTokens: number, - outputTokens: number, - cacheCreationTokens: number, - cacheReadTokens: number, - webSearchRequests: number, - speed: 'standard' | 'fast' = 'standard', + groundTruthCredits?: number | null, ): BillingResult { - const costUSD = calculateCost( - model, - inputTokens, - outputTokens, - cacheCreationTokens, - cacheReadTokens, - webSearchRequests, - speed, - ) - - let credits: number | null = null - - if (config.mode === 'credits' || config.mode === 'dual') { - credits = calculateCredits(costUSD, config.surchargeRate) + const baseCostUsd = modelCosts ? calculateBaseCost(tokens, modelCosts) : 0 + + if (config.mode === 'credits') { + // Credits mode: NO surcharge, NO billed USD. Either ground-truth or synthesized. + let creditsAugment: number | null + let creditsSynthesized: number | null = null + let synthesized = false + + if (groundTruthCredits != null) { + creditsAugment = groundTruthCredits + } else if (modelCosts) { + creditsSynthesized = synthesizeCredits(baseCostUsd) + creditsAugment = creditsSynthesized + synthesized = true + } else { + creditsAugment = null // unknown model AND no ground truth → cannot compute + } + + return { + mode: 'credits', + baseCostUsd, + surchargeUsd: null, + billedAmountUsd: null, + creditsAugment, + creditsSynthesized, + synthesized, + } } - return { costUSD, credits } -} + // Token+ mode: NO credits. Base + surcharge = billed. + const surchargeUsd = baseCostUsd * config.surchargeRate + const billedAmountUsd = baseCostUsd + surchargeUsd -/** - * Format billing result for display based on mode. - */ -export function formatBillingResult(result: BillingResult, mode: BillingMode): string { - switch (mode) { - case 'usd': - return `$${result.costUSD.toFixed(4)}` - case 'credits': - return result.credits !== null ? `${result.credits} credits` : 'N/A' - case 'dual': - return result.credits !== null - ? `$${result.costUSD.toFixed(4)} (${result.credits} credits)` - : `$${result.costUSD.toFixed(4)}` + return { + mode: 'token_plus', + baseCostUsd, + surchargeUsd, + billedAmountUsd, + creditsAugment: null, + creditsSynthesized: null, + synthesized: false, } } diff --git a/tests/billing.test.ts b/tests/billing.test.ts index 2de07cd..86d1626 100644 --- a/tests/billing.test.ts +++ b/tests/billing.test.ts @@ -1,161 +1,165 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect } from 'vitest' import { loadBillingConfig, - calculateCredits, - calculateBilling, - formatBillingResult, + computeBilling, + synthesizeCredits, + CREDITS_PER_DOLLAR, type BillingConfig, - type BillingMode, } from '../src/billing.js' +import type { ModelCosts } from '../src/models.js' + +// Realistic model costs fixture (claude-opus-4-5 pricing) +const OPUS_COSTS: ModelCosts = { + inputCostPerToken: 5e-6, + outputCostPerToken: 25e-6, + cacheWriteCostPerToken: 6.25e-6, + cacheReadCostPerToken: 0.5e-6, + webSearchCostPerRequest: 0.01, + fastMultiplier: 1, +} + +describe('computeBilling — credits mode', () => { + // Test 1: passes through ground-truth credits unchanged + it('1. passes through ground-truth credits unchanged', () => { + const config: BillingConfig = { mode: 'credits', surchargeRate: 0 } + const tokens = { input: 1000, output: 500 } + const groundTruthCredits = 12345 + + const result = computeBilling(tokens, OPUS_COSTS, config, groundTruthCredits) + + expect(result.creditsAugment).toBe(12345) + expect(result.creditsSynthesized).toBeNull() + expect(result.synthesized).toBe(false) + expect(result.surchargeUsd).toBeNull() + expect(result.billedAmountUsd).toBeNull() + expect(result.mode).toBe('credits') + }) + + // Test 2: synthesizes credits when groundTruthCredits missing but model+tokens present + it('2. synthesizes credits when groundTruthCredits missing but model+tokens present', () => { + const config: BillingConfig = { mode: 'credits', surchargeRate: 0 } + const tokens = { input: 1000, output: 500 } + + const result = computeBilling(tokens, OPUS_COSTS, config, null) + + // baseCostUsd = 1000 * 5e-6 + 500 * 25e-6 = 0.005 + 0.0125 = 0.0175 + // credits = Math.ceil(0.0175 * 1600) = Math.ceil(28) = 28 + const expectedBase = 1000 * 5e-6 + 500 * 25e-6 + const expectedCredits = Math.ceil(expectedBase * CREDITS_PER_DOLLAR) + + expect(result.synthesized).toBe(true) + expect(result.creditsAugment).toBe(expectedCredits) + expect(result.creditsSynthesized).toBe(expectedCredits) + expect(result.baseCostUsd).toBeCloseTo(expectedBase) + expect(result.surchargeUsd).toBeNull() + expect(result.billedAmountUsd).toBeNull() + }) + + // Test 3: returns credits = 0 when tokens are all zero and model is known + it('3. returns credits = 0 when tokens are all zero and model is known', () => { + const config: BillingConfig = { mode: 'credits', surchargeRate: 0 } + const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } -describe('billing module', () => { - const originalEnv = process.env + const result = computeBilling(tokens, OPUS_COSTS, config, null) + + expect(result.creditsAugment).toBe(0) + expect(result.synthesized).toBe(true) + expect(result.baseCostUsd).toBe(0) + }) + + // Test 4: returns creditsAugment = null when model is unknown and no ground truth + it('4. returns creditsAugment = null when model is unknown and no ground truth', () => { + const config: BillingConfig = { mode: 'credits', surchargeRate: 0 } + const tokens = { input: 1000, output: 500 } + + const result = computeBilling(tokens, null, config, null) + + expect(result.creditsAugment).toBeNull() + expect(result.creditsSynthesized).toBeNull() + expect(result.synthesized).toBe(false) + expect(result.baseCostUsd).toBe(0) + }) +}) + +describe('computeBilling — token_plus mode', () => { + // Test 5: applies default 30% surcharge so billed = base × 1.3 + it('5. applies default 30% surcharge so billed = base × 1.3', () => { + const config: BillingConfig = { mode: 'token_plus', surchargeRate: 0.3 } + const tokens = { input: 1000, output: 500 } + + const result = computeBilling(tokens, OPUS_COSTS, config, null) + + const expectedBase = 1000 * 5e-6 + 500 * 25e-6 // 0.0175 + expect(result.baseCostUsd).toBeCloseTo(expectedBase) + expect(result.surchargeUsd).toBeCloseTo(expectedBase * 0.3) + expect(result.billedAmountUsd).toBeCloseTo(expectedBase * 1.3) + expect(result.creditsAugment).toBeNull() + expect(result.creditsSynthesized).toBeNull() + expect(result.synthesized).toBe(false) + expect(result.mode).toBe('token_plus') + }) + + // Test 6: applies custom 25% surcharge so billed = base × 1.25 + it('6. applies custom 25% surcharge so billed = base × 1.25', () => { + const config: BillingConfig = { mode: 'token_plus', surchargeRate: 0.25 } + const tokens = { input: 2000, output: 1000 } + + const result = computeBilling(tokens, OPUS_COSTS, config, null) + + const expectedBase = 2000 * 5e-6 + 1000 * 25e-6 // 0.035 + expect(result.baseCostUsd).toBeCloseTo(expectedBase) + expect(result.surchargeUsd).toBeCloseTo(expectedBase * 0.25) + expect(result.billedAmountUsd).toBeCloseTo(expectedBase * 1.25) + }) + + // Test 7: applies 0% surcharge so billed = base exactly + it('7. applies 0% surcharge so billed = base exactly', () => { + const config: BillingConfig = { mode: 'token_plus', surchargeRate: 0 } + const tokens = { input: 1000, output: 500 } + + const result = computeBilling(tokens, OPUS_COSTS, config, null) + + const expectedBase = 1000 * 5e-6 + 500 * 25e-6 + expect(result.baseCostUsd).toBeCloseTo(expectedBase) + expect(result.surchargeUsd).toBe(0) + expect(result.billedAmountUsd).toBeCloseTo(expectedBase) + }) +}) + +describe('loadBillingConfig', () => { + // Test 8: falls back to credits mode for invalid CODEBURN_BILLING_MODE + it('8. falls back to credits mode for invalid CODEBURN_BILLING_MODE', () => { + const config = loadBillingConfig({ CODEBURN_BILLING_MODE: 'bogus' }) + expect(config.mode).toBe('credits') + }) - beforeEach(() => { - process.env = { ...originalEnv } - delete process.env['CODEBURN_BILLING_MODE'] - delete process.env['CODEBURN_SURCHARGE_RATE'] + it('defaults to credits mode when env var is unset', () => { + const config = loadBillingConfig({}) + expect(config.mode).toBe('credits') }) - afterEach(() => { - process.env = originalEnv + it('parses token_plus mode correctly', () => { + const config = loadBillingConfig({ CODEBURN_BILLING_MODE: 'token_plus' }) + expect(config.mode).toBe('token_plus') }) - // ========================================================================= - // Test 1: Config loader defaults - // ========================================================================= - describe('loadBillingConfig', () => { - it('returns default config when no env vars are set', () => { - const config = loadBillingConfig() - expect(config.mode).toBe('dual') - expect(config.surchargeRate).toBe(0) - }) - - it('parses CODEBURN_BILLING_MODE correctly', () => { - process.env['CODEBURN_BILLING_MODE'] = 'usd' - expect(loadBillingConfig().mode).toBe('usd') - - process.env['CODEBURN_BILLING_MODE'] = 'credits' - expect(loadBillingConfig().mode).toBe('credits') - - process.env['CODEBURN_BILLING_MODE'] = 'dual' - expect(loadBillingConfig().mode).toBe('dual') - }) - - it('falls back to dual for invalid billing mode', () => { - process.env['CODEBURN_BILLING_MODE'] = 'invalid' - expect(loadBillingConfig().mode).toBe('dual') - }) - - it('parses CODEBURN_SURCHARGE_RATE correctly', () => { - process.env['CODEBURN_SURCHARGE_RATE'] = '0.15' - expect(loadBillingConfig().surchargeRate).toBe(0.15) - - process.env['CODEBURN_SURCHARGE_RATE'] = '0.5' - expect(loadBillingConfig().surchargeRate).toBe(0.5) - }) - - it('returns 0 surcharge for invalid values', () => { - process.env['CODEBURN_SURCHARGE_RATE'] = 'invalid' - expect(loadBillingConfig().surchargeRate).toBe(0) - - process.env['CODEBURN_SURCHARGE_RATE'] = '-0.1' - expect(loadBillingConfig().surchargeRate).toBe(0) - }) + it('defaults surchargeRate to 0.3 when unset', () => { + const config = loadBillingConfig({}) + expect(config.surchargeRate).toBe(0.3) }) - // ========================================================================= - // Test 2: Credit calculation with zero cost - // ========================================================================= - describe('calculateCredits', () => { - it('returns 0 for zero cost', () => { - expect(calculateCredits(0, 0)).toBe(0) - expect(calculateCredits(0, 0.15)).toBe(0) - }) - - // Test 3: Credit calculation with no surcharge - it('calculates credits with no surcharge (BASE_RATE = 100)', () => { - // $1.00 × 100 = 100 credits - expect(calculateCredits(1.0, 0)).toBe(100) - // $0.50 × 100 = 50 credits - expect(calculateCredits(0.5, 0)).toBe(50) - // $0.01 × 100 = 1 credit - expect(calculateCredits(0.01, 0)).toBe(1) - }) - - // Test 4: Credit calculation with surcharge - it('calculates credits with surcharge using Math.ceil', () => { - // $1.00 × 100 × (1 + 0.15) = 115 credits - expect(calculateCredits(1.0, 0.15)).toBe(115) - // $0.50 × 100 × (1 + 0.15) = 57.5 → ceil → 58 credits - expect(calculateCredits(0.5, 0.15)).toBe(58) - // $0.01 × 100 × (1 + 0.15) = 1.15 → ceil → 2 credits - expect(calculateCredits(0.01, 0.15)).toBe(2) - }) - - // Test 5: Math.ceil rounding behavior - it('always rounds up with Math.ceil', () => { - // $0.001 × 100 = 0.1 → ceil → 1 credit - expect(calculateCredits(0.001, 0)).toBe(1) - // $0.0001 × 100 = 0.01 → ceil → 1 credit - expect(calculateCredits(0.0001, 0)).toBe(1) - // $1.001 × 100 = 100.1 → ceil → 101 credits - expect(calculateCredits(1.001, 0)).toBe(101) - }) + it('parses custom surchargeRate', () => { + const config = loadBillingConfig({ CODEBURN_SURCHARGE_RATE: '0.25' }) + expect(config.surchargeRate).toBe(0.25) }) - // ========================================================================= - // Test 6: Billing result with dual mode - // ========================================================================= - describe('calculateBilling', () => { - it('returns both costUSD and credits in dual mode', () => { - const config: BillingConfig = { mode: 'dual', surchargeRate: 0 } - // Using claude-opus-4-5: input=$5e-6/token, output=$25e-6/token - const result = calculateBilling(config, 'claude-opus-4-5', 1000, 500, 0, 0, 0) - expect(result.costUSD).toBeGreaterThan(0) - expect(result.credits).not.toBeNull() - expect(result.credits).toBeGreaterThan(0) - }) - - // Test 7: Billing result with usd-only mode - it('returns null credits in usd mode', () => { - const config: BillingConfig = { mode: 'usd', surchargeRate: 0 } - const result = calculateBilling(config, 'claude-opus-4-5', 1000, 500, 0, 0, 0) - expect(result.costUSD).toBeGreaterThan(0) - expect(result.credits).toBeNull() - }) - - // Test 8: Billing result with credits-only mode - it('returns credits in credits mode', () => { - const config: BillingConfig = { mode: 'credits', surchargeRate: 0 } - const result = calculateBilling(config, 'claude-opus-4-5', 1000, 500, 0, 0, 0) - expect(result.costUSD).toBeGreaterThan(0) - expect(result.credits).not.toBeNull() - }) + it('falls back to 0.3 for negative surchargeRate', () => { + const config = loadBillingConfig({ CODEBURN_SURCHARGE_RATE: '-0.1' }) + expect(config.surchargeRate).toBe(0.3) }) - // ========================================================================= - // Additional tests: formatBillingResult - // ========================================================================= - describe('formatBillingResult', () => { - it('formats usd mode correctly', () => { - const result = formatBillingResult({ costUSD: 1.2345, credits: 124 }, 'usd') - expect(result).toBe('$1.2345') - }) - - it('formats credits mode correctly', () => { - const result = formatBillingResult({ costUSD: 1.2345, credits: 124 }, 'credits') - expect(result).toBe('124 credits') - }) - - it('formats dual mode correctly', () => { - const result = formatBillingResult({ costUSD: 1.2345, credits: 124 }, 'dual') - expect(result).toBe('$1.2345 (124 credits)') - }) - - it('handles null credits gracefully', () => { - expect(formatBillingResult({ costUSD: 1.0, credits: null }, 'credits')).toBe('N/A') - expect(formatBillingResult({ costUSD: 1.0, credits: null }, 'dual')).toBe('$1.0000') - }) + it('falls back to 0.3 for non-numeric surchargeRate', () => { + const config = loadBillingConfig({ CODEBURN_SURCHARGE_RATE: 'invalid' }) + expect(config.surchargeRate).toBe(0.3) }) }) From 7fda2d0a09663767ed260048526e8c8ace365514 Mon Sep 17 00:00:00 2001 From: jaycdave88 Date: Sun, 19 Apr 2026 21:20:12 -0400 Subject: [PATCH 3/9] feat: integrate billing engine with dashboard and export formats Agent-Id: agent-042ab8eb-9be5-497f-bb47-205fe9c88cf1 Linked-Note-Id: fc854671-00be-4d9e-91f3-987906dbdb62 --- src/dashboard.tsx | 201 +++++++++++++++++++++++--------- src/export.ts | 204 +++++++++++++++++++++++++++++++++ src/format.ts | 23 +++- src/menubar-json.ts | 100 +++++++++++++--- src/parser.ts | 45 +++++++- src/providers/auggie.ts | 47 +++++++- src/providers/types.ts | 5 + src/types.ts | 33 +++++- tests/menubar-json.test.ts | 34 +++++- tests/providers/auggie.test.ts | 12 +- 10 files changed, 613 insertions(+), 91 deletions(-) diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 0e3defc..ed25222 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -6,6 +6,7 @@ import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory import { formatCost, formatTokens, formatCredits } from './format.js' import { parseAllSessions, filterProjectsByName } from './parser.js' import { loadPricing } from './models.js' +import { loadBillingConfig, type BillingMode } from './billing.js' import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js' @@ -143,7 +144,7 @@ function fit(s: string, n: number): string { return s.length > n ? s.slice(0, n) : s.padEnd(n) } -function Overview({ projects, label, width }: { projects: ProjectSummary[]; label: string; width: number }) { +function Overview({ projects, label, width, billingMode, surchargeRate }: { projects: ProjectSummary[]; label: string; width: number; billingMode: BillingMode; surchargeRate: number }) { const totalCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0) const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0) @@ -160,22 +161,56 @@ function Overview({ projects, label, width }: { projects: ProjectSummary[]; labe if (acc === null && p.totalCredits === null) return null return (acc ?? 0) + (p.totalCredits ?? 0) }, null) + // Token+ aggregates + const totalBaseCostUsd = allSessions.reduce((acc, sess) => { + if (acc === null && sess.totalBaseCostUsd == null) return null + return (acc ?? 0) + (sess.totalBaseCostUsd ?? 0) + }, null) + const totalBilledAmountUsd = allSessions.reduce((acc, sess) => { + if (acc === null && sess.totalBilledAmountUsd == null) return null + return (acc ?? 0) + (sess.totalBilledAmountUsd ?? 0) + }, null) // Count distinct sessions with auggie-legacy model (model unrecoverable from pre-Nov-2025) const legacySessions = allSessions.filter(sess => 'auggie-legacy' in sess.modelBreakdown).length + // Build billing mode subtitle + const billingSubtitle = billingMode === 'credits' + ? 'Billing: credits' + : `Billing: Token+ · surcharge ${Math.round(surchargeRate * 100)}%` + return ( CodeBurn - {label} + {label} + {billingSubtitle} - {formatCost(totalCost)} - est. USD - {totalCredits !== null && ( + {billingMode === 'credits' ? ( + // Credits mode: show credits prominently, no USD + <> + {totalCredits !== null && ( + <> + {formatCredits(totalCredits)} + credits + + )} + + ) : ( + // Token+ mode: show Base / Billed USD <> - {formatCredits(totalCredits)} - credits + {totalBaseCostUsd !== null && ( + <> + {formatCost(totalBaseCostUsd)} + base + + )} + {totalBilledAmountUsd !== null && ( + <> + {formatCost(totalBilledAmountUsd)} + billed + + )} )} {totalCalls.toLocaleString()} @@ -197,30 +232,41 @@ function Overview({ projects, label, width }: { projects: ProjectSummary[]; labe ) } -function DailyActivity({ projects, days = 14, pw, bw }: { projects: ProjectSummary[]; days?: number; pw: number; bw: number }) { - const dailyCosts: Record = {} +function DailyActivity({ projects, days = 14, pw, bw, billingMode }: { projects: ProjectSummary[]; days?: number; pw: number; bw: number; billingMode: BillingMode }) { + const dailyValues: Record = {} const dailyCalls: Record = {} for (const project of projects) { for (const session of project.sessions) { for (const turn of session.turns) { if (!turn.timestamp) continue const day = turn.timestamp.slice(0, 10) - dailyCosts[day] = (dailyCosts[day] ?? 0) + turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0) + // In credits mode: sum credits. In token_plus mode: sum billedAmountUsd + const value = turn.assistantCalls.reduce((s, c) => { + if (billingMode === 'credits') { + return s + (c.billing?.creditsAugment ?? c.credits ?? 0) + } else { + return s + (c.billing?.billedAmountUsd ?? c.costUSD) + } + }, 0) + dailyValues[day] = (dailyValues[day] ?? 0) + value dailyCalls[day] = (dailyCalls[day] ?? 0) + turn.assistantCalls.length } } } - const sortedDays = days !== undefined ? Object.keys(dailyCosts).sort().slice(-days) : Object.keys(dailyCosts).sort() - const maxCost = Math.max(...sortedDays.map(d => dailyCosts[d] ?? 0)) + const sortedDays = days !== undefined ? Object.keys(dailyValues).sort().slice(-days) : Object.keys(dailyValues).sort() + const maxValue = Math.max(...sortedDays.map(d => dailyValues[d] ?? 0)) + + const valueLabel = billingMode === 'credits' ? 'credits' : 'billed' + const formatValue = billingMode === 'credits' ? formatCredits : formatCost return ( - {''.padEnd(6 + bw)}{'cost'.padStart(8)}{'calls'.padStart(6)} + {''.padEnd(6 + bw)}{valueLabel.padStart(8)}{'calls'.padStart(6)} {sortedDays.map(day => ( {day.slice(5)} - - {formatCost(dailyCosts[day] ?? 0).padStart(8)} + + {formatValue(dailyValues[day] ?? 0).padStart(8)} {String(dailyCalls[day] ?? 0).padStart(6)} ))} @@ -245,24 +291,38 @@ function shortProject(encoded: string): string { const PROJECT_COL_AVG = 7 const PROJECT_COL_BASE_WIDTH = 30 -function ProjectBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { - const maxCost = Math.max(...projects.map(p => p.totalCostUSD)) +function ProjectBreakdown({ projects, pw, bw, billingMode }: { projects: ProjectSummary[]; pw: number; bw: number; billingMode: BillingMode }) { + // Compute total value per project based on billing mode + const projectValues = projects.map(p => { + if (billingMode === 'credits') { + return p.totalCredits ?? 0 + } else { + // Sum billedAmountUsd across all sessions + return p.sessions.reduce((s, sess) => s + (sess.totalBilledAmountUsd ?? sess.totalCostUSD), 0) + } + }) + const maxValue = Math.max(...projectValues) const nw = Math.max(8, pw - bw - PROJECT_COL_BASE_WIDTH) + + const valueLabel = billingMode === 'credits' ? 'credits' : 'billed' + const formatValue = billingMode === 'credits' ? formatCredits : formatCost + return ( - {''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)} + {''.padEnd(bw + 1 + nw)}{valueLabel.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)} {projects.slice(0, 8).map((project, i) => { - const avgCost = project.sessions.length > 0 - ? formatCost(project.totalCostUSD / project.sessions.length) + const totalValue = projectValues[i] + const avgValue = project.sessions.length > 0 + ? formatValue(totalValue / project.sessions.length) : '-' return ( - + {fit(shortProject(project.project), nw)} - {formatCost(project.totalCostUSD).padStart(8)} - {avgCost.padStart(PROJECT_COL_AVG)} + {formatValue(totalValue).padStart(8)} + {avgValue.padStart(PROJECT_COL_AVG)} {String(project.sessions.length).padStart(6)} ) @@ -271,18 +331,17 @@ function ProjectBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw ) } -const MODEL_COL_COST = 8 -const MODEL_COL_CREDITS = 7 +const MODEL_COL_VALUE = 8 const MODEL_COL_CACHE = 7 const MODEL_COL_CALLS = 7 const MODEL_NAME_WIDTH = 12 -function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { - const modelTotals: Record = {} +function ModelBreakdown({ projects, pw, bw, billingMode }: { projects: ProjectSummary[]; pw: number; bw: number; billingMode: BillingMode }) { + const modelTotals: Record = {} for (const project of projects) { for (const session of project.sessions) { for (const [model, data] of Object.entries(session.modelBreakdown)) { - if (!modelTotals[model]) modelTotals[model] = { calls: 0, costUSD: 0, credits: null, freshInput: 0, cacheRead: 0, cacheWrite: 0 } + if (!modelTotals[model]) modelTotals[model] = { calls: 0, costUSD: 0, credits: null, billedAmountUsd: null, freshInput: 0, cacheRead: 0, cacheWrite: 0 } modelTotals[model].calls += data.calls modelTotals[model].costUSD += data.costUSD // Aggregate credits: null + null = null, null + N = N, N + M = N + M @@ -293,23 +352,38 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: } else { modelTotals[model].credits = (existingCredits ?? 0) + (newCredits ?? 0) } + // Aggregate billedAmountUsd + const existingBilled = modelTotals[model].billedAmountUsd + const newBilled = data.billedAmountUsd + if (existingBilled === null && newBilled == null) { + // Both null, keep null + } else { + modelTotals[model].billedAmountUsd = (existingBilled ?? 0) + (newBilled ?? 0) + } modelTotals[model].freshInput += data.tokens.inputTokens modelTotals[model].cacheRead += data.tokens.cacheReadInputTokens modelTotals[model].cacheWrite += data.tokens.cacheCreationInputTokens } } } - const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD) - const maxCost = sorted[0]?.[1]?.costUSD ?? 0 - // Check if any model has credits data - const hasAnyCredits = sorted.some(([, d]) => d.credits !== null) + // Sort by the relevant billing value + const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => { + const valueA = billingMode === 'credits' ? (a.credits ?? 0) : (a.billedAmountUsd ?? a.costUSD) + const valueB = billingMode === 'credits' ? (b.credits ?? 0) : (b.billedAmountUsd ?? b.costUSD) + return valueB - valueA + }) + const maxValue = sorted.length > 0 + ? (billingMode === 'credits' ? (sorted[0][1].credits ?? 0) : (sorted[0][1].billedAmountUsd ?? sorted[0][1].costUSD)) + : 0 + + const valueLabel = billingMode === 'credits' ? 'credits' : 'billed' + const formatValue = billingMode === 'credits' ? formatCredits : formatCost return ( {''.padEnd(bw + 1 + MODEL_NAME_WIDTH)} - {'est.USD'.padStart(MODEL_COL_COST)} - {hasAnyCredits && 'Augment'.padStart(MODEL_COL_CREDITS)} + {valueLabel.padStart(MODEL_COL_VALUE)} {'cache'.padStart(MODEL_COL_CACHE)} {'calls'.padStart(MODEL_COL_CALLS)} @@ -317,12 +391,12 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: const totalInput = data.freshInput + data.cacheRead + data.cacheWrite const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0 const cacheLabel = totalInput > 0 ? `${cacheHit.toFixed(1)}%` : '-' + const displayValue = billingMode === 'credits' ? data.credits : (data.billedAmountUsd ?? data.costUSD) return ( - + {fit(model, MODEL_NAME_WIDTH)} - {formatCost(data.costUSD).padStart(MODEL_COL_COST)} - {hasAnyCredits && {formatCredits(data.credits).padStart(MODEL_COL_CREDITS)}} + {formatValue(displayValue ?? 0).padStart(MODEL_COL_VALUE)} {cacheLabel.padStart(MODEL_COL_CACHE)} {String(data.calls).padStart(MODEL_COL_CALLS)} @@ -398,35 +472,47 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr } const TOP_SESSIONS_DATE_LEN = 10 -const TOP_SESSIONS_COST_COL = 8 +const TOP_SESSIONS_VALUE_COL = 8 const TOP_SESSIONS_CALLS_COL = 6 -function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { +function TopSessions({ projects, pw, bw, billingMode }: { projects: ProjectSummary[]; pw: number; bw: number; billingMode: BillingMode }) { const allSessions = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectName: p.project })) ) - const top = [...allSessions].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, 5) + // Sort by relevant billing value + const getValue = (s: typeof allSessions[0]): number => { + if (billingMode === 'credits') { + return s.totalCredits ?? 0 + } else { + return s.totalBilledAmountUsd ?? s.totalCostUSD + } + } + const top = [...allSessions].sort((a, b) => getValue(b) - getValue(a)).slice(0, 5) if (top.length === 0) { return No sessions } - const maxCost = top[0].totalCostUSD - const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1 - PANEL_CHROME) + const maxValue = getValue(top[0]) + const nw = Math.max(8, pw - bw - TOP_SESSIONS_VALUE_COL - TOP_SESSIONS_CALLS_COL - 1 - PANEL_CHROME) + + const valueLabel = billingMode === 'credits' ? 'credits' : 'billed' + const formatValue = billingMode === 'credits' ? formatCredits : formatCost return ( - {''.padEnd(bw + 1 + nw)}{'cost'.padStart(TOP_SESSIONS_COST_COL)}{'calls'.padStart(TOP_SESSIONS_CALLS_COL)} + {''.padEnd(bw + 1 + nw)}{valueLabel.padStart(TOP_SESSIONS_VALUE_COL)}{'calls'.padStart(TOP_SESSIONS_CALLS_COL)} {top.map((session, i) => { const date = session.firstTimestamp ? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN) : '----------' const label = `${date} ${shortProject(session.projectName)}` + const displayValue = getValue(session) return ( - + {fit(label, nw - 1)} - {formatCost(session.totalCostUSD).padStart(TOP_SESSIONS_COST_COL)} + {formatValue(displayValue).padStart(TOP_SESSIONS_VALUE_COL)} {String(session.apiCalls).padStart(TOP_SESSIONS_CALLS_COL)} ) @@ -562,29 +648,31 @@ function Row({ wide, width, children }: { wide: boolean; width: number; children return <>{children} } -function DashboardContent({ projects, period, columns }: { projects: ProjectSummary[]; period: Period; columns?: number }) { +function DashboardContent({ projects, period, columns, billingMode, surchargeRate }: { projects: ProjectSummary[]; period: Period; columns?: number; billingMode: BillingMode; surchargeRate: number }) { const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns) if (projects.length === 0) return No usage data found for {PERIOD_LABELS[period]}. const pw = wide ? halfWidth : dashWidth const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14) return ( - - - - + + + + ) } -function InteractiveDashboard({ initialProjects, initialPeriod, refreshSeconds, projectFilter, excludeFilter }: { +function InteractiveDashboard({ initialProjects, initialPeriod, refreshSeconds, projectFilter, excludeFilter, billingMode, surchargeRate }: { initialProjects: ProjectSummary[] initialPeriod: Period refreshSeconds?: number projectFilter?: string[] excludeFilter?: string[] + billingMode: BillingMode + surchargeRate: number }) { const { exit } = useApp() const [period, setPeriod] = useState(initialPeriod) @@ -666,35 +754,36 @@ function InteractiveDashboard({ initialProjects, initialPeriod, refreshSeconds, {view === 'optimize' && optimizeResult ? - : } + : } ) } -function StaticDashboard({ projects, period }: { projects: ProjectSummary[]; period: Period }) { +function StaticDashboard({ projects, period, billingMode, surchargeRate }: { projects: ProjectSummary[]; period: Period; billingMode: BillingMode; surchargeRate: number }) { const { columns } = useWindowSize() const { dashWidth } = getLayout(columns) return ( - + ) } export async function renderDashboard(period: Period = 'week', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null): Promise { await loadPricing() + const billingConfig = loadBillingConfig() const range = customRange ?? getDateRange(period) const projects = filterProjectsByName(await parseAllSessions(range), projectFilter, excludeFilter) const isTTY = process.stdin.isTTY && process.stdout.isTTY if (isTTY) { const { waitUntilExit } = render( - + ) await waitUntilExit() } else { - const { unmount } = render(, { patchConsole: false }) + const { unmount } = render(, { patchConsole: false }) unmount() } } diff --git a/src/export.ts b/src/export.ts index 200f9bf..73c2adf 100644 --- a/src/export.ts +++ b/src/export.ts @@ -3,6 +3,7 @@ import { dirname, join, resolve } from 'path' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' import { getCurrency, convertCost } from './currency.js' +import { loadBillingConfig, CREDITS_PER_DOLLAR, type BillingConfig } from './billing.js' function escCsv(s: string): string { const sanitized = /^[=+\-@]/.test(s) ? `'${s}` : s @@ -361,15 +362,218 @@ export async function exportCsv(periods: PeriodExport[], outputPath: string): Pr return folder } +/// Build overview object with billing-aware fields +function buildOverview(projects: ProjectSummary[], billingConfig: BillingConfig): Record { + const allSessions = projects.flatMap(p => p.sessions) + const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0) + const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0) + const totalSessions = allSessions.length + const totalInputTokens = allSessions.reduce((s, sess) => s + sess.totalInputTokens, 0) + const totalOutputTokens = allSessions.reduce((s, sess) => s + sess.totalOutputTokens, 0) + const totalCacheRead = allSessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0) + const totalCacheWrite = allSessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0) + + // Aggregate billing fields + const totalCredits = allSessions.reduce((acc, sess) => { + if (acc === null && sess.totalCredits === null) return null + return (acc ?? 0) + (sess.totalCredits ?? 0) + }, null) + const totalBaseCostUsd = allSessions.reduce((acc, sess) => { + if (acc === null && sess.totalBaseCostUsd == null) return null + return (acc ?? 0) + (sess.totalBaseCostUsd ?? 0) + }, null) + const totalSurchargeUsd = allSessions.reduce((acc, sess) => { + if (acc === null && sess.totalSurchargeUsd == null) return null + return (acc ?? 0) + (sess.totalSurchargeUsd ?? 0) + }, null) + const totalBilledAmountUsd = allSessions.reduce((acc, sess) => { + if (acc === null && sess.totalBilledAmountUsd == null) return null + return (acc ?? 0) + (sess.totalBilledAmountUsd ?? 0) + }, null) + const creditsSynthesizedCount = allSessions.reduce((s, sess) => s + (sess.creditsSynthesizedCount ?? 0), 0) + + const base: Record = { + calls: totalCalls, + sessions: totalSessions, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheReadTokens: totalCacheRead, + cacheWriteTokens: totalCacheWrite, + } + + if (billingConfig.mode === 'credits') { + // Credits mode: cost = null, add creditsAugment and creditsSynthesized + base.cost = null + base.creditsAugment = totalCredits + base.creditsSynthesized = creditsSynthesizedCount + } else { + // Token+ mode: add baseCostUsd, surchargeUsd, billedAmountUsd; keep cost = billedAmountUsd for back-compat + base.baseCostUsd = totalBaseCostUsd !== null ? round2(totalBaseCostUsd) : null + base.surchargeUsd = totalSurchargeUsd !== null ? round2(totalSurchargeUsd) : null + base.billedAmountUsd = totalBilledAmountUsd !== null ? round2(totalBilledAmountUsd) : null + base.cost = totalBilledAmountUsd !== null ? round2(totalBilledAmountUsd) : round2(totalCostUSD) + } + + return base +} + +/// Build byModel array with billing-aware fields +function buildByModel(projects: ProjectSummary[], billingConfig: BillingConfig): unknown[] { + const modelTotals: Record = {} + + for (const project of projects) { + for (const session of project.sessions) { + for (const [model, data] of Object.entries(session.modelBreakdown)) { + if (!modelTotals[model]) { + modelTotals[model] = { + calls: 0, costUSD: 0, credits: null, baseCostUsd: null, surchargeUsd: null, billedAmountUsd: null, + creditsSynthesizedCount: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0, + } + } + modelTotals[model].calls += data.calls + modelTotals[model].costUSD += data.costUSD + // Aggregate credits + if (modelTotals[model].credits === null && data.credits === null) { + // Both null, keep null + } else { + modelTotals[model].credits = (modelTotals[model].credits ?? 0) + (data.credits ?? 0) + } + // Aggregate billing fields + if (modelTotals[model].baseCostUsd === null && data.baseCostUsd == null) { + // Both null, keep null + } else { + modelTotals[model].baseCostUsd = (modelTotals[model].baseCostUsd ?? 0) + (data.baseCostUsd ?? 0) + } + if (modelTotals[model].surchargeUsd === null && data.surchargeUsd == null) { + // Both null, keep null + } else { + modelTotals[model].surchargeUsd = (modelTotals[model].surchargeUsd ?? 0) + (data.surchargeUsd ?? 0) + } + if (modelTotals[model].billedAmountUsd === null && data.billedAmountUsd == null) { + // Both null, keep null + } else { + modelTotals[model].billedAmountUsd = (modelTotals[model].billedAmountUsd ?? 0) + (data.billedAmountUsd ?? 0) + } + modelTotals[model].creditsSynthesizedCount += data.creditsSynthesizedCount ?? 0 + modelTotals[model].inputTokens += data.tokens.inputTokens + modelTotals[model].outputTokens += data.tokens.outputTokens + modelTotals[model].cacheRead += data.tokens.cacheReadInputTokens ?? 0 + modelTotals[model].cacheWrite += data.tokens.cacheCreationInputTokens ?? 0 + } + } + } + + return Object.entries(modelTotals) + .filter(([name]) => name !== '') + .sort(([, a], [, b]) => (billingConfig.mode === 'credits' ? (b.credits ?? 0) - (a.credits ?? 0) : b.costUSD - a.costUSD)) + .map(([model, d]) => { + const base: Record = { + model, + calls: d.calls, + inputTokens: d.inputTokens, + outputTokens: d.outputTokens, + cacheReadTokens: d.cacheRead, + cacheWriteTokens: d.cacheWrite, + } + if (billingConfig.mode === 'credits') { + base.cost = null + base.creditsAugment = d.credits + base.creditsSynthesized = d.creditsSynthesizedCount + } else { + base.baseCostUsd = d.baseCostUsd !== null ? round2(d.baseCostUsd) : null + base.surchargeUsd = d.surchargeUsd !== null ? round2(d.surchargeUsd) : null + base.billedAmountUsd = d.billedAmountUsd !== null ? round2(d.billedAmountUsd) : null + base.cost = d.billedAmountUsd !== null ? round2(d.billedAmountUsd) : round2(d.costUSD) + } + return base + }) +} + +/// Build byProject array with billing-aware fields +function buildByProject(projects: ProjectSummary[], billingConfig: BillingConfig): unknown[] { + return projects + .slice() + .sort((a, b) => { + if (billingConfig.mode === 'credits') { + return (b.totalCredits ?? 0) - (a.totalCredits ?? 0) + } + // Token+ mode: sort by billed amount + const aBilled = b.sessions.reduce((s, sess) => s + (sess.totalBilledAmountUsd ?? sess.totalCostUSD), 0) + const bBilled = a.sessions.reduce((s, sess) => s + (sess.totalBilledAmountUsd ?? sess.totalCostUSD), 0) + return aBilled - bBilled + }) + .map(p => { + const allSessions = p.sessions + const totalBilledAmountUsd = allSessions.reduce((acc, sess) => { + if (acc === null && sess.totalBilledAmountUsd == null) return null + return (acc ?? 0) + (sess.totalBilledAmountUsd ?? 0) + }, null) + const totalBaseCostUsd = allSessions.reduce((acc, sess) => { + if (acc === null && sess.totalBaseCostUsd == null) return null + return (acc ?? 0) + (sess.totalBaseCostUsd ?? 0) + }, null) + const totalSurchargeUsd = allSessions.reduce((acc, sess) => { + if (acc === null && sess.totalSurchargeUsd == null) return null + return (acc ?? 0) + (sess.totalSurchargeUsd ?? 0) + }, null) + const creditsSynthesizedCount = allSessions.reduce((s, sess) => s + (sess.creditsSynthesizedCount ?? 0), 0) + + const base: Record = { + project: p.projectPath, + calls: p.totalApiCalls, + sessions: p.sessions.length, + } + if (billingConfig.mode === 'credits') { + base.cost = null + base.creditsAugment = p.totalCredits + base.creditsSynthesized = creditsSynthesizedCount + } else { + base.baseCostUsd = totalBaseCostUsd !== null ? round2(totalBaseCostUsd) : null + base.surchargeUsd = totalSurchargeUsd !== null ? round2(totalSurchargeUsd) : null + base.billedAmountUsd = totalBilledAmountUsd !== null ? round2(totalBilledAmountUsd) : null + base.cost = totalBilledAmountUsd !== null ? round2(totalBilledAmountUsd) : round2(p.totalCostUSD) + } + return base + }) +} + export async function exportJson(periods: PeriodExport[], outputPath: string): Promise { const thirtyDays = periods.find(p => p.label === '30 Days') const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects const { code, rate, symbol } = getCurrency() + const billingConfig = loadBillingConfig() + + // Build billing metadata for top-level + const billingMeta: Record = { + mode: billingConfig.mode, + } + if (billingConfig.mode === 'credits') { + billingMeta.creditsPerDollar = CREDITS_PER_DOLLAR + } else { + billingMeta.surchargeRate = billingConfig.surchargeRate + } const data = { schema: 'codeburn.export.v2', generated: new Date().toISOString(), currency: { code, rate, symbol }, + billing: billingMeta, + overview: buildOverview(thirtyDayProjects, billingConfig), + byModel: buildByModel(thirtyDayProjects, billingConfig), + byProject: buildByProject(thirtyDayProjects, billingConfig), + // Legacy fields for compatibility summary: buildSummaryRows(periods), periods: periods.map(p => ({ label: p.label, diff --git a/src/format.ts b/src/format.ts index 4c03207..f6e1649 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,5 +1,6 @@ import chalk from 'chalk' import type { ProjectSummary } from './types.js' +import { loadBillingConfig } from './billing.js' // Re-exported from currency.ts so existing imports from './format.js' keep working. // The currency-aware version applies exchange rate and symbol automatically. @@ -34,11 +35,12 @@ function localDateString(d: Date): string { } export function renderStatusBar(projects: ProjectSummary[]): string { + const billingConfig = loadBillingConfig() const now = new Date() const today = localDateString(now) const monthStart = `${today.slice(0, 7)}-01` - let todayCost = 0, todayCalls = 0, monthCost = 0, monthCalls = 0 + let todayValue = 0, todayCalls = 0, monthValue = 0, monthCalls = 0 for (const project of projects) { for (const session of project.sessions) { @@ -49,16 +51,27 @@ export function renderStatusBar(projects: ProjectSummary[]): string { // strings; naively slicing `timestamp.slice(0,10)` bucketed them by UTC date, which // showed `Today $0` during the UTC-midnight-to-local-midnight window. const day = localDateString(new Date(turn.timestamp)) - const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0) + // Use billing-mode aware value: credits in credits mode, billedAmountUsd in token_plus + const turnValue = turn.assistantCalls.reduce((s, c) => { + if (billingConfig.mode === 'credits') { + return s + (c.billing?.creditsAugment ?? c.credits ?? 0) + } else { + return s + (c.billing?.billedAmountUsd ?? c.costUSD) + } + }, 0) const turnCalls = turn.assistantCalls.length - if (day === today) { todayCost += turnCost; todayCalls += turnCalls } - if (day >= monthStart) { monthCost += turnCost; monthCalls += turnCalls } + if (day === today) { todayValue += turnValue; todayCalls += turnCalls } + if (day >= monthStart) { monthValue += turnValue; monthCalls += turnCalls } } } } const lines: string[] = [''] - lines.push(` ${chalk.bold('Today')} ${chalk.yellowBright(formatCost(todayCost))} ${chalk.dim(`${todayCalls} calls`)} ${chalk.bold('Month')} ${chalk.yellowBright(formatCost(monthCost))} ${chalk.dim(`${monthCalls} calls`)}`) + if (billingConfig.mode === 'credits') { + lines.push(` ${chalk.bold('Today')} ${chalk.yellowBright(formatCredits(todayValue))} ${chalk.dim(`${todayCalls} calls`)} ${chalk.bold('Month')} ${chalk.yellowBright(formatCredits(monthValue))} ${chalk.dim(`${monthCalls} calls`)}`) + } else { + lines.push(` ${chalk.bold('Today')} ${chalk.yellowBright(formatCost(todayValue))} ${chalk.dim(`${todayCalls} calls`)} ${chalk.bold('Month')} ${chalk.yellowBright(formatCost(monthValue))} ${chalk.dim(`${monthCalls} calls`)}`) + } lines.push('') return lines.join('\n') diff --git a/src/menubar-json.ts b/src/menubar-json.ts index bab4e40..d2c942e 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -1,3 +1,5 @@ +import type { BillingMode } from './billing.js' + /// Rollup of one time window (today / 7 days / 30 days / month / all) used as the canonical /// input to the menubar payload. Built inside the CLI and also consumed by the day-aggregator /// when hydrating per-day cache entries. @@ -11,7 +13,13 @@ export type PeriodData = { cacheReadTokens: number cacheWriteTokens: number categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }> - models: Array<{ name: string; cost: number; calls: number }> + models: Array<{ name: string; cost: number; calls: number; credits?: number | null; baseCostUsd?: number | null; billedAmountUsd?: number | null }> + // Billing mode-specific fields + creditsAugment?: number | null + creditsSynthesized?: number + baseCostUsd?: number | null + surchargeUsd?: number | null + billedAmountUsd?: number | null } export type ProviderCost = { @@ -47,15 +55,26 @@ export type DailyHistoryEntry = { export type MenubarPayload = { generated: string + billing?: { + mode: BillingMode + creditsPerDollar?: number + surchargeRate?: number + } current: { label: string - cost: number + cost: number | null calls: number sessions: number oneShotRate: number | null inputTokens: number outputTokens: number cacheHitPercent: number + // Billing mode-specific fields + creditsAugment?: number | null + creditsSynthesized?: number + baseCostUsd?: number | null + surchargeUsd?: number | null + billedAmountUsd?: number | null topActivities: Array<{ name: string cost: number @@ -64,8 +83,11 @@ export type MenubarPayload = { }> topModels: Array<{ name: string - cost: number + cost: number | null calls: number + creditsAugment?: number | null + baseCostUsd?: number | null + billedAmountUsd?: number | null }> providers: Record } @@ -114,11 +136,24 @@ function buildTopActivities(categories: PeriodData['categories']): MenubarPayloa })) } -function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] { +function buildTopModels(models: PeriodData['models'], billingMode?: BillingMode): MenubarPayload['current']['topModels'] { return models .filter(m => m.name !== SYNTHETIC_MODEL_NAME) .slice(0, TOP_MODELS_LIMIT) - .map(m => ({ name: m.name, cost: m.cost, calls: m.calls })) + .map(m => { + const base: MenubarPayload['current']['topModels'][number] = { + name: m.name, + cost: billingMode === 'credits' ? null : (m.billedAmountUsd ?? m.cost), + calls: m.calls, + } + if (billingMode === 'credits') { + base.creditsAugment = m.credits ?? null + } else if (billingMode === 'token_plus') { + base.baseCostUsd = m.baseCostUsd ?? null + base.billedAmountUsd = m.billedAmountUsd ?? null + } + return base + }) } function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] { @@ -155,27 +190,56 @@ function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['h return { daily: trimmed } } +import { loadBillingConfig, CREDITS_PER_DOLLAR, type BillingConfig } from './billing.js' + export function buildMenubarPayload( current: PeriodData, providers: ProviderCost[], optimize: OptimizeResult | null, dailyHistory?: DailyHistoryEntry[], + billingConfig?: BillingConfig, ): MenubarPayload { + const config = billingConfig ?? loadBillingConfig() + + // Build billing metadata + const billing: MenubarPayload['billing'] = { + mode: config.mode, + } + if (config.mode === 'credits') { + billing.creditsPerDollar = CREDITS_PER_DOLLAR + } else { + billing.surchargeRate = config.surchargeRate + } + + // Build current section with billing-aware fields + const currentSection: MenubarPayload['current'] = { + label: current.label, + cost: config.mode === 'credits' ? null : (current.billedAmountUsd ?? current.cost), + calls: current.calls, + sessions: current.sessions, + oneShotRate: aggregateOneShotRate(current.categories), + inputTokens: current.inputTokens, + outputTokens: current.outputTokens, + cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens), + topActivities: buildTopActivities(current.categories), + topModels: buildTopModels(current.models, config.mode), + providers: buildProviders(providers), + } + + // Add billing mode-specific fields + if (config.mode === 'credits') { + currentSection.creditsAugment = current.creditsAugment ?? null + currentSection.creditsSynthesized = current.creditsSynthesized ?? 0 + } else { + currentSection.baseCostUsd = current.baseCostUsd ?? null + currentSection.surchargeUsd = current.surchargeUsd ?? null + currentSection.billedAmountUsd = current.billedAmountUsd ?? null + } + return { generated: new Date().toISOString(), - current: { - label: current.label, - cost: current.cost, - calls: current.calls, - sessions: current.sessions, - oneShotRate: aggregateOneShotRate(current.categories), - inputTokens: current.inputTokens, - outputTokens: current.outputTokens, - cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens), - topActivities: buildTopActivities(current.categories), - topModels: buildTopModels(current.models), - providers: buildProviders(providers), - }, + billing, + current: currentSection, optimize: buildOptimize(optimize), history: buildHistory(dailyHistory), } diff --git a/src/parser.ts b/src/parser.ts index 3686442..cd696b8 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -196,13 +196,16 @@ function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set): Parse return turns } -/// Helper to add credits with proper null semantics. +/// Helper to add nullable numbers with proper null semantics. /// null + null = null (no data), null + N = N, N + M = N + M -function addCredits(a: number | null, b: number | null): number | null { - if (a === null && b === null) return null +function addNullable(a: number | null | undefined, b: number | null | undefined): number | null { + if ((a === null || a === undefined) && (b === null || b === undefined)) return null return (a ?? 0) + (b ?? 0) } +// Alias for backwards compat +const addCredits = addNullable + function buildSessionSummary( sessionId: string, project: string, @@ -222,6 +225,12 @@ function buildSessionSummary( let totalCacheWrite = 0 // Per-call credits summed for fallback (older sessions without sessionCreditUsage) let summedCredits: number | null = null + // Billing aggregates (Token+ mode) + let totalBaseCostUsd: number | null = null + let totalSurchargeUsd: number | null = null + let totalBilledAmountUsd: number | null = null + let creditsSynthesizedCount = 0 + let billingMode: 'credits' | 'token_plus' | undefined = undefined let apiCalls = 0 let firstTs = '' let lastTs = '' @@ -249,6 +258,16 @@ function buildSessionSummary( summedCredits = addCredits(summedCredits, call.credits) apiCalls++ + // Aggregate billing fields if present + const billing = call.billing + if (billing) { + if (!billingMode) billingMode = billing.mode + totalBaseCostUsd = addNullable(totalBaseCostUsd, billing.baseCostUsd) + totalSurchargeUsd = addNullable(totalSurchargeUsd, billing.surchargeUsd) + totalBilledAmountUsd = addNullable(totalBilledAmountUsd, billing.billedAmountUsd) + if (billing.synthesized) creditsSynthesizedCount++ + } + const modelKey = getShortModelName(call.model) if (!modelBreakdown[modelKey]) { modelBreakdown[modelKey] = { @@ -256,6 +275,10 @@ function buildSessionSummary( costUSD: 0, credits: null, tokens: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 }, + baseCostUsd: null, + surchargeUsd: null, + billedAmountUsd: null, + creditsSynthesizedCount: 0, } } modelBreakdown[modelKey].calls++ @@ -266,6 +289,16 @@ function buildSessionSummary( modelBreakdown[modelKey].tokens.cacheReadInputTokens += call.usage.cacheReadInputTokens modelBreakdown[modelKey].tokens.cacheCreationInputTokens += call.usage.cacheCreationInputTokens + // Aggregate billing fields per model + if (billing) { + modelBreakdown[modelKey].baseCostUsd = addNullable(modelBreakdown[modelKey].baseCostUsd, billing.baseCostUsd) + modelBreakdown[modelKey].surchargeUsd = addNullable(modelBreakdown[modelKey].surchargeUsd, billing.surchargeUsd) + modelBreakdown[modelKey].billedAmountUsd = addNullable(modelBreakdown[modelKey].billedAmountUsd, billing.billedAmountUsd) + if (billing.synthesized) { + modelBreakdown[modelKey].creditsSynthesizedCount = (modelBreakdown[modelKey].creditsSynthesizedCount ?? 0) + 1 + } + } + for (const tool of extractCoreTools(call.tools)) { toolBreakdown[tool] = toolBreakdown[tool] ?? { calls: 0 } toolBreakdown[tool].calls++ @@ -303,6 +336,11 @@ function buildSessionSummary( totalCacheReadTokens: totalCacheRead, totalCacheWriteTokens: totalCacheWrite, totalCredits, + billingMode, + totalBaseCostUsd, + totalSurchargeUsd, + totalBilledAmountUsd, + creditsSynthesizedCount, apiCalls, turns, modelBreakdown, @@ -427,6 +465,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn { usage, costUSD: call.costUSD, credits: call.credits ?? null, + billing: call.billing ?? null, tools, mcpTools: extractMcpTools(tools), hasAgentSpawn: tools.includes('Agent'), diff --git a/src/providers/auggie.ts b/src/providers/auggie.ts index 3d1d71c..8a0d336 100644 --- a/src/providers/auggie.ts +++ b/src/providers/auggie.ts @@ -4,8 +4,9 @@ import { homedir } from 'os' import { readCachedCalls, writeCachedCalls } from '../auggie-cache.js' import { readSessionFile } from '../fs-utils.js' -import { calculateCost } from '../models.js' +import { calculateCost, getModelCosts } from '../models.js' import { extractBashCommands } from '../bash-utils.js' +import { loadBillingConfig, computeBilling, type BillingConfig, type BillingResult } from '../billing.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' /// Augment Code's CLI ("Auggie") writes one JSON file per conversation into ~/.augment/sessions/. @@ -323,6 +324,9 @@ function extractCreditsFromNodes( /// Credits are extracted from type-9 BILLING_METADATA nodes and attached to the first /// ParsedProviderCall emitted for this exchange. Transaction IDs are tracked across the /// session to prevent double-counting the same billing transaction. +/// +/// Billing: Each call is run through computeBilling() to attach the full BillingResult. +/// The `credits` field is preserved for back-compat (derived from BillingResult.creditsAugment). function* parseExchange( session: AuggieSession, exchange: AuggieExchange, @@ -330,6 +334,7 @@ function* parseExchange( projectLabel: string, seenKeys: Set, seenTransactionIds: Set, + billingConfig: BillingConfig, ): Generator { const userMessage = extractUserMessage(exchange) const requestId = exchange.request_id ?? '' @@ -404,9 +409,21 @@ function* parseExchange( // Attach tools and credits only to first call to prevent inflation const tools = firstEmitted ? [] : allTools const bashCommands = firstEmitted ? [] : allBashCommands - const credits = firstEmitted ? null : exchangeCredits + const groundTruthCredits = firstEmitted ? null : exchangeCredits firstEmitted = true + // Compute billing using the billing engine + const modelCosts = getModelCosts(model) + const billingResult = computeBilling( + { input, output, cacheRead, cacheWrite }, + modelCosts, + billingConfig, + groundTruthCredits, + ) + + // Derive `credits` from BillingResult.creditsAugment for back-compat + const credits = billingResult.creditsAugment + yield { provider: 'auggie', model, @@ -426,6 +443,7 @@ function* parseExchange( userMessage, sessionId, credits, + billing: billingResult, } } } else { @@ -466,9 +484,21 @@ function* parseExchange( } // Attach credits only to first call to prevent inflation - const credits = firstEmitted ? null : exchangeCredits + const groundTruthCredits = firstEmitted ? null : exchangeCredits firstEmitted = true + // Compute billing using the billing engine + const modelCosts = getModelCosts(model) + const billingResult = computeBilling( + { input, output, cacheRead, cacheWrite }, + modelCosts, + billingConfig, + groundTruthCredits, + ) + + // Derive `credits` from BillingResult.creditsAugment for back-compat + const credits = billingResult.creditsAugment + yield { provider: 'auggie', model, @@ -488,6 +518,7 @@ function* parseExchange( userMessage, sessionId, credits, + billing: billingResult, } } } @@ -535,11 +566,19 @@ function taggedSessionId(session: AuggieSession): string { } function createParser(source: SessionSource, seenKeys: Set): SessionParser { + // Load billing config once per parser (per session file). + // Config is env-var driven so this is deterministic for the lifetime of the process. + const billingConfig = loadBillingConfig() + return { async *parse(): AsyncGenerator { // Cache hit: the session file is unchanged since the last run (same mtime + size). // Yield the cached calls directly -- this is the happy path for most of the 700+ // sessions on a typical install. + // NOTE: Cached calls may have billing computed under a different config. + // This is acceptable since the cache is keyed by mtime+size, so if the user + // changes billing mode, they can touch/edit the session files to invalidate. + // In practice, billing mode rarely changes mid-flight. const cached = await readCachedCalls(source.path) if (cached) { for (const call of cached) { @@ -585,7 +624,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars for (const turn of chatHistory) { const ex = turn.exchange if (!ex) continue - for (const call of parseExchange(session, ex, sessionId, projectLabel, seenKeys, seenTransactionIds)) { + for (const call of parseExchange(session, ex, sessionId, projectLabel, seenKeys, seenTransactionIds, billingConfig)) { // Attach session-level creditUsage to the first call so the parser can use it. if (isFirstCall && sessionCreditUsage !== null) { call.sessionCreditUsage = sessionCreditUsage diff --git a/src/providers/types.ts b/src/providers/types.ts index 502e9ab..f01fc43 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -1,3 +1,5 @@ +import type { BillingMode, BillingResult } from '../billing.js' + export type SessionSource = { path: string project: string @@ -29,12 +31,15 @@ export type ParsedProviderCall = { /// Augment credits consumed for this call. null means no billing data available /// (e.g., non-Augment provider or legacy session), 0 means zero usage, positive /// means actual credits consumed. + /// DEPRECATED: Use billing.creditsAugment instead. Kept for back-compat. credits?: number | null /// Session-level credit usage (fast-path). When present on any call in a session, /// this is Augment's authoritative session total (already deduped, includes sub-agents). /// The parser should prefer this over summing per-call credits for session totals. /// Per-call credits are still used for per-model breakdowns. sessionCreditUsage?: number | null + /// Full billing result from computeBilling(). Present when billing engine is active. + billing?: BillingResult | null } export type Provider = { diff --git a/src/types.ts b/src/types.ts index 6730d25..82bc788 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,13 +65,18 @@ export type ParsedTurn = { sessionId: string } +import type { BillingMode, BillingResult } from './billing.js' + export type ParsedApiCall = { provider: string model: string usage: TokenUsage costUSD: number /// Augment credits for this call. null = no billing data, 0 = zero usage, positive = usage. + /// DEPRECATED: Use billing.creditsAugment instead. Kept for back-compat. credits: number | null + /// Full billing result from computeBilling(). Present when billing engine is active. + billing?: BillingResult | null tools: string[] mcpTools: string[] hasAgentSpawn: boolean @@ -115,9 +120,27 @@ export type SessionSummary = { totalCacheWriteTokens: number /// Total Augment credits for this session. null = no billing data, 0 = zero usage, positive = usage. totalCredits: number | null + /// Billing mode in effect for this session (credits or token_plus). + billingMode?: BillingMode + /// Token+ billing aggregates (null in credits mode). + totalBaseCostUsd?: number | null + totalSurchargeUsd?: number | null + totalBilledAmountUsd?: number | null + /// Count of calls where credits were synthesized (no ground-truth billing data). + creditsSynthesizedCount?: number apiCalls: number turns: ClassifiedTurn[] - modelBreakdown: Record + modelBreakdown: Record toolBreakdown: Record mcpBreakdown: Record bashBreakdown: Record @@ -131,6 +154,14 @@ export type ProjectSummary = { totalCostUSD: number /// Total Augment credits for this project. null = no billing data, 0 = zero usage, positive = usage. totalCredits: number | null + /// Billing mode (from first session with billing data). + billingMode?: BillingMode + /// Token+ billing aggregates (null in credits mode). + totalBaseCostUsd?: number | null + totalSurchargeUsd?: number | null + totalBilledAmountUsd?: number | null + /// Count of calls where credits were synthesized. + creditsSynthesizedCount?: number totalApiCalls: number } diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index f7493d0..0606cd9 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { buildMenubarPayload, type PeriodData, type ProviderCost } from '../src/menubar-json.js' +import type { BillingConfig } from '../src/billing.js' import type { OptimizeResult } from '../src/optimize.js' function emptyPeriod(label: string): PeriodData { @@ -19,6 +20,10 @@ function emptyPeriod(label: string): PeriodData { } describe('buildMenubarPayload', () => { + // Use token_plus mode for tests expecting cost to be present + const tokenPlusConfig: BillingConfig = { mode: 'token_plus', surchargeRate: 0.3 } + const creditsConfig: BillingConfig = { mode: 'credits', surchargeRate: 0.3 } + it('emits the full schema with current-period metrics and iso timestamp', () => { const period: PeriodData = { label: '7 Days', @@ -31,8 +36,9 @@ describe('buildMenubarPayload', () => { cacheWriteTokens: 0, categories: [], models: [], + billedAmountUsd: 1248.01, // For token_plus mode } - const payload = buildMenubarPayload(period, [], null) + const payload = buildMenubarPayload(period, [], null, undefined, tokenPlusConfig) expect(payload.generated).toMatch(/^\d{4}-\d{2}-\d{2}T/) expect(payload.current.label).toBe('7 Days') @@ -41,6 +47,32 @@ describe('buildMenubarPayload', () => { expect(payload.current.sessions).toBe(97) expect(payload.current.inputTokens).toBe(19100) expect(payload.current.outputTokens).toBe(675600) + expect(payload.billing?.mode).toBe('token_plus') + expect(payload.billing?.surchargeRate).toBe(0.3) + }) + + it('emits credits mode fields when billing mode is credits', () => { + const period: PeriodData = { + label: '7 Days', + cost: 1248.01, + calls: 11231, + sessions: 97, + inputTokens: 19100, + outputTokens: 675600, + cacheReadTokens: 0, + cacheWriteTokens: 0, + categories: [], + models: [], + creditsAugment: 1996816, + creditsSynthesized: 5, + } + const payload = buildMenubarPayload(period, [], null, undefined, creditsConfig) + + expect(payload.current.cost).toBeNull() + expect(payload.current.creditsAugment).toBe(1996816) + expect(payload.current.creditsSynthesized).toBe(5) + expect(payload.billing?.mode).toBe('credits') + expect(payload.billing?.creditsPerDollar).toBe(1600) }) it('computes per-category oneShotRate from editTurns and skips categories without edits', () => { diff --git a/tests/providers/auggie.test.ts b/tests/providers/auggie.test.ts index e84ce71..56a6aa4 100644 --- a/tests/providers/auggie.test.ts +++ b/tests/providers/auggie.test.ts @@ -206,12 +206,18 @@ describe('auggie provider - modern schema', () => { expect(calls[0].bashCommands).toContain('echo') }) - it('credits come from type-9 billing_metadata', async () => { + it('credits come from type-9 billing_metadata (or synthesized from billing engine)', async () => { const path = await stageFixture('modern-schema.json') const calls = await collectCalls(path) - // First call should have credits, subsequent null + // First call should have ground-truth credits from type-9 expect(calls[0].credits).toBe(42.5) - expect(calls[1].credits).toBeNull() + // Subsequent calls have synthesized credits from billing engine (no ground-truth) + // The billing engine always computes credits now, so this won't be null + expect(calls[1].credits).toBeTypeOf('number') + expect(calls[1].credits).toBeGreaterThanOrEqual(0) + // Check billing result for more details + expect(calls[0].billing?.synthesized).toBe(false) // ground truth + expect(calls[1].billing?.synthesized).toBe(true) // synthesized }) it('session.creditUsage fast-path: sessionCreditUsage present on first call', async () => { From c0b3573fe9e9a0e7a01687914c5f04b1b809f4e4 Mon Sep 17 00:00:00 2001 From: jaycdave88 Date: Sun, 19 Apr 2026 21:29:02 -0400 Subject: [PATCH 4/9] feat: add billing field aggregation to session summaries and cache schem Agent-Id: agent-042ab8eb-9be5-497f-bb47-205fe9c88cf1 Linked-Note-Id: fc854671-00be-4d9e-91f3-987906dbdb62 --- src/auggie-cache.ts | 5 +++- src/cli.ts | 69 +++++++++++++++++++++++++++++++++++++-------- src/dashboard.tsx | 41 +++++++++++++++++++++------ src/parser.ts | 16 ++++++++++- src/types.ts | 13 ++++++++- 5 files changed, 120 insertions(+), 24 deletions(-) diff --git a/src/auggie-cache.ts b/src/auggie-cache.ts index 3d43d75..6898dbb 100644 --- a/src/auggie-cache.ts +++ b/src/auggie-cache.ts @@ -22,7 +22,10 @@ type SessionCacheFile = { calls: ParsedProviderCall[] } -const CACHE_VERSION = 1 +// CACHE_VERSION changelog: +// v1: Initial schema +// v2: Added `billing: BillingResult` field to ParsedProviderCall (Wave 2 billing integration) +const CACHE_VERSION = 2 const CACHE_SUBDIR = 'auggie' const CACHE_FILE_MODE = 0o600 const CACHE_DIR_MODE = 0o700 diff --git a/src/cli.ts b/src/cli.ts index 8deea16..252709a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { parseDateRangeFlags } from './cli-date.js' import { runOptimize, scanAndDetect } from './optimize.js' import { getAllProviders } from './providers/index.js' import { readConfig, saveConfig, getConfigFilePath } from './config.js' +import { loadBillingConfig, CREDITS_PER_DOLLAR } from './billing.js' import { createRequire } from 'node:module' const require = createRequire(import.meta.url) @@ -106,6 +107,17 @@ program.hook('preAction', async (thisCommand) => { function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) { const sessions = projects.flatMap(p => p.sessions) const { code } = getCurrency() + const billingConfig = loadBillingConfig() + + // Build billing metadata + const billing: Record = { + mode: billingConfig.mode, + } + if (billingConfig.mode === 'credits') { + billing.creditsPerDollar = CREDITS_PER_DOLLAR + } else { + billing.surchargeRate = billingConfig.surchargeRate + } const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0) const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0) @@ -208,23 +220,56 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: .sort((a, b) => b.cost - a.cost) .slice(0, 5) + // Aggregate billing-specific fields for overview + const totalCredits = sessions.reduce((acc, sess) => { + if (acc === null && sess.totalCredits === null) return null + return (acc ?? 0) + (sess.totalCredits ?? 0) + }, null) + const totalBaseCostUsd = sessions.reduce((acc, sess) => { + if (acc === null && sess.totalBaseCostUsd == null) return null + return (acc ?? 0) + (sess.totalBaseCostUsd ?? 0) + }, null) + const totalSurchargeUsd = sessions.reduce((acc, sess) => { + if (acc === null && sess.totalSurchargeUsd == null) return null + return (acc ?? 0) + (sess.totalSurchargeUsd ?? 0) + }, null) + const totalBilledAmountUsd = sessions.reduce((acc, sess) => { + if (acc === null && sess.totalBilledAmountUsd == null) return null + return (acc ?? 0) + (sess.totalBilledAmountUsd ?? 0) + }, null) + const creditsSynthesizedCount = sessions.reduce((s, sess) => s + (sess.creditsSynthesizedCount ?? 0), 0) + + // Build overview with billing-mode aware fields + const overview: Record = { + calls: totalCalls, + sessions: totalSessions, + cacheHitPercent, + tokens: { + input: totalInput, + output: totalOutput, + cacheRead: totalCacheRead, + cacheWrite: totalCacheWrite, + }, + } + + if (billingConfig.mode === 'credits') { + overview.cost = null + overview.creditsAugment = totalCredits + overview.creditsSynthesized = creditsSynthesizedCount + } else { + overview.baseCostUsd = totalBaseCostUsd !== null ? Math.round(totalBaseCostUsd * 100) / 100 : null + overview.surchargeUsd = totalSurchargeUsd !== null ? Math.round(totalSurchargeUsd * 100) / 100 : null + overview.billedAmountUsd = totalBilledAmountUsd !== null ? Math.round(totalBilledAmountUsd * 100) / 100 : null + overview.cost = totalBilledAmountUsd !== null ? Math.round(totalBilledAmountUsd * 100) / 100 : convertCost(totalCostUSD) + } + return { generated: new Date().toISOString(), currency: code, + billing, period, periodKey, - overview: { - cost: convertCost(totalCostUSD), - calls: totalCalls, - sessions: totalSessions, - cacheHitPercent, - tokens: { - input: totalInput, - output: totalOutput, - cacheRead: totalCacheRead, - cacheWrite: totalCacheWrite, - }, - }, + overview, daily, projects: projectList, models, diff --git a/src/dashboard.tsx b/src/dashboard.tsx index ed25222..df8414c 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -406,31 +406,54 @@ function ModelBreakdown({ projects, pw, bw, billingMode }: { projects: ProjectSu ) } -function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { - const categoryTotals: Record = {} +function ActivityBreakdown({ projects, pw, bw, billingMode }: { projects: ProjectSummary[]; pw: number; bw: number; billingMode: BillingMode }) { + const categoryTotals: Record = {} for (const project of projects) { for (const session of project.sessions) { for (const [cat, data] of Object.entries(session.categoryBreakdown)) { - if (!categoryTotals[cat]) categoryTotals[cat] = { turns: 0, costUSD: 0, editTurns: 0, oneShotTurns: 0 } + if (!categoryTotals[cat]) categoryTotals[cat] = { turns: 0, costUSD: 0, credits: null, billedAmountUsd: null, editTurns: 0, oneShotTurns: 0 } categoryTotals[cat].turns += data.turns categoryTotals[cat].costUSD += data.costUSD categoryTotals[cat].editTurns += data.editTurns categoryTotals[cat].oneShotTurns += data.oneShotTurns + // Aggregate billing fields + if (categoryTotals[cat].credits === null && data.credits == null) { + // Both null, keep null + } else { + categoryTotals[cat].credits = (categoryTotals[cat].credits ?? 0) + (data.credits ?? 0) + } + if (categoryTotals[cat].billedAmountUsd === null && data.billedAmountUsd == null) { + // Both null, keep null + } else { + categoryTotals[cat].billedAmountUsd = (categoryTotals[cat].billedAmountUsd ?? 0) + (data.billedAmountUsd ?? 0) + } } } } - const sorted = Object.entries(categoryTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD) - const maxCost = sorted[0]?.[1]?.costUSD ?? 0 + // Sort by billing-mode aware value + const sorted = Object.entries(categoryTotals).sort(([, a], [, b]) => { + const valueA = billingMode === 'credits' ? (a.credits ?? 0) : (a.billedAmountUsd ?? a.costUSD) + const valueB = billingMode === 'credits' ? (b.credits ?? 0) : (b.billedAmountUsd ?? b.costUSD) + return valueB - valueA + }) + const maxValue = sorted.length > 0 + ? (billingMode === 'credits' ? (sorted[0][1].credits ?? 0) : (sorted[0][1].billedAmountUsd ?? sorted[0][1].costUSD)) + : 0 + + const valueLabel = billingMode === 'credits' ? 'credits' : 'billed' + const formatValue = billingMode === 'credits' ? formatCredits : formatCost + return ( - {''.padEnd(bw + 14)}{'cost'.padStart(8)}{'turns'.padStart(6)}{'1-shot'.padStart(7)} + {''.padEnd(bw + 14)}{valueLabel.padStart(8)}{'turns'.padStart(6)}{'1-shot'.padStart(7)} {sorted.map(([cat, data]) => { + const displayValue = billingMode === 'credits' ? data.credits : (data.billedAmountUsd ?? data.costUSD) const oneShotPct = data.editTurns > 0 ? Math.round((data.oneShotTurns / data.editTurns) * 100) + '%' : '-' return ( - + {fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)} - {formatCost(data.costUSD).padStart(8)} + {formatValue(displayValue ?? 0).padStart(8)} {String(data.turns).padStart(6)} {String(oneShotPct).padStart(7)} @@ -658,7 +681,7 @@ function DashboardContent({ projects, period, columns, billingMode, surchargeRat - + diff --git a/src/parser.ts b/src/parser.ts index cd696b8..dbed56b 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -239,7 +239,10 @@ function buildSessionSummary( const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0) if (!categoryBreakdown[turn.category]) { - categoryBreakdown[turn.category] = { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 } + categoryBreakdown[turn.category] = { + turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0, + credits: null, baseCostUsd: null, surchargeUsd: null, billedAmountUsd: null, + } } categoryBreakdown[turn.category].turns++ categoryBreakdown[turn.category].costUSD += turnCost @@ -249,6 +252,17 @@ function buildSessionSummary( if (turn.retries === 0) categoryBreakdown[turn.category].oneShotTurns++ } + // Aggregate billing fields per category from turn's calls + for (const call of turn.assistantCalls) { + const billing = call.billing + if (billing) { + categoryBreakdown[turn.category].credits = addNullable(categoryBreakdown[turn.category].credits, billing.creditsAugment) + categoryBreakdown[turn.category].baseCostUsd = addNullable(categoryBreakdown[turn.category].baseCostUsd, billing.baseCostUsd) + categoryBreakdown[turn.category].surchargeUsd = addNullable(categoryBreakdown[turn.category].surchargeUsd, billing.surchargeUsd) + categoryBreakdown[turn.category].billedAmountUsd = addNullable(categoryBreakdown[turn.category].billedAmountUsd, billing.billedAmountUsd) + } + } + for (const call of turn.assistantCalls) { totalCost += call.costUSD totalInput += call.usage.inputTokens diff --git a/src/types.ts b/src/types.ts index 82bc788..607167c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -144,7 +144,18 @@ export type SessionSummary = { toolBreakdown: Record mcpBreakdown: Record bashBreakdown: Record - categoryBreakdown: Record + categoryBreakdown: Record } export type ProjectSummary = { From 2022e085f49ceb01c6a09f61d6069b2aff65e0d9 Mon Sep 17 00:00:00 2001 From: jaycdave88 Date: Sun, 19 Apr 2026 21:41:37 -0400 Subject: [PATCH 5/9] refactor: simplify billing aggregation with unconditional summation Agent-Id: agent-042ab8eb-9be5-497f-bb47-205fe9c88cf1 Linked-Note-Id: fc854671-00be-4d9e-91f3-987906dbdb62 --- src/cli.ts | 32 +++++------ src/export.ts | 33 +++++------ tests/export.test.ts | 132 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 160 insertions(+), 37 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 252709a..536848f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -221,23 +221,21 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: .slice(0, 5) // Aggregate billing-specific fields for overview - const totalCredits = sessions.reduce((acc, sess) => { - if (acc === null && sess.totalCredits === null) return null - return (acc ?? 0) + (sess.totalCredits ?? 0) - }, null) - const totalBaseCostUsd = sessions.reduce((acc, sess) => { - if (acc === null && sess.totalBaseCostUsd == null) return null - return (acc ?? 0) + (sess.totalBaseCostUsd ?? 0) - }, null) - const totalSurchargeUsd = sessions.reduce((acc, sess) => { - if (acc === null && sess.totalSurchargeUsd == null) return null - return (acc ?? 0) + (sess.totalSurchargeUsd ?? 0) - }, null) - const totalBilledAmountUsd = sessions.reduce((acc, sess) => { - if (acc === null && sess.totalBilledAmountUsd == null) return null - return (acc ?? 0) + (sess.totalBilledAmountUsd ?? 0) - }, null) - const creditsSynthesizedCount = sessions.reduce((s, sess) => s + (sess.creditsSynthesizedCount ?? 0), 0) + // Use simple summation: null → 0 contribution, no short-circuit. + // This ensures all sessions contribute equally to base, surcharge, and billed. + let totalCredits: number | null = null + let totalBaseCostUsd: number | null = null + let totalSurchargeUsd: number | null = null + let totalBilledAmountUsd: number | null = null + let creditsSynthesizedCount = 0 + + for (const sess of sessions) { + if (sess.totalCredits != null) totalCredits = (totalCredits ?? 0) + sess.totalCredits + if (sess.totalBaseCostUsd != null) totalBaseCostUsd = (totalBaseCostUsd ?? 0) + sess.totalBaseCostUsd + if (sess.totalSurchargeUsd != null) totalSurchargeUsd = (totalSurchargeUsd ?? 0) + sess.totalSurchargeUsd + if (sess.totalBilledAmountUsd != null) totalBilledAmountUsd = (totalBilledAmountUsd ?? 0) + sess.totalBilledAmountUsd + creditsSynthesizedCount += sess.creditsSynthesizedCount ?? 0 + } // Build overview with billing-mode aware fields const overview: Record = { diff --git a/src/export.ts b/src/export.ts index 73c2adf..8120da0 100644 --- a/src/export.ts +++ b/src/export.ts @@ -373,24 +373,21 @@ function buildOverview(projects: ProjectSummary[], billingConfig: BillingConfig) const totalCacheRead = allSessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0) const totalCacheWrite = allSessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0) - // Aggregate billing fields - const totalCredits = allSessions.reduce((acc, sess) => { - if (acc === null && sess.totalCredits === null) return null - return (acc ?? 0) + (sess.totalCredits ?? 0) - }, null) - const totalBaseCostUsd = allSessions.reduce((acc, sess) => { - if (acc === null && sess.totalBaseCostUsd == null) return null - return (acc ?? 0) + (sess.totalBaseCostUsd ?? 0) - }, null) - const totalSurchargeUsd = allSessions.reduce((acc, sess) => { - if (acc === null && sess.totalSurchargeUsd == null) return null - return (acc ?? 0) + (sess.totalSurchargeUsd ?? 0) - }, null) - const totalBilledAmountUsd = allSessions.reduce((acc, sess) => { - if (acc === null && sess.totalBilledAmountUsd == null) return null - return (acc ?? 0) + (sess.totalBilledAmountUsd ?? 0) - }, null) - const creditsSynthesizedCount = allSessions.reduce((s, sess) => s + (sess.creditsSynthesizedCount ?? 0), 0) + // Aggregate billing fields — use simple summation (null → 0 contribution, no short-circuit). + // This ensures all sessions contribute equally to base, surcharge, and billed. + let totalCredits: number | null = null + let totalBaseCostUsd: number | null = null + let totalSurchargeUsd: number | null = null + let totalBilledAmountUsd: number | null = null + let creditsSynthesizedCount = 0 + + for (const sess of allSessions) { + if (sess.totalCredits != null) totalCredits = (totalCredits ?? 0) + sess.totalCredits + if (sess.totalBaseCostUsd != null) totalBaseCostUsd = (totalBaseCostUsd ?? 0) + sess.totalBaseCostUsd + if (sess.totalSurchargeUsd != null) totalSurchargeUsd = (totalSurchargeUsd ?? 0) + sess.totalSurchargeUsd + if (sess.totalBilledAmountUsd != null) totalBilledAmountUsd = (totalBilledAmountUsd ?? 0) + sess.totalBilledAmountUsd + creditsSynthesizedCount += sess.creditsSynthesizedCount ?? 0 + } const base: Record = { calls: totalCalls, diff --git a/tests/export.test.ts b/tests/export.test.ts index 4e2acf5..139aaf9 100644 --- a/tests/export.test.ts +++ b/tests/export.test.ts @@ -3,8 +3,8 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'fs/promises' import { join } from 'path' import { tmpdir } from 'os' -import { exportCsv, type PeriodExport } from '../src/export.js' -import type { ProjectSummary } from '../src/types.js' +import { exportCsv, exportJson, type PeriodExport } from '../src/export.js' +import type { ProjectSummary, BillingResult } from '../src/types.js' let tmpDir: string @@ -156,3 +156,131 @@ describe('exportCsv', () => { expect(survived).toBe('do not delete me\n') }) }) + +describe('exportJson token+ billing invariant', () => { + /** Helper to create a project with billing data */ + function makeProjectWithBilling(baseCost: number, surchargeRate: number): ProjectSummary { + const surcharge = baseCost * surchargeRate + const billed = baseCost + surcharge + const billing: BillingResult = { + mode: 'token_plus', + baseCostUsd: baseCost, + surchargeRate, + surchargeUsd: surcharge, + billedAmountUsd: billed, + creditsAugment: null, + synthesized: false, + } + return { + project: 'test-project', + projectPath: 'test-project', + sessions: [ + { + sessionId: 'sess-billing-1', + project: 'test-project', + firstTimestamp: '2026-04-14T10:00:00Z', + lastTimestamp: '2026-04-14T10:01:00Z', + totalCostUSD: baseCost, + totalInputTokens: 1000, + totalOutputTokens: 500, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + totalCredits: null, + billingMode: 'token_plus', + totalBaseCostUsd: baseCost, + totalSurchargeUsd: surcharge, + totalBilledAmountUsd: billed, + creditsSynthesizedCount: 0, + turns: [ + { + userMessage: 'test', + timestamp: '2026-04-14T10:00:00Z', + sessionId: 'sess-billing-1', + category: 'coding', + retries: 0, + hasEdits: false, + assistantCalls: [ + { + provider: 'claude', + model: 'claude-sonnet-4', + usage: { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 }, + costUSD: baseCost, + credits: null, + billing, + tools: [], + mcpTools: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-04-14T10:00:00Z', + bashCommands: [], + deduplicationKey: 'dedup-billing-1', + }, + ], + }, + ], + modelBreakdown: { 'claude-sonnet-4': { calls: 1, costUSD: baseCost, credits: null, tokens: { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 }, baseCostUsd: baseCost, surchargeUsd: surcharge, billedAmountUsd: billed } }, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: { + coding: { turns: 1, costUSD: baseCost, retries: 0, editTurns: 0, oneShotTurns: 0, credits: null, baseCostUsd: baseCost, surchargeUsd: surcharge, billedAmountUsd: billed }, + debugging: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + feature: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + refactoring: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + testing: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + exploration: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + planning: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + delegation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + git: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + 'build/deploy': { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + conversation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + brainstorming: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + general: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + }, + }, + ], + totalCostUSD: baseCost, + totalCredits: null, + billingMode: 'token_plus', + totalBaseCostUsd: baseCost, + totalSurchargeUsd: surcharge, + totalBilledAmountUsd: billed, + creditsSynthesizedCount: 0, + totalApiCalls: 1, + } + } + + it('invariant: baseCostUsd + surchargeUsd ≈ billedAmountUsd (±0.01)', async () => { + // Set token_plus mode via env + const origMode = process.env['CODEBURN_BILLING_MODE'] + const origRate = process.env['CODEBURN_SURCHARGE_RATE'] + process.env['CODEBURN_BILLING_MODE'] = 'token_plus' + process.env['CODEBURN_SURCHARGE_RATE'] = '0.25' + try { + const baseCost = 100.0 + const surchargeRate = 0.25 + const project = makeProjectWithBilling(baseCost, surchargeRate) + const periods: PeriodExport[] = [{ label: '30 Days', projects: [project] }] + const outputPath = join(tmpDir, 'billing-invariant.json') + await exportJson(periods, outputPath) + const raw = await readFile(outputPath, 'utf-8') + const data = JSON.parse(raw) + const overview = data.overview + + // Invariant 1: base + surcharge ≈ billed + const summed = overview.baseCostUsd + overview.surchargeUsd + expect(Math.abs(summed - overview.billedAmountUsd)).toBeLessThanOrEqual(0.01) + + // Invariant 2: surcharge ≈ base × surchargeRate + const expectedSurcharge = overview.baseCostUsd * surchargeRate + expect(Math.abs(overview.surchargeUsd - expectedSurcharge)).toBeLessThanOrEqual(0.01) + } finally { + if (origMode !== undefined) process.env['CODEBURN_BILLING_MODE'] = origMode + else delete process.env['CODEBURN_BILLING_MODE'] + if (origRate !== undefined) process.env['CODEBURN_SURCHARGE_RATE'] = origRate + else delete process.env['CODEBURN_SURCHARGE_RATE'] + } + }) +}) From 895e876658e1c453927e934033fbf3a805605d2e Mon Sep 17 00:00:00 2001 From: jaycdave88 Date: Sun, 19 Apr 2026 21:59:59 -0400 Subject: [PATCH 6/9] feat: integrate billing modes (credits/token_plus/legacy) into menubar U Agent-Id: agent-d47c61d6-048d-4f12-8dde-79fb39700667 Linked-Note-Id: 133eabe0-d3f0-4caf-8759-2baf6f4fabba --- mac/Sources/CodeBurnMenubar/AppStore.swift | 38 +++++ .../CodeBurnMenubar/Data/MenubarPayload.swift | 128 +++++++++++++- .../Views/ActivitySection.swift | 29 +++- .../Views/HeatmapSection.swift | 33 +++- .../CodeBurnMenubar/Views/HeroSection.swift | 29 +++- .../Views/MenuBarContent.swift | 144 ++++++++++------ .../CodeBurnMenubar/Views/ModelsSection.swift | 56 ++++++- .../DataClientBoundsTests.swift | 157 +++++++++++++++++- 8 files changed, 539 insertions(+), 75 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index bfd9dee..4b1df74 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -217,6 +217,44 @@ extension Double { let state = CurrencyState.shared return String(format: "\(state.symbol)%.2f", self * state.rate) } + + /// Format as credits (no currency symbol, comma-separated integer) + func asCredits() -> String { + thousandsFormatter.string(from: NSNumber(value: Int(self))) ?? "\(Int(self))" + } + + /// Format as compact credits (K/M suffix for large values) + func asCompactCredits() -> String { + if self >= 1_000_000 { + return String(format: "%.1fM", self / 1_000_000) + } else if self >= 1_000 { + return String(format: "%.1fK", self / 1_000) + } + return "\(Int(self))" + } +} + +extension Optional where Wrapped == Double { + /// Format optional double as currency with fallback + func asCurrency(fallback: String = "—") -> String { + guard let v = self else { return fallback } + return v.asCurrency() + } + + func asCompactCurrency(fallback: String = "—") -> String { + guard let v = self else { return fallback } + return v.asCompactCurrency() + } + + func asCredits(fallback: String = "—") -> String { + guard let v = self else { return fallback } + return v.asCredits() + } + + func asCompactCredits(fallback: String = "—") -> String { + guard let v = self else { return fallback } + return v.asCompactCredits() + } } extension Int { diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 2e44fae..4d657bb 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -1,12 +1,37 @@ import Foundation +// MARK: - Billing Mode + +/// Billing mode determines how costs are displayed throughout the UI. +/// - `credits`: Show Augment credits only, never USD. All `$` symbols hidden. +/// - `tokenPlus`: Show USD with surcharge. `$` symbols visible. +/// - `legacy`: Old CLI output without billing block. Display raw `cost` as USD for backwards compat. +enum BillingMode: String, Codable, Sendable { + case credits = "credits" + case tokenPlus = "token_plus" + case legacy // not emitted by CLI; synthesized when billing block absent +} + +/// Top-level billing metadata from CLI v2 JSON. +struct BillingInfo: Codable, Sendable { + let mode: BillingMode + let creditsPerDollar: Double? + let surchargeRate: Double? +} + /// Shape of `codeburn status --format menubar-json --period `. /// `current` is scoped to the requested period; the whole payload reflects that slice. struct MenubarPayload: Codable, Sendable { let generated: String + let billing: BillingInfo? let current: CurrentBlock let optimize: OptimizeBlock let history: HistoryBlock + + /// Resolved billing mode. Falls back to `.legacy` if `billing` block absent. + var billingMode: BillingMode { + billing?.mode ?? .legacy + } } struct HistoryBlock: Codable, Sendable { @@ -60,16 +85,82 @@ extension DailyHistoryEntry { struct CurrentBlock: Codable, Sendable { let label: String - let cost: Double + /// In credits mode: always null. In token_plus mode: billedAmountUsd. Legacy: USD cost. + let cost: Double? let calls: Int let sessions: Int let oneShotRate: Double? let inputTokens: Int let outputTokens: Int let cacheHitPercent: Double + + // Billing mode-specific fields (v2 JSON) + /// Credits mode: ground-truth or synthesized credits. Token+ mode: null. + let creditsAugment: Double? + /// Credits mode: count of credits that were synthesized (no ground truth). Token+ mode: 0. + let creditsSynthesized: Int? + /// Token+ mode: base cost before surcharge. Credits mode: null. + let baseCostUsd: Double? + /// Token+ mode: surcharge amount. Credits mode: null. + let surchargeUsd: Double? + /// Token+ mode: base + surcharge. Credits mode: null. + let billedAmountUsd: Double? + let topActivities: [ActivityEntry] let topModels: [ModelEntry] let providers: [String: Double] + + enum CodingKeys: String, CodingKey { + case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens, cacheHitPercent + case creditsAugment, creditsSynthesized, baseCostUsd, surchargeUsd, billedAmountUsd + case topActivities, topModels, providers + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + label = try c.decode(String.self, forKey: .label) + cost = try c.decodeIfPresent(Double.self, forKey: .cost) + calls = try c.decode(Int.self, forKey: .calls) + sessions = try c.decode(Int.self, forKey: .sessions) + oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate) + inputTokens = try c.decode(Int.self, forKey: .inputTokens) + outputTokens = try c.decode(Int.self, forKey: .outputTokens) + cacheHitPercent = try c.decode(Double.self, forKey: .cacheHitPercent) + creditsAugment = try c.decodeIfPresent(Double.self, forKey: .creditsAugment) + creditsSynthesized = try c.decodeIfPresent(Int.self, forKey: .creditsSynthesized) + baseCostUsd = try c.decodeIfPresent(Double.self, forKey: .baseCostUsd) + surchargeUsd = try c.decodeIfPresent(Double.self, forKey: .surchargeUsd) + billedAmountUsd = try c.decodeIfPresent(Double.self, forKey: .billedAmountUsd) + topActivities = try c.decode([ActivityEntry].self, forKey: .topActivities) + topModels = try c.decode([ModelEntry].self, forKey: .topModels) + providers = try c.decode([String: Double].self, forKey: .providers) + } + + /// Memberwise initializer for tests and empty placeholder. + init( + label: String, cost: Double?, calls: Int, sessions: Int, oneShotRate: Double?, + inputTokens: Int, outputTokens: Int, cacheHitPercent: Double, + creditsAugment: Double? = nil, creditsSynthesized: Int? = nil, + baseCostUsd: Double? = nil, surchargeUsd: Double? = nil, billedAmountUsd: Double? = nil, + topActivities: [ActivityEntry], topModels: [ModelEntry], providers: [String: Double] + ) { + self.label = label + self.cost = cost + self.calls = calls + self.sessions = sessions + self.oneShotRate = oneShotRate + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheHitPercent = cacheHitPercent + self.creditsAugment = creditsAugment + self.creditsSynthesized = creditsSynthesized + self.baseCostUsd = baseCostUsd + self.surchargeUsd = surchargeUsd + self.billedAmountUsd = billedAmountUsd + self.topActivities = topActivities + self.topModels = topModels + self.providers = providers + } } struct ActivityEntry: Codable, Sendable { @@ -81,8 +172,38 @@ struct ActivityEntry: Codable, Sendable { struct ModelEntry: Codable, Sendable { let name: String - let cost: Double + /// In credits mode: always null. In token_plus: billedAmountUsd. Legacy: USD cost. + let cost: Double? let calls: Int + /// Credits mode: credits for this model. Token+ mode: null. + let creditsAugment: Double? + /// Token+ mode: base cost for this model. Credits mode: null. + let baseCostUsd: Double? + /// Token+ mode: billed amount for this model. Credits mode: null. + let billedAmountUsd: Double? + + enum CodingKeys: String, CodingKey { + case name, cost, calls, creditsAugment, baseCostUsd, billedAmountUsd + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + name = try c.decode(String.self, forKey: .name) + cost = try c.decodeIfPresent(Double.self, forKey: .cost) + calls = try c.decode(Int.self, forKey: .calls) + creditsAugment = try c.decodeIfPresent(Double.self, forKey: .creditsAugment) + baseCostUsd = try c.decodeIfPresent(Double.self, forKey: .baseCostUsd) + billedAmountUsd = try c.decodeIfPresent(Double.self, forKey: .billedAmountUsd) + } + + init(name: String, cost: Double?, calls: Int, creditsAugment: Double? = nil, baseCostUsd: Double? = nil, billedAmountUsd: Double? = nil) { + self.name = name + self.cost = cost + self.calls = calls + self.creditsAugment = creditsAugment + self.baseCostUsd = baseCostUsd + self.billedAmountUsd = billedAmountUsd + } } struct OptimizeBlock: Codable, Sendable { @@ -104,9 +225,10 @@ extension MenubarPayload { /// plausible-looking fake numbers leak into the UI. static let empty = MenubarPayload( generated: "", + billing: nil, current: CurrentBlock( label: "", - cost: 0, + cost: nil, calls: 0, sessions: 0, oneShotRate: nil, diff --git a/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift b/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift index 9803387..abe88cd 100644 --- a/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift @@ -10,7 +10,7 @@ struct ActivitySection: View { isExpanded: $isExpanded, trailing: { HStack(spacing: 8) { - Text("Cost").frame(minWidth: 54, alignment: .trailing) + Text(costColumnHeader).frame(minWidth: 54, alignment: .trailing) Text("Turns").frame(minWidth: 52, alignment: .trailing) Text("1-shot").frame(minWidth: 44, alignment: .trailing) } @@ -20,18 +20,28 @@ struct ActivitySection: View { } ) { VStack(alignment: .leading, spacing: 7) { + let billingMode = store.payload.billingMode let maxCost = store.payload.current.topActivities.map(\.cost).max() ?? 1 ForEach(store.payload.current.topActivities, id: \.name) { activity in - ActivityRow(activity: activity, maxCost: maxCost) + ActivityRow(activity: activity, maxCost: maxCost, billingMode: billingMode) } } } } + + /// Column header varies by billing mode + private var costColumnHeader: String { + switch store.payload.billingMode { + case .credits: "Cost" // Activities don't have per-activity credits in v2 + case .tokenPlus, .legacy: "Cost" + } + } } struct ActivityRow: View { let activity: ActivityEntry let maxCost: Double + let billingMode: BillingMode var body: some View { HStack(spacing: 8) { @@ -42,7 +52,7 @@ struct ActivityRow: View { .font(.system(size: 12.5, weight: .medium)) .frame(maxWidth: .infinity, alignment: .leading) - Text(activity.cost.asCompactCurrency()) + Text(formattedCost) .font(.codeMono(size: 12, weight: .medium)) .tracking(-0.2) .frame(minWidth: 54, alignment: .trailing) @@ -63,6 +73,19 @@ struct ActivityRow: View { .padding(.vertical, 1) } + /// Format cost based on billing mode. Activities in the v2 schema only have `cost` + /// (no per-activity credits field), so we show the numeric cost without $ in credits mode. + private var formattedCost: String { + switch billingMode { + case .credits: + // In credits mode, activities don't have a credits field in the JSON schema, + // so we show the cost value as a raw number (no $ symbol) + return String(format: "%.2f", activity.cost) + case .tokenPlus, .legacy: + return activity.cost.asCompactCurrency() + } + } + private var oneShotText: String { guard let rate = activity.oneShotRate else { return "—" } return "\(Int(rate * 100))%" diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index e78808c..e75ec64 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -611,10 +611,8 @@ private struct PulseInsight: View { PulseTile(label: "Cache hit", value: cacheHitText, color: Theme.brandAccent) PulseTile(label: "1-shot", value: oneShotText, color: oneShotColor) PulseTile( - label: "Cost / session", - value: payload.current.sessions > 0 - ? (payload.current.cost / Double(payload.current.sessions)).asCompactCurrency() - : "—", + label: perSessionLabel, + value: perSessionValue, color: .secondary ) } @@ -633,6 +631,33 @@ private struct PulseInsight: View { private var oneShotColor: Color { payload.current.oneShotRate == nil ? .secondary : Theme.brandAccent } + + /// Label varies by billing mode + private var perSessionLabel: String { + switch payload.billingMode { + case .credits: "Credits / session" + case .tokenPlus, .legacy: "Cost / session" + } + } + + /// Value formatted according to billing mode + private var perSessionValue: String { + guard payload.current.sessions > 0 else { return "—" } + switch payload.billingMode { + case .credits: + guard let credits = payload.current.creditsAugment else { return "—" } + let perSession = credits / Double(payload.current.sessions) + return perSession.asCompactCredits() + case .tokenPlus: + guard let billed = payload.current.billedAmountUsd else { + return payload.current.cost.asCompactCurrency(fallback: "—") + } + return (billed / Double(payload.current.sessions)).asCompactCurrency() + case .legacy: + guard let cost = payload.current.cost else { return "—" } + return (cost / Double(payload.current.sessions)).asCompactCurrency() + } + } } private struct PulseTile: View { diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift index ca30cee..970e94e 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift @@ -8,7 +8,7 @@ struct HeroSection: View { SectionCaption(text: caption) HStack(alignment: .firstTextBaseline) { - Text(store.payload.current.cost.asCurrency()) + Text(heroValue) .font(.system(size: 32, weight: .semibold, design: .rounded)) .monospacedDigit() .tracking(-1) @@ -20,6 +20,14 @@ struct HeroSection: View { ) ) + // Unit indicator for credits mode + if store.payload.billingMode == .credits { + Text("credits") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.leading, 4) + } + Spacer() VStack(alignment: .trailing, spacing: 2) { @@ -39,6 +47,25 @@ struct HeroSection: View { .padding(.bottom, 12) } + /// Returns the hero metric formatted according to billing mode: + /// - credits mode: credits (no $) + /// - token_plus mode: billed USD amount + /// - legacy mode: cost as USD (backwards compat) + private var heroValue: String { + let current = store.payload.current + switch store.payload.billingMode { + case .credits: + // Show credits, no $ sign + return current.creditsAugment.asCredits(fallback: "0") + case .tokenPlus: + // Show billed USD amount + return current.billedAmountUsd.asCurrency(fallback: current.cost.asCurrency(fallback: "$0.00")) + case .legacy: + // Legacy mode: show cost as USD for backwards compat + return current.cost.asCurrency(fallback: "$0.00") + } + } + private var caption: String { let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label if store.selectedPeriod == .today { diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 76bdbe5..756c798 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -227,67 +227,101 @@ struct FooterBar: View { @Environment(AppStore.self) private var store var body: some View { - HStack(spacing: 6) { - Menu { - ForEach(SupportedCurrency.allCases) { currency in - Button { - applyCurrency(code: currency.rawValue) - } label: { - if currency.rawValue == store.currency { - Label("\(currency.displayName) (\(currency.rawValue))", systemImage: "checkmark") - } else { - Text("\(currency.displayName) (\(currency.rawValue))") + VStack(spacing: 0) { + // Billing mode indicator strip + if let indicator = billingModeIndicator { + HStack { + Text(indicator) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.06)) + } + + HStack(spacing: 6) { + // Only show currency picker in non-credits mode (credits are unitless) + if store.payload.billingMode != .credits { + Menu { + ForEach(SupportedCurrency.allCases) { currency in + Button { + applyCurrency(code: currency.rawValue) + } label: { + if currency.rawValue == store.currency { + Label("\(currency.displayName) (\(currency.rawValue))", systemImage: "checkmark") + } else { + Text("\(currency.displayName) (\(currency.rawValue))") + } + } } + } label: { + Label(store.currency, systemImage: "dollarsign.circle") + .font(.system(size: 11, weight: .medium)) + .labelStyle(.titleAndIcon) } + .menuStyle(.button) + .menuIndicator(.hidden) + .buttonStyle(.bordered) + .controlSize(.small) + .fixedSize() } - } label: { - Label(store.currency, systemImage: "dollarsign.circle") - .font(.system(size: 11, weight: .medium)) - .labelStyle(.titleAndIcon) - } - .menuStyle(.button) - .menuIndicator(.hidden) - .buttonStyle(.bordered) - .controlSize(.small) - .fixedSize() - - Button { - Task { await store.refresh(includeOptimize: true) } - } label: { - Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise") - .font(.system(size: 11, weight: .medium)) - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(store.isLoading) - - Menu { - Button("CSV (folder)") { runExport(format: .csv) } - Button("JSON") { runExport(format: .json) } - } label: { - Label("Export", systemImage: "square.and.arrow.down") - .font(.system(size: 11, weight: .medium)) - .labelStyle(.titleAndIcon) + + Button { + Task { await store.refresh(includeOptimize: true) } + } label: { + Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise") + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(store.isLoading) + + Menu { + Button("CSV (folder)") { runExport(format: .csv) } + Button("JSON") { runExport(format: .json) } + } label: { + Label("Export", systemImage: "square.and.arrow.down") + .font(.system(size: 11, weight: .medium)) + .labelStyle(.titleAndIcon) + } + .menuStyle(.button) + .menuIndicator(.hidden) + .buttonStyle(.bordered) + .controlSize(.small) + .fixedSize() + + Spacer() + + Button { openReport() } label: { + Label("Open Full Report", systemImage: "terminal") + .font(.system(size: 11, weight: .semibold)) + .labelStyle(.titleAndIcon) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .tint(Theme.brandAccent) } - .menuStyle(.button) - .menuIndicator(.hidden) - .buttonStyle(.bordered) - .controlSize(.small) - .fixedSize() - - Spacer() - - Button { openReport() } label: { - Label("Open Full Report", systemImage: "terminal") - .font(.system(size: 11, weight: .semibold)) - .labelStyle(.titleAndIcon) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + } + + /// Returns billing mode indicator text, or nil for legacy mode (no indicator needed) + private var billingModeIndicator: String? { + switch store.payload.billingMode { + case .credits: + return "Billing: credits" + case .tokenPlus: + if let rate = store.payload.billing?.surchargeRate { + let pct = Int(rate * 100) + return "Billing: Token+ · \(pct)%" } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .tint(Theme.brandAccent) + return "Billing: Token+" + case .legacy: + return nil // Don't show indicator for legacy payloads } - .padding(.horizontal, 12) - .padding(.vertical, 8) } private func openReport() { diff --git a/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift index cac5457..4bb03b2 100644 --- a/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift @@ -10,7 +10,7 @@ struct ModelsSection: View { isExpanded: $isExpanded, trailing: { HStack(spacing: 8) { - Text("Cost").frame(minWidth: 54, alignment: .trailing) + Text(costColumnHeader).frame(minWidth: 54, alignment: .trailing) Text("Calls").frame(minWidth: 52, alignment: .trailing) } .font(.system(size: 10, weight: .medium)) @@ -19,9 +19,10 @@ struct ModelsSection: View { } ) { VStack(alignment: .leading, spacing: 7) { - let maxCost = store.payload.current.topModels.map(\.cost).max() ?? 1 + let billingMode = store.payload.billingMode + let maxValue = store.payload.current.topModels.map { modelMetric($0, mode: billingMode) }.max() ?? 1 ForEach(store.payload.current.topModels, id: \.name) { model in - ModelRow(model: model, maxCost: maxCost) + ModelRow(model: model, maxValue: maxValue, billingMode: billingMode) } TokensLine() @@ -29,22 +30,43 @@ struct ModelsSection: View { } } } + + /// Column header varies by billing mode + private var costColumnHeader: String { + switch store.payload.billingMode { + case .credits: "Credits" + case .tokenPlus, .legacy: "Cost" + } + } + + /// Extract the numeric metric used for the bar chart from a model entry + private func modelMetric(_ model: ModelEntry, mode: BillingMode) -> Double { + switch mode { + case .credits: + return model.creditsAugment ?? 0 + case .tokenPlus: + return model.billedAmountUsd ?? model.cost ?? 0 + case .legacy: + return model.cost ?? 0 + } + } } private struct ModelRow: View { let model: ModelEntry - let maxCost: Double + let maxValue: Double + let billingMode: BillingMode var body: some View { HStack(spacing: 8) { - FixedBar(fraction: model.cost / maxCost) + FixedBar(fraction: metricValue / maxValue) .frame(width: 56, height: 6) Text(model.name) .font(.system(size: 12.5, weight: .medium)) .frame(maxWidth: .infinity, alignment: .leading) - Text(model.cost.asCompactCurrency()) + Text(formattedMetric) .font(.codeMono(size: 12, weight: .medium)) .tracking(-0.2) .frame(minWidth: 54, alignment: .trailing) @@ -58,6 +80,28 @@ private struct ModelRow: View { .padding(.horizontal, 2) .padding(.vertical, 1) } + + private var metricValue: Double { + switch billingMode { + case .credits: + return model.creditsAugment ?? 0 + case .tokenPlus: + return model.billedAmountUsd ?? model.cost ?? 0 + case .legacy: + return model.cost ?? 0 + } + } + + private var formattedMetric: String { + switch billingMode { + case .credits: + return model.creditsAugment.asCompactCredits(fallback: "—") + case .tokenPlus: + return model.billedAmountUsd.asCompactCurrency(fallback: model.cost.asCompactCurrency(fallback: "—")) + case .legacy: + return model.cost.asCompactCurrency(fallback: "—") + } + } } private struct TokensLine: View { diff --git a/mac/Tests/CodeBurnMenubarTests/DataClientBoundsTests.swift b/mac/Tests/CodeBurnMenubarTests/DataClientBoundsTests.swift index ca6dc7f..095dbc0 100644 --- a/mac/Tests/CodeBurnMenubarTests/DataClientBoundsTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/DataClientBoundsTests.swift @@ -4,7 +4,7 @@ import Testing /// Builds a minimal-but-valid MenubarPayload with `daily` populated to a given length so tests /// can exercise the post-decode array-length guards without constructing hundreds of fields. -private func payload(with dailyCount: Int, topModelsPerEntry: Int = 0) -> MenubarPayload { +private func payload(with dailyCount: Int, topModelsPerEntry: Int = 0, billing: BillingInfo? = nil) -> MenubarPayload { let entry = DailyHistoryEntry( date: "2026-04-19", cost: 0, @@ -20,8 +20,9 @@ private func payload(with dailyCount: Int, topModelsPerEntry: Int = 0) -> Menuba ) return MenubarPayload( generated: "", + billing: billing, current: CurrentBlock( - label: "", cost: 0, calls: 0, sessions: 0, oneShotRate: nil, + label: "", cost: nil, calls: 0, sessions: 0, oneShotRate: nil, inputTokens: 0, outputTokens: 0, cacheHitPercent: 0, topActivities: [], topModels: [], providers: [:] ), @@ -60,8 +61,9 @@ struct DataClientBoundsTests { ) let poisoned = MenubarPayload( generated: base.generated, + billing: base.billing, current: CurrentBlock( - label: "", cost: 0, calls: 0, sessions: 0, oneShotRate: nil, + label: "", cost: nil, calls: 0, sessions: 0, oneShotRate: nil, inputTokens: 0, outputTokens: 0, cacheHitPercent: 0, topActivities: oversizeActivities, topModels: [], @@ -75,3 +77,152 @@ struct DataClientBoundsTests { } } } + +// MARK: - Billing Mode Tests + +@Suite("Billing mode detection") +struct BillingModeTests { + @Test("defaults to legacy mode when billing block absent") + func legacyModeDefault() { + let p = payload(with: 0, billing: nil) + #expect(p.billingMode == .legacy) + } + + @Test("detects credits mode from billing block") + func creditsMode() { + let billing = BillingInfo(mode: .credits, creditsPerDollar: 1600, surchargeRate: nil) + let p = payload(with: 0, billing: billing) + #expect(p.billingMode == .credits) + } + + @Test("detects token_plus mode from billing block") + func tokenPlusMode() { + let billing = BillingInfo(mode: .tokenPlus, creditsPerDollar: nil, surchargeRate: 0.3) + let p = payload(with: 0, billing: billing) + #expect(p.billingMode == .tokenPlus) + } +} + +@Suite("Billing JSON decoding") +struct BillingDecodingTests { + + @Test("decodes credits mode JSON with all fields") + func decodeCreditsMode() throws { + let json = """ + { + "generated": "2026-04-20T12:00:00Z", + "billing": { + "mode": "credits", + "creditsPerDollar": 1600 + }, + "current": { + "label": "Today", + "cost": null, + "calls": 42, + "sessions": 5, + "oneShotRate": 0.75, + "inputTokens": 10000, + "outputTokens": 5000, + "cacheHitPercent": 45.5, + "creditsAugment": 12345, + "creditsSynthesized": 500, + "topActivities": [], + "topModels": [ + {"name": "claude-sonnet-4-20250514", "cost": null, "calls": 10, "creditsAugment": 5000} + ], + "providers": {} + }, + "optimize": {"findingCount": 0, "savingsUSD": 0, "topFindings": []}, + "history": {"daily": []} + } + """ + let data = json.data(using: .utf8)! + let payload = try JSONDecoder().decode(MenubarPayload.self, from: data) + + #expect(payload.billingMode == .credits) + #expect(payload.billing?.creditsPerDollar == 1600) + #expect(payload.current.cost == nil) + #expect(payload.current.creditsAugment == 12345) + #expect(payload.current.creditsSynthesized == 500) + #expect(payload.current.topModels.first?.creditsAugment == 5000) + #expect(payload.current.topModels.first?.cost == nil) + } + + @Test("decodes token_plus mode JSON with all fields") + func decodeTokenPlusMode() throws { + let json = """ + { + "generated": "2026-04-20T12:00:00Z", + "billing": { + "mode": "token_plus", + "surchargeRate": 0.3 + }, + "current": { + "label": "Today", + "cost": 13.00, + "calls": 42, + "sessions": 5, + "oneShotRate": 0.75, + "inputTokens": 10000, + "outputTokens": 5000, + "cacheHitPercent": 45.5, + "baseCostUsd": 10.00, + "surchargeUsd": 3.00, + "billedAmountUsd": 13.00, + "topActivities": [], + "topModels": [ + {"name": "claude-sonnet-4-20250514", "cost": 6.50, "calls": 10, "baseCostUsd": 5.00, "billedAmountUsd": 6.50} + ], + "providers": {} + }, + "optimize": {"findingCount": 0, "savingsUSD": 0, "topFindings": []}, + "history": {"daily": []} + } + """ + let data = json.data(using: .utf8)! + let payload = try JSONDecoder().decode(MenubarPayload.self, from: data) + + #expect(payload.billingMode == .tokenPlus) + #expect(payload.billing?.surchargeRate == 0.3) + #expect(payload.current.cost == 13.00) + #expect(payload.current.baseCostUsd == 10.00) + #expect(payload.current.surchargeUsd == 3.00) + #expect(payload.current.billedAmountUsd == 13.00) + #expect(payload.current.topModels.first?.baseCostUsd == 5.00) + #expect(payload.current.topModels.first?.billedAmountUsd == 6.50) + } + + @Test("decodes legacy JSON (no billing block) with backwards compat") + func decodeLegacyMode() throws { + let json = """ + { + "generated": "2026-04-20T12:00:00Z", + "current": { + "label": "Today", + "cost": 25.50, + "calls": 42, + "sessions": 5, + "oneShotRate": 0.75, + "inputTokens": 10000, + "outputTokens": 5000, + "cacheHitPercent": 45.5, + "topActivities": [], + "topModels": [ + {"name": "claude-sonnet-4-20250514", "cost": 12.50, "calls": 10} + ], + "providers": {} + }, + "optimize": {"findingCount": 0, "savingsUSD": 0, "topFindings": []}, + "history": {"daily": []} + } + """ + let data = json.data(using: .utf8)! + let payload = try JSONDecoder().decode(MenubarPayload.self, from: data) + + #expect(payload.billingMode == .legacy) + #expect(payload.billing == nil) + #expect(payload.current.cost == 25.50) + #expect(payload.current.creditsAugment == nil) + #expect(payload.current.topModels.first?.cost == 12.50) + } +} From 8cc65e21c993111303b32e56f08f52a0177de37a Mon Sep 17 00:00:00 2001 From: jaycdave88 Date: Sun, 19 Apr 2026 22:15:09 -0400 Subject: [PATCH 7/9] feat: pass billingMode to TrendInsight and use it to determine metric di Agent-Id: agent-d47c61d6-048d-4f12-8dde-79fb39700667 Linked-Note-Id: 133eabe0-d3f0-4caf-8759-2baf6f4fabba --- mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index e75ec64..709f98d 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -37,7 +37,7 @@ struct HeatmapSection: View { private var content: some View { switch store.selectedInsight { case .plan: PlanInsight(usage: store.subscription) - case .trend: TrendInsight(days: store.payload.history.daily) + case .trend: TrendInsight(days: store.payload.history.daily, billingMode: store.payload.billingMode) case .forecast: ForecastInsight(days: store.payload.history.daily) case .pulse: PulseInsight(payload: store.payload) case .stats: StatsInsight(payload: store.payload) @@ -77,14 +77,16 @@ private struct InsightPillSwitcher: View { private struct TrendInsight: View { let days: [DailyHistoryEntry] + let billingMode: BillingMode var body: some View { let bars = buildTrendBars(from: days) let stats = computeTrendStats(bars: bars, allDays: days) // Tokens are real for the .all-providers view; per-provider history doesn't carry // token breakdown yet, so fall back to $ when no tokens are present. + // In credits mode, always use tokens to avoid showing $ symbols. let totalTokens = bars.reduce(0.0) { $0 + $1.tokens } - let useTokens = totalTokens > 0 + let useTokens = (billingMode == .credits) || (totalTokens > 0) let metric: (TrendBar) -> Double = useTokens ? { $0.tokens } : { $0.cost } let maxValue = max(bars.map(metric).max() ?? 1, 0.01) let avgValue = bars.isEmpty ? 0 : bars.map(metric).reduce(0, +) / Double(bars.count) From e2365e52fb9ac5d85ebeaf2e5280670271db114f Mon Sep 17 00:00:00 2001 From: jaycdave88 Date: Sun, 19 Apr 2026 22:20:04 -0400 Subject: [PATCH 8/9] feat: release v2.0.0 with billing modes and Token+ USD estimates Agent-Id: agent-e6416dd9-1c58-4ecf-89fb-a279464bc41f Linked-Note-Id: c21f7364-5533-4dd5-b8e4-0a6c4a66cd1b --- CHANGELOG.md | 31 ++++++++++ README.md | 60 +++++++++++++++++++ .../Views/MenuBarContent.swift | 4 +- src/billing.ts | 4 +- src/dashboard.tsx | 5 +- tests/billing.test.ts | 12 ++-- 6 files changed, 105 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 963bcd8..254fb2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [2.0.0] - 2026-04-20 + +### Added + +- **Credits mode** (default): Shows Augment credits consumed per session and per model. Ground-truth credits from `billing_metadata` are used when present; otherwise credits are synthesized as `⌈ base_cost_usd × 1600 ⌉`. +- **Token+ mode** ("USD estimate"): Shows estimated USD cost instead of credits, with configurable surcharge. Useful for enterprise users with contracted USD rates. +- **`billing` JSON block** in export output with mode, surchargeRate, and credit/cost breakdowns. +- **Per-model and per-project credits** in dashboard panels and JSON exports. +- **macOS menubar mode-awareness**: Billing mode indicator in footer shows "credits" or "USD estimate" with surcharge rate. +- **Invariant test** for Token+ aggregation ensuring `base + surcharge = billed`. + +### Changed + +- **Default `CODEBURN_SURCHARGE_RATE` is now `0`** (was implicitly `0.3` in pre-release drafts). Self-serve CBP tenants have `surcharge_rate = 0` at the metering-event ground truth; 30% was wrong for most users except some enterprise USD deals. +- **Display label "Token+" shown as "USD estimate"** in CLI dashboard and macOS menubar UI. Internal mode value `token_plus` unchanged. +- Updated README with "Billing modes" section documenting both modes, env vars, limitations, and migration notes. + +### Breaking + +- **JSON schema v2**: `overview.cost` is `null` in credits mode. Callers that indexed `overview.cost` as a number must handle null. +- **New fields**: `creditsAugment`, `creditsSynthesized`, `baseCostUsd`, `surchargeUsd`, `billedAmountUsd`, and top-level `billing` block. +- **Cache format versioned to v2**: Pre-v2 caches auto-invalidated on upgrade. + +### Fixed + +- **Token+ aggregation reducer short-circuit** that caused `base + surcharge ≠ billed` in edge cases. + +### Known Limitations + +- See README "Billing modes → Limitations" section. + ## 1.0.0 - 2026-04-19 ### Breaking changes diff --git a/README.md b/README.md index eb0213b..978ae95 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,66 @@ Detects files re-read across sessions, low Read:Edit ratios, uncapped bash outpu *Screenshot predates 1.0.0 and will be updated.* +## Billing modes + +CodeBurn supports two billing modes for tracking Auggie usage: + +### `credits` (default) + +Shows **Augment credits** consumed per session and per model. Credits are the authoritative billing unit for most users. + +- **Ground-truth credits**: When present in session data (via `billing_metadata.credits_consumed`), these are used directly +- **Synthesized credits**: When ground-truth is missing but model pricing is known, credits are computed as `⌈ base_cost_usd × 1600 ⌉` + +### `token_plus` (a.k.a. "USD estimate") + +Shows estimated **USD cost** instead of credits. Useful for enterprise users with contracted USD rates. + +- Displays `base cost`, `surcharge`, and `billed amount` columns +- Formula: `billed = base_cost_usd × (1 + surcharge_rate)` + +### Environment variables + +| Variable | Description | Default | +|---|---|---| +| `CODEBURN_BILLING_MODE` | `credits` or `token_plus` | `credits` | +| `CODEBURN_SURCHARGE_RATE` | Decimal surcharge for token_plus mode | `0` (0% surcharge; enterprise USD users set to contracted rate e.g. `0.3` for 30%) | + +### Limitations + +> **⚠️ Token+ mode is approximate.** The USD values shown are synthesized from token counts plus a configured surcharge. They are **not invoice-accurate**. True per-request USD (`billed_amount_usd`) lives in Augment's server-side metering pipeline and isn't written to local session logs. + +- `CREDITS_PER_DOLLAR = 1600` is the platform default but is a feature flag; some tenants may use a different rate. +- Activity multiplier is hardcoded to 1.0 (correct for `Chat` / `Agent` / `CliNoninteractive`). ContextEngine activities (3.0x) and CodeReview (2.0x) would be under-counted, but these aren't exercised through the Auggie CLI path codeburn reads. +- Legacy sessions missing `modelId` (~22% in observed corpora) are reported as `null` credits / cost. + +### Migration from v1.x + +JSON schema v2 is **breaking**: +- `overview.cost` is `null` in credits mode (callers that indexed `overview.cost` as a number must handle null) +- New fields: `creditsAugment`, `creditsSynthesized`, `baseCostUsd`, `surchargeUsd`, `billedAmountUsd`, and top-level `billing` block +- Cache format versioned to v2 (pre-v2 caches auto-invalidated on upgrade) + +### Rate-card reference + +The credit pricing table is cross-referenced against [docs.augmentcode.com/models/credit-based-pricing](https://docs.augmentcode.com/models/credit-based-pricing) (advisory). Internal `billing_configs.jsonnet` is Augment's actual source of truth. + +**Models in CodeBurn's pricing table:** + +| Model | Relative to Sonnet | Notes | +|---|---|---| +| Claude Sonnet 4.5/4.6 | 100% (baseline) | 293 credits per standard task | +| Claude Opus 4.5/4.6/4.7 | 167% | 488 credits per standard task | +| Claude Haiku 4.5 | 30% | 88 credits per standard task | +| Gemini 3.1 Pro | 92% | 268 credits per standard task | +| GPT-5.1 | 75% | 219 credits per standard task | +| GPT-5.2 | 133% | 390 credits per standard task | +| GPT-5.4 | 143% | 420 credits per standard task | + +Models in our table that aren't on the docs page: `gpt-4o`, `gpt-4o-mini`, `gpt-4.1*`, `gpt-5`, `gpt-5-mini`, `gpt-5.3-codex`, `gpt-5.4-mini`, `o3`, `o4-mini`, `claude-3-5-sonnet`, `claude-3-7-sonnet`, `claude-3-5-haiku`, `gemini-2.5-pro`, `auggie-legacy`, `auggie-unknown`. + +Models on the docs page not in our table: `GPT-5.2` (missing from `models.ts` — flagged, may need addition if used in Auggie sessions). + ## Data and privacy All session parsing is local; no prompt or response text is sent off your machine. The network calls CodeBurn makes are: diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 756c798..9505168 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -316,9 +316,9 @@ struct FooterBar: View { case .tokenPlus: if let rate = store.payload.billing?.surchargeRate { let pct = Int(rate * 100) - return "Billing: Token+ · \(pct)%" + return "Billing: USD estimate · \(pct)%" } - return "Billing: Token+" + return "Billing: USD estimate" case .legacy: return nil // Don't show indicator for legacy payloads } diff --git a/src/billing.ts b/src/billing.ts index 014caad..377d1a8 100644 --- a/src/billing.ts +++ b/src/billing.ts @@ -7,7 +7,7 @@ * * Environment variables: * CODEBURN_BILLING_MODE: 'credits' | 'token_plus' (default: 'credits') - * CODEBURN_SURCHARGE_RATE: decimal surcharge for token_plus mode (default: 0.3 = 30%) + * CODEBURN_SURCHARGE_RATE: decimal surcharge for token_plus mode (default: 0) * * Credit formula (no surcharge): Math.ceil(baseCostUsd × 1600 × 1.0 × 1.0) */ @@ -58,7 +58,7 @@ export function loadBillingConfig(env: NodeJS.ProcessEnv = process.env): Billing const rawSurcharge = env.CODEBURN_SURCHARGE_RATE const parsed = rawSurcharge !== undefined ? Number(rawSurcharge) : NaN - const surchargeRate = Number.isFinite(parsed) && parsed >= 0 ? parsed : 0.3 + const surchargeRate = Number.isFinite(parsed) && parsed >= 0 ? parsed : 0 return { mode, surchargeRate } } diff --git a/src/dashboard.tsx b/src/dashboard.tsx index df8414c..fb5a363 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -176,7 +176,7 @@ function Overview({ projects, label, width, billingMode, surchargeRate }: { proj // Build billing mode subtitle const billingSubtitle = billingMode === 'credits' ? 'Billing: credits' - : `Billing: Token+ · surcharge ${Math.round(surchargeRate * 100)}%` + : `Billing: USD estimate · surcharge ${Math.round(surchargeRate * 100)}%` return ( @@ -185,6 +185,9 @@ function Overview({ projects, label, width, billingMode, surchargeRate }: { proj {label} {billingSubtitle} + {billingMode === 'token_plus' && ( + Approximated from token costs; not invoice-accurate. + )} {billingMode === 'credits' ? ( // Credits mode: show credits prominently, no USD diff --git a/tests/billing.test.ts b/tests/billing.test.ts index 86d1626..f15eb70 100644 --- a/tests/billing.test.ts +++ b/tests/billing.test.ts @@ -143,9 +143,9 @@ describe('loadBillingConfig', () => { expect(config.mode).toBe('token_plus') }) - it('defaults surchargeRate to 0.3 when unset', () => { + it('defaults surchargeRate to 0 when unset', () => { const config = loadBillingConfig({}) - expect(config.surchargeRate).toBe(0.3) + expect(config.surchargeRate).toBe(0) }) it('parses custom surchargeRate', () => { @@ -153,13 +153,13 @@ describe('loadBillingConfig', () => { expect(config.surchargeRate).toBe(0.25) }) - it('falls back to 0.3 for negative surchargeRate', () => { + it('falls back to 0 for negative surchargeRate', () => { const config = loadBillingConfig({ CODEBURN_SURCHARGE_RATE: '-0.1' }) - expect(config.surchargeRate).toBe(0.3) + expect(config.surchargeRate).toBe(0) }) - it('falls back to 0.3 for non-numeric surchargeRate', () => { + it('falls back to 0 for non-numeric surchargeRate', () => { const config = loadBillingConfig({ CODEBURN_SURCHARGE_RATE: 'invalid' }) - expect(config.surchargeRate).toBe(0.3) + expect(config.surchargeRate).toBe(0) }) }) From efc8b7de852db231a0411a2876106a0c2878c05a Mon Sep 17 00:00:00 2001 From: jaycdave88 Date: Sun, 19 Apr 2026 22:27:38 -0400 Subject: [PATCH 9/9] chore: sync package.json + lockfile to 2.0.0 Release commit e2365e5 bumped the docs/CHANGELOG but missed the package.json version field and lockfile sync. This closes the gap so npm publish picks up 2.0.0. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8911286..99816cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeburn", - "version": "0.7.3", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeburn", - "version": "0.7.3", + "version": "2.0.0", "license": "MIT", "dependencies": { "chalk": "^5.4.1", diff --git a/package.json b/package.json index 683064e..4f6f00e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeburn", - "version": "1.0.0", + "version": "2.0.0", "description": "See where your Auggie tokens go - by task, tool, model, and project", "type": "module", "main": "./dist/cli.js",