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
26 changes: 26 additions & 0 deletions Sources/CodingBar/SelfTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,32 @@ enum SelfTest {
check("Sonnet 5 standard pricing", abs(Pricing.cost(model: "claude-sonnet-5", tokens: millionTokens,
at: september, cacheWrite1h: 1_000_000) - 24.3) < 0.000_001)

let openAIBaseTokens = TokenBreakdown(input: 100_000, output: 100_000,
cacheRead: 100_000, cacheWrite: 100_000)
check("GPT-5.6 tiers resolve exactly",
Pricing.normalize(model: "gpt-5.6") == "openai/gpt-5.6-sol"
&& Pricing.normalize(model: "gpt-5.6-sol") == "openai/gpt-5.6-sol"
&& Pricing.normalize(model: "gpt-5.6-terra") == "openai/gpt-5.6-terra"
&& Pricing.normalize(model: "gpt-5.6-luna") == "openai/gpt-5.6-luna"
&& Pricing.priceIsExact(model: "gpt-5.6"))
check("GPT-5.6 Sol base and long-context pricing",
abs(Pricing.cost(model: "gpt-5.6-sol", tokens: openAIBaseTokens, at: july,
billingInputTokens: 272_000) - 4.175) < 0.000_001
&& abs(Pricing.cost(model: "gpt-5.6-sol", tokens: openAIBaseTokens, at: july,
billingInputTokens: 272_001) - 6.85) < 0.000_001)
check("GPT prices cover current and historical IDs",
abs(Pricing.cost(model: "gpt-5.4-mini", tokens: TokenBreakdown(output: 1_000_000),
at: july) - 4.5) < 0.000_001
&& abs(Pricing.cost(model: "gpt-5.3-codex", tokens: TokenBreakdown(output: 1_000_000),
at: july) - 14) < 0.000_001
&& Pricing.priceIsExact(model: "gpt-5.1")
&& Pricing.priceIsExact(model: "gpt-4o-mini")
&& Pricing.normalize(model: "gpt-5.4-nano-2026-03-17") == "openai/gpt-5.4-nano")
check("unknown Codex IDs remain approximate",
Pricing.normalize(model: "gpt-5.6-codex") == "gpt-5.6-codex"
&& !Pricing.priceIsExact(model: "gpt-5.6-codex")
&& !Pricing.priceIsExact(model: "gpt-5.5-codex"))

// Regression: the family-keyword fallback used to funnel every Opus into 4.8, so a
// real `claude-opus-5` record was renamed and merged into the 4.8 row. Each tier
// must resolve to itself, and an unrecognized version to the newest — not a pinned
Expand Down
51 changes: 29 additions & 22 deletions Sources/CodingBarCore/Aggregator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@ import Foundation

public enum Aggregator {

/// Cache stats span every local provider because the UI presents one all-time total.
/// Keeping this pure also pins the pricing semantics without scanning real user logs.
static func cacheStat(from records: [RawRecord]) -> CacheStat {
var totalCacheRead = 0
var totalCacheWrite = 0
var totalInput = 0
var totalSavedWeightedRead = 0.0

for r in records {
totalCacheRead += r.tokens.cacheRead
totalCacheWrite += r.tokens.cacheWrite
totalInput += r.tokens.input
let key = Pricing.normalize(model: r.model)
let promptTokens = r.billingInputTokens ?? (r.tokens.input + r.tokens.cacheRead + r.tokens.cacheWrite)
let inputPrice = Pricing.inputPrice(forCanonicalKey: key, at: r.timestamp,
billingInputTokens: promptTokens)
let cacheReadPrice = Pricing.cacheReadPrice(forCanonicalKey: key, at: r.timestamp,
billingInputTokens: promptTokens)
totalSavedWeightedRead += Double(r.tokens.cacheRead) * (inputPrice - cacheReadPrice)
}

let denominator = totalCacheRead + totalCacheWrite + totalInput
let hitRate = denominator > 0 ? Double(totalCacheRead) / Double(denominator) : 0
return CacheStat(hitRate: hitRate, savedUSD: totalSavedWeightedRead / 1_000_000)
}

/// `quota` is supplied by the online `QuotaService` (Claude + Codex usage
/// APIs). It is a parameter rather than scanned here so the local-log
/// aggregation stays synchronous and offline; the UI injects the latest
Expand All @@ -20,7 +46,8 @@ public enum Aggregator {
let allRecords = claudeRecords + codexRecords
func recordCost(_ record: RawRecord) -> Double {
Pricing.cost(model: record.model, tokens: record.tokens,
at: record.timestamp, cacheWrite1h: record.cacheWrite1h)
at: record.timestamp, cacheWrite1h: record.cacheWrite1h,
billingInputTokens: record.billingInputTokens)
}

let todayStart = cal.startOfDay(for: now)
Expand Down Expand Up @@ -198,27 +225,7 @@ public enum Aggregator {

let (models, projects) = breakdown(from: allRecords)

// cache stats are Claude only
var totalCacheRead = 0
var totalCacheWrite = 0
var totalInput = 0
var totalSavedWeightedRead = 0.0

for r in claudeRecords {
totalCacheRead += r.tokens.cacheRead
totalCacheWrite += r.tokens.cacheWrite
totalInput += r.tokens.input
let key = Pricing.normalize(model: r.model)
let iPrice = Pricing.inputPrice(forCanonicalKey: key, at: r.timestamp)
let crPrice = Pricing.cacheReadPrice(forCanonicalKey: key, at: r.timestamp)
totalSavedWeightedRead += Double(r.tokens.cacheRead) * (iPrice - crPrice)
}

let denominator = totalCacheRead + totalCacheWrite + totalInput
let hitRate = denominator > 0 ? Double(totalCacheRead) / Double(denominator) : 0
let savedUSD = totalSavedWeightedRead / 1_000_000

let cache = CacheStat(hitRate: hitRate, savedUSD: savedUSD)
let cache = cacheStat(from: allRecords)

let totalTodayTokens = todayTokens.total
let primaryText: String
Expand Down
10 changes: 7 additions & 3 deletions Sources/CodingBarCore/Coach.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,16 @@ enum Coach {
totalWrite += r.tokens.cacheWrite
totalRead += r.tokens.cacheRead
let key = Pricing.normalize(model: r.model)
let writePrice = Pricing.inputPrice(forCanonicalKey: key, at: r.timestamp)
let readPrice = Pricing.cacheReadPrice(forCanonicalKey: key, at: r.timestamp)
let promptTokens = r.billingInputTokens ?? (r.tokens.input + r.tokens.cacheRead + r.tokens.cacheWrite)
let writePrice = Pricing.inputPrice(forCanonicalKey: key, at: r.timestamp,
billingInputTokens: promptTokens)
let readPrice = Pricing.cacheReadPrice(forCanonicalKey: key, at: r.timestamp,
billingInputTokens: promptTokens)
totalWriteCost += Pricing.cost(model: r.model,
tokens: TokenBreakdown(cacheWrite: r.tokens.cacheWrite),
at: r.timestamp,
cacheWrite1h: r.cacheWrite1h)
cacheWrite1h: r.cacheWrite1h,
billingInputTokens: promptTokens)
totalReadSavings += Double(r.tokens.cacheRead) * (writePrice - readPrice) / 1_000_000
}

Expand Down
53 changes: 33 additions & 20 deletions Sources/CodingBarCore/CodexScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public enum CodexScanner {
// (measured ~1.3–1.8× across this machine's logs). Taking the positive delta
// of `total_token_usage` reconstructs each turn's true increment, drops
// duplicate snapshots (Δ≤0), and preserves per-turn timestamps for bucketing.
var prevInput = 0, prevCached = 0, prevOutput = 0, prevReasoning = 0
var prevInput = 0, prevCached = 0, prevCacheWrite = 0, prevOutput = 0, prevReasoning = 0
// Codex tool calls (`function_call` response items, e.g. exec_command) arrive
// before the turn's `token_count`; buffer their names and attach them to the
// next emitted record so the habits tool-mix counts Codex, not just Claude.
Expand Down Expand Up @@ -96,25 +96,31 @@ public enum CodexScanner {
}

// Every non-null `info` carries `total_token_usage` (verified across
// every real event); the per-turn `last_token_usage` is no longer used.
// every real event). Its positive delta remains the billable token count;
// `last_token_usage.input_tokens` is retained only as the absolute prompt
// size needed to select OpenAI's >272K long-context price tier.
guard let info = payload["info"] as? [String: Any],
let total = info["total_token_usage"] as? [String: Any] else {
return
}
let last = info["last_token_usage"] as? [String: Any]
let billingInputTokens = (last?["input_tokens"] as? Int).map { max(0, $0) }

let curInput = total["input_tokens"] as? Int ?? 0
let curCached = total["cached_input_tokens"] as? Int ?? 0
let curOutput = total["output_tokens"] as? Int ?? 0
let curReasoning = total["reasoning_output_tokens"] as? Int ?? 0
let curInput = total["input_tokens"] as? Int ?? 0
let curCached = total["cached_input_tokens"] as? Int ?? 0
let curCacheWrite = total["cache_write_input_tokens"] as? Int ?? 0
let curOutput = total["output_tokens"] as? Int ?? 0
let curReasoning = total["reasoning_output_tokens"] as? Int ?? 0

// Δ of the cumulative counter. A counter that *drops* (post-compaction
// reset) starts a fresh baseline so those turns aren't lost.
let reset = curInput < prevInput || curOutput < prevOutput
let dInput = reset ? curInput : curInput - prevInput
let dCached = reset ? curCached : curCached - prevCached
let dOutput = reset ? curOutput : curOutput - prevOutput
let dReasoning = reset ? curReasoning : curReasoning - prevReasoning
prevInput = curInput; prevCached = curCached
let dInput = reset ? curInput : curInput - prevInput
let dCached = reset ? curCached : curCached - prevCached
let dCacheWrite = reset ? curCacheWrite : curCacheWrite - prevCacheWrite
let dOutput = reset ? curOutput : curOutput - prevOutput
let dReasoning = reset ? curReasoning : curReasoning - prevReasoning
prevInput = curInput; prevCached = curCached; prevCacheWrite = curCacheWrite
prevOutput = curOutput; prevReasoning = curReasoning

// No forward progress → a replayed/duplicate snapshot, nothing billed.
Expand All @@ -128,17 +134,23 @@ public enum CodexScanner {
pendingTools.removeAll(keepingCapacity: true); return
}

// Codex: input_tokens INCLUDES cached; net fresh input = input − cached.
// Clamp the cached delta at 0 first so a (data-wise unreachable) cached
// dip without a full reset can never inflate net input above dInput.
let netInput = max(0, dInput - max(0, dCached))

// Codex input_tokens includes both cached reads and cache writes. Keep all
// three buckets disjoint so both total tokens and model-specific cache rates
// remain correct. Negative subset deltas are treated as zero after a reset.
let cacheRead = max(0, dCached)
let cacheWrite = max(0, dCacheWrite)
let netInput = max(0, dInput - cacheRead - cacheWrite)

// Codex's output_tokens already includes reasoning_output_tokens. Split
// the subset into its own bucket so TokenBreakdown.total and Pricing.cost
// count it once rather than adding the same reasoning tokens twice.
let reasoning = min(max(0, dReasoning), max(0, dOutput))
let tokens = TokenBreakdown(
input: netInput,
output: dOutput,
cacheRead: max(0, dCached),
cacheWrite: 0,
reasoning: max(0, dReasoning)
output: max(0, dOutput - reasoning),
cacheRead: cacheRead,
cacheWrite: cacheWrite,
reasoning: reasoning
)

let record = RawRecord(
Expand All @@ -147,6 +159,7 @@ public enum CodexScanner {
timestamp: timestamp,
cwd: cwd,
tokens: tokens,
billingInputTokens: billingInputTokens,
toolName: pendingTools.first,
toolNames: pendingTools,
messageId: nil,
Expand Down
6 changes: 4 additions & 2 deletions Sources/CodingBarCore/Fuel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,13 @@ enum FuelCalculator {
var burn: Double = 0
for r in claudeRecords where r.timestamp >= minuteAgo && r.timestamp <= now {
burn += Pricing.cost(model: r.model, tokens: r.tokens,
at: r.timestamp, cacheWrite1h: r.cacheWrite1h)
at: r.timestamp, cacheWrite1h: r.cacheWrite1h,
billingInputTokens: r.billingInputTokens)
}
for r in codexRecords where r.timestamp >= minuteAgo && r.timestamp <= now {
burn += Pricing.cost(model: r.model, tokens: r.tokens,
at: r.timestamp, cacheWrite1h: r.cacheWrite1h)
at: r.timestamp, cacheWrite1h: r.cacheWrite1h,
billingInputTokens: r.billingInputTokens)
}

// Group Claude records by session; surface those active within 90s.
Expand Down
Loading
Loading