Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
38 changes: 38 additions & 0 deletions mac/Sources/CodeBurnMenubar/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
128 changes: 125 additions & 3 deletions mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Original file line number Diff line number Diff line change
@@ -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 <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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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,
Expand Down
29 changes: 26 additions & 3 deletions mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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) {
Expand All @@ -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)
Expand All @@ -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))%"
Expand Down
Loading
Loading