diff --git a/Scripts/Info.plist b/Scripts/Info.plist
index 8f55901..144462a 100644
--- a/Scripts/Info.plist
+++ b/Scripts/Info.plist
@@ -9,9 +9,9 @@
CFBundleIdentifier
com.gnonymous.codingbar
CFBundleVersion
- 0.1.0
+ 1.2.1
CFBundleShortVersionString
- 0.1.0
+ 1.2.1
CFBundleExecutable
CodingBar
CFBundleIconFile
diff --git a/Sources/CodingBar/SelfTest.swift b/Sources/CodingBar/SelfTest.swift
index fb5392b..0720658 100644
--- a/Sources/CodingBar/SelfTest.swift
+++ b/Sources/CodingBar/SelfTest.swift
@@ -89,6 +89,14 @@ enum SelfTest {
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"))
+ check("GPT-6.1 Sol and Sonnet 5.5 cache rates",
+ Pricing.priceIsExact(model: "gpt-6.1-sol") && Pricing.priceIsExact(model: "claude-sonnet-5-5")
+ && abs(Pricing.cost(model: "gpt-6.1-sol", tokens: .init(cacheRead: 10_000), at: september) - 0.001) < 1e-9
+ && abs(Pricing.cost(model: "claude-sonnet-5-5", tokens: .init(cacheRead: 10_000), at: september) - 0.001) < 1e-9)
+ check("Haiku 5.5 prompt-length pricing",
+ Pricing.priceIsExact(model: "claude-haiku-5-5")
+ && abs(Pricing.cost(model: "claude-haiku-5-5", tokens: .init(input: 100_000), at: september) - 0.01) < 1e-9
+ && abs(Pricing.cost(model: "claude-haiku-5-5", tokens: .init(input: 100_001), at: september) - 0.0500005) < 1e-9)
// 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
@@ -117,7 +125,8 @@ enum SelfTest {
- Pricing.cost(model: "claude-fable-5", tokens: millionTokens, at: july)) < 0.000_001)
check("bare family selectors mean the current model",
Pricing.normalize(model: "opus") == "anthropic/claude-opus-5-5"
- && Pricing.normalize(model: "sonnet") == "anthropic/claude-sonnet-5")
+ && Pricing.normalize(model: "sonnet") == "anthropic/claude-sonnet-5-5"
+ && Pricing.normalize(model: "haiku") == "anthropic/claude-haiku-5-5")
// 1M each of input/output/cacheRead/cacheWrite at $5 / $25 / $0.5 / $6.25 = $36.75.
check("Opus 5 priced at the Opus tier, not the generic fallback",
Pricing.priceIsExact(model: "claude-opus-5")
@@ -134,6 +143,11 @@ enum SelfTest {
let monthModels = snap.overviews.first { $0.range == .month }?.models.count ?? 0
let todayModels = snap.overviews.first { $0.range == .today }?.models.count ?? 0
check("month composition ⊇ today", monthModels >= todayModels)
+ check("all range projects retained", snap.overviews.allSatisfy { $0.projects.count == $0.spend.sessions })
+ check("range trends match totals", snap.overviews.allSatisfy {
+ $0.trend.reduce(0) { $0 + $1.tokens } == $0.spend.tokens.total
+ && abs($0.trend.reduce(0) { $0 + $1.cost } - $0.spend.cost) < 0.000_001
+ })
let projectDetailsAgree = snap.overviews.flatMap(\.projects).allSatisfy { project in
let modelCost = project.models.reduce(0) { $0 + $1.cost }
let modelTokens = project.models.reduce(0) { $0 + $1.tokens.total }
diff --git a/Sources/CodingBarCore/Aggregator.swift b/Sources/CodingBarCore/Aggregator.swift
index e2d7a55..e713d8f 100644
--- a/Sources/CodingBarCore/Aggregator.swift
+++ b/Sources/CodingBarCore/Aggregator.swift
@@ -34,8 +34,6 @@ public enum Aggregator {
/// network-fetched quota each run. All three overview ranges are precomputed.
public static func run(now: Date = Date(), quota: [QuotaWindow] = [], menuQuotaProvider: Provider? = nil,
language: AppLanguage = .en) -> Snapshot {
- let cal = Calendar.current
-
// One shared Scanner so the on-disk cache is loaded (and saved) once per run,
// not once per provider — the cache decode is one of the heaviest steps in
// boot-time memory because it goes through `JSONSerialization`.
@@ -43,6 +41,24 @@ public enum Aggregator {
// token/cost/behavior aggregation is 100% local
let (claudeRecords, _) = ClaudeScanner.scan(scanner: scanner)
let codexRecords = CodexScanner.scan(scanner: scanner)
+ let forecastInsight = Forecaster.recordAndForecast(quota: quota, now: now, language: language)
+ let quotaForecast = Forecaster.forecastByProvider(quota: quota, now: now, language: language)
+ return aggregate(claudeRecords: claudeRecords, codexRecords: codexRecords, now: now,
+ quota: quota, menuQuotaProvider: menuQuotaProvider, language: language,
+ forecastInsight: forecastInsight, quotaForecast: quotaForecast)
+ }
+
+ /// Accepts parsed records and optional git output so accounting can be checked
+ /// without reading user logs, repositories, or persisted quota history.
+ static func aggregate(claudeRecords: [RawRecord], codexRecords: [RawRecord], now: Date,
+ quota: [QuotaWindow] = [], menuQuotaProvider: Provider? = nil,
+ language: AppLanguage = .en, gitOutput: GitCorrelator.RangeOutputs? = nil,
+ forecastInsight: Insight? = nil, quotaForecast: [String: String] = [:]) -> Snapshot {
+ let cal = Calendar.current
+ // A scan may observe records written after its start. Keep every snapshot
+ // surface on that same cutoff, including the current hour/day's trend bucket.
+ let claudeRecords = claudeRecords.filter { $0.timestamp <= now }
+ let codexRecords = codexRecords.filter { $0.timestamp <= now }
let allRecords = claudeRecords + codexRecords
func recordCost(_ record: RawRecord) -> Double {
Pricing.cost(model: record.model, tokens: record.tokens,
@@ -102,7 +118,7 @@ public enum Aggregator {
if seenCwd.insert(r.cwd).inserted { todayCwds.append(r.cwd) }
}
let scanCwds = todayCwds + monthCwds.prefix(10).filter { !seenCwd.contains($0) }
- let gitRanges = GitCorrelator.buildRanges(cwds: scanCwds, now: now)
+ let gitRanges = gitOutput ?? GitCorrelator.buildRanges(cwds: scanCwds, now: now)
// The trend sparkline follows the range pill: Today buckets by hour (since
// 00:00), 7d/30d by calendar day. Each overview builds its own series so the
@@ -198,8 +214,6 @@ public enum Aggregator {
trend: trendDates.map { entry.buckets[$0] ?? DayPoint(date: $0, cost: 0, tokens: 0) })
}
.sorted { $0.cost > $1.cost }
- .prefix(8)
- .map { $0 }
return (models, projects)
}
@@ -279,10 +293,6 @@ public enum Aggregator {
let (liveSessions, burnPerMin) = FuelCalculator.liveSessions(
claudeRecords: claudeRecords, codexRecords: codexRecords, now: now)
- // Pillar ④ — Forecast (records history, returns coach insight)
- let forecastInsight = Forecaster.recordAndForecast(quota: quota, now: now, language: language)
- let quotaForecast = Forecaster.forecastByProvider(quota: quota, now: now, language: language)
-
// Pillar ④b — Coach tips
var coach: [Insight] = Coach.build(from: todayRecords, language: language)
if let fi = forecastInsight { coach.append(fi) }
diff --git a/Sources/CodingBarCore/CodexScanner.swift b/Sources/CodingBarCore/CodexScanner.swift
index f06ad1b..d0f68ed 100644
--- a/Sources/CodingBarCore/CodexScanner.swift
+++ b/Sources/CodingBarCore/CodexScanner.swift
@@ -82,10 +82,15 @@ public enum CodexScanner {
// token_count delta must be priced/attributed under the model in
// effect at that turn — freezing on the first one mis-prices every
// turn after a switch and skews model composition + fuel classification.
- if let payload = obj["payload"] as? [String: Any],
- let m = payload["model"] as? String, !m.isEmpty {
+ guard let payload = obj["payload"] as? [String: Any] else { return }
+ if let m = payload["model"] as? String, !m.isEmpty {
model = m
}
+ // Handoffs can move a running session to another checkout. The
+ // turn's directory is authoritative for both usage and git attribution.
+ if let c = payload["cwd"] as? String, !c.isEmpty {
+ cwd = c
+ }
case "response_item":
if let payload = obj["payload"] as? [String: Any],
diff --git a/Sources/CodingBarCore/Pricing.swift b/Sources/CodingBarCore/Pricing.swift
index 438ae12..e105736 100644
--- a/Sources/CodingBarCore/Pricing.swift
+++ b/Sources/CodingBarCore/Pricing.swift
@@ -50,13 +50,20 @@ public enum Pricing {
// land on the generic $3/$15 fallback, i.e. 3.3x underpriced.
"anthropic/claude-mythos-5-1": ModelPrice(input: 10, output: 50, cacheRead: 0.25, cacheWrite5m: 12.5, cacheWrite1h: 20),
"anthropic/claude-mythos-5": ModelPrice(input: 10, output: 50, cacheRead: 1, cacheWrite5m: 12.5, cacheWrite1h: 20),
+ "anthropic/claude-sonnet-5-5": ModelPrice(input: 2, output: 10, cacheRead: 0.1, cacheWrite5m: 2.5, cacheWrite1h: 4),
"anthropic/claude-sonnet-5": ModelPrice(input: 2, output: 10, cacheRead: 0.2, cacheWrite5m: 2.5, cacheWrite1h: 4),
"anthropic/claude-sonnet-4-6": ModelPrice(input: 3, output: 15, cacheRead: 0.3, cacheWrite5m: 3.75, cacheWrite1h: 6),
+ // Haiku 5.5 applies the higher tier to the whole request above 100K input
+ // tokens, including cache reads and writes: https://platform.claude.com/docs/en/about-claude/pricing
+ "anthropic/claude-haiku-5-5": ModelPrice(input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite5m: 0.125, cacheWrite1h: 0.2,
+ longContextThreshold: 100_000,
+ longContextInputMultiplier: 5, longContextOutputMultiplier: 5),
"anthropic/claude-haiku-4-5": ModelPrice(input: 1, output: 5, cacheRead: 0.1, cacheWrite5m: 1.25, cacheWrite1h: 2),
// OpenAI standard pay-as-you-go rates. Pro models publish no cached-input
// discount. GPT-6 and GPT-5.6 cache writes use 1.25x input; both TTL fields
// use that one tier. Source: https://developers.openai.com/api/docs/pricing
"openai/gpt-6-astra": openAI(input: 10, cachedInput: 1, output: 50, cacheWrite: 12.5, longContext: true),
+ "openai/gpt-6.1-sol": openAI(input: 2, cachedInput: 0.1, output: 10, cacheWrite: 2.5, longContext: true),
"openai/gpt-6-sol": openAI(input: 2, cachedInput: 0.2, output: 10, cacheWrite: 2.5, longContext: true),
"openai/gpt-6-luna": openAI(input: 0.1, cachedInput: 0.01, output: 0.5, cacheWrite: 0.125,longContext: true),
// Promotional standard rate confirmed through at least 2026-11-21.
@@ -124,9 +131,11 @@ public enum Pricing {
for alias in ["fable-5", "claude-fable-5"] { m[alias] = "anthropic/claude-fable-5" }
for alias in ["mythos-5.1", "claude-mythos-5-1", "mythos"] { m[alias] = "anthropic/claude-mythos-5-1" }
for alias in ["mythos-5", "claude-mythos-5"] { m[alias] = "anthropic/claude-mythos-5" }
- for alias in ["sonnet-5", "claude-sonnet-5", "sonnet"] { m[alias] = "anthropic/claude-sonnet-5" }
+ for alias in ["sonnet-5.5", "claude-sonnet-5-5", "sonnet"] { m[alias] = "anthropic/claude-sonnet-5-5" }
+ for alias in ["sonnet-5", "claude-sonnet-5"] { m[alias] = "anthropic/claude-sonnet-5" }
for alias in ["sonnet-4.6", "claude-sonnet-4-6"] { m[alias] = "anthropic/claude-sonnet-4-6" }
- for alias in ["haiku-4.5", "claude-haiku-4-5", "haiku",
+ for alias in ["haiku-5.5", "claude-haiku-5-5", "haiku"] { m[alias] = "anthropic/claude-haiku-5-5" }
+ for alias in ["haiku-4.5", "claude-haiku-4-5",
"claude-haiku-4-5-20251001"] { m[alias] = "anthropic/claude-haiku-4-5" }
// Every canonical OpenAI row accepts its bare API model ID. Keeping this generated
// from priceTable prevents a model from being priced but unreachable from real logs.
@@ -215,10 +224,13 @@ public enum Pricing {
"anthropic/claude-fable-5": "Fable 5",
"anthropic/claude-mythos-5-1": "Mythos 5.1",
"anthropic/claude-mythos-5": "Mythos 5",
+ "anthropic/claude-sonnet-5-5": "Sonnet 5.5",
"anthropic/claude-sonnet-5": "Sonnet 5",
"anthropic/claude-sonnet-4-6": "Sonnet 4.6",
+ "anthropic/claude-haiku-5-5": "Haiku 5.5",
"anthropic/claude-haiku-4-5": "Haiku 4.5",
"openai/gpt-6-astra": "GPT-6 Astra",
+ "openai/gpt-6.1-sol": "GPT-6.1 Sol",
"openai/gpt-6-sol": "GPT-6 Sol",
"openai/gpt-6-luna": "GPT-6 Luna",
"openai/gpt-5.6-sol": "GPT-5.6 Sol",
diff --git a/Sources/CodingBarCore/Scanner.swift b/Sources/CodingBarCore/Scanner.swift
index 192226e..8f59b8b 100644
--- a/Sources/CodingBarCore/Scanner.swift
+++ b/Sources/CodingBarCore/Scanner.swift
@@ -84,8 +84,9 @@ final class Scanner {
/// records preserve the absolute last-turn input size for long-context pricing. v8:
/// Codex cache-write tokens are split from fresh input instead of being discarded.
/// v9: Codex prefers per-response usage records and no longer counts a prior
- /// session's cumulative counter as the first turn of each rollout file.
- private static let cacheVersion = 9
+ /// session's cumulative counter as the first turn of each rollout file. v10:
+ /// Codex records follow per-turn working-directory changes.
+ private static let cacheVersion = 10
private struct CacheFile: Codable {
var version: Int
diff --git a/Tests/CodingBarCoreTests/SmokeTests.swift b/Tests/CodingBarCoreTests/SmokeTests.swift
index a4b76f6..d1cdb36 100644
--- a/Tests/CodingBarCoreTests/SmokeTests.swift
+++ b/Tests/CodingBarCoreTests/SmokeTests.swift
@@ -740,7 +740,7 @@ final class SmokeTests: XCTestCase {
// The bare selectors Claude Code writes when you pick a family, not a version.
XCTAssertEqual(Pricing.normalize(model: "opus"), "anthropic/claude-opus-5-5")
- XCTAssertEqual(Pricing.normalize(model: "sonnet"), "anthropic/claude-sonnet-5")
+ XCTAssertEqual(Pricing.normalize(model: "sonnet"), "anthropic/claude-sonnet-5-5")
}
/// "Unknown → newest" is only safe while every known tier is enumerated. Opus 4.1 costs
diff --git a/Tests/CodingBarCoreTests/StatisticsRegressionTests.swift b/Tests/CodingBarCoreTests/StatisticsRegressionTests.swift
new file mode 100644
index 0000000..f567a45
--- /dev/null
+++ b/Tests/CodingBarCoreTests/StatisticsRegressionTests.swift
@@ -0,0 +1,140 @@
+import XCTest
+@testable import CodingBarCore
+
+final class StatisticsRegressionTests: XCTestCase {
+ private let now = Calendar.current.date(from: DateComponents(year: 2026, month: 10, day: 11, hour: 12))!
+ private let noGit = GitCorrelator.RangeOutputs(today: .init(), week: .init(), month: .init())
+
+ func testLatestModelPricesAndAliases() {
+ let cases: [(String, String, Double, Double, Double, Double, Double)] = [
+ ("gpt-6.1-sol", "openai/gpt-6.1-sol", 2, 10, 0.1, 2.5, 2.5),
+ ("claude-sonnet-5-5", "anthropic/claude-sonnet-5-5", 2, 10, 0.1, 2.5, 4),
+ ("claude-haiku-5-5", "anthropic/claude-haiku-5-5", 0.1, 0.5, 0.01, 0.125, 0.2),
+ ]
+ for (raw, canonical, input, output, read, write5m, write1h) in cases {
+ XCTAssertEqual(Pricing.normalize(model: raw), canonical)
+ XCTAssertEqual(Pricing.normalize(model: canonical), canonical)
+ XCTAssertTrue(Pricing.priceIsExact(model: raw))
+ XCTAssertEqual(Pricing.cost(model: raw, tokens: .init(input: 10_000), at: now), input / 100, accuracy: 1e-9)
+ XCTAssertEqual(Pricing.cost(model: raw, tokens: .init(output: 10_000), at: now), output / 100, accuracy: 1e-9)
+ XCTAssertEqual(Pricing.cost(model: raw, tokens: .init(cacheRead: 10_000), at: now), read / 100, accuracy: 1e-9)
+ XCTAssertEqual(Pricing.cost(model: raw, tokens: .init(cacheWrite: 10_000), at: now), write5m / 100, accuracy: 1e-9)
+ XCTAssertEqual(Pricing.cost(model: raw, tokens: .init(cacheWrite: 10_000), at: now,
+ cacheWrite1h: 10_000), write1h / 100, accuracy: 1e-9)
+ }
+ for (alias, canonical) in [
+ "sonnet": "anthropic/claude-sonnet-5-5",
+ "sonnet-5.5": "anthropic/claude-sonnet-5-5",
+ "claude-sonnet-5-5-20261001": "anthropic/claude-sonnet-5-5",
+ "haiku": "anthropic/claude-haiku-5-5",
+ "haiku-5.5": "anthropic/claude-haiku-5-5",
+ "claude-haiku-5-5-20261001": "anthropic/claude-haiku-5-5",
+ ] {
+ XCTAssertEqual(Pricing.normalize(model: alias), canonical)
+ }
+ XCTAssertEqual(Pricing.displayName(forCanonicalKey: "openai/gpt-6.1-sol"), "GPT-6.1 Sol")
+ XCTAssertEqual(Pricing.displayName(forCanonicalKey: "anthropic/claude-sonnet-5-5"), "Sonnet 5.5")
+ XCTAssertEqual(Pricing.displayName(forCanonicalKey: "anthropic/claude-haiku-5-5"), "Haiku 5.5")
+ XCTAssertEqual(Pricing.cost(model: "claude-haiku-4-5", tokens: .init(input: 10_000), at: now), 0.01, accuracy: 1e-9)
+ XCTAssertEqual(Pricing.cost(model: "claude-sonnet-5", tokens: .init(cacheRead: 10_000), at: now), 0.002, accuracy: 1e-9)
+ XCTAssertEqual(Pricing.cost(model: "gpt-6-sol", tokens: .init(cacheRead: 10_000), at: now), 0.002, accuracy: 1e-9)
+ }
+
+ func testHaikuPromptBoundaryIncludesCacheReadsAndBothWriteDurations() {
+ var tokens = TokenBreakdown(input: 20_000, output: 2_000, cacheRead: 50_000, cacheWrite: 30_000, reasoning: 1_000)
+ XCTAssertEqual(Pricing.cost(model: "claude-haiku-5-5", tokens: tokens, at: now,
+ cacheWrite1h: 10_000), 0.0085, accuracy: 1e-9)
+ tokens.input += 1
+ XCTAssertEqual(Pricing.cost(model: "claude-haiku-5-5", tokens: tokens, at: now,
+ cacheWrite1h: 10_000), 0.0425005, accuracy: 1e-9)
+ let key = Pricing.normalize(model: "claude-haiku-5-5")
+ XCTAssertEqual(Pricing.inputPrice(forCanonicalKey: key, billingInputTokens: 100_001), 0.5)
+ XCTAssertEqual(Pricing.cacheReadPrice(forCanonicalKey: key, billingInputTokens: 100_001), 0.05)
+ }
+
+ func testGPT61PromptBoundaryUsesAbsolutePromptAndBillsReasoningOnce() {
+ let tokens = TokenBreakdown(input: 20_000, output: 2_000, cacheRead: 50_000, cacheWrite: 30_000, reasoning: 1_000)
+ XCTAssertEqual(Pricing.cost(model: "gpt-6.1-sol", tokens: tokens, at: now,
+ billingInputTokens: 272_000), 0.15, accuracy: 1e-9)
+ XCTAssertEqual(Pricing.cost(model: "gpt-6.1-sol", tokens: tokens, at: now,
+ billingInputTokens: 272_001), 0.285, accuracy: 1e-9)
+ }
+
+ func testAllProjectsRemainAvailableAndDetailsReconcile() {
+ let records = (0..<12).flatMap { project in
+ [0, 3, 20].map { days in
+ record(project: "/CodingBar/tests/project-\(project)",
+ date: Calendar.current.date(byAdding: .day, value: -days, to: now)!,
+ tokens: .init(input: 100 + project, output: 30, cacheRead: 500))
+ }
+ }
+ let snap = Aggregator.aggregate(claudeRecords: [], codexRecords: records, now: now, gitOutput: noGit)
+ XCTAssertEqual(snap.projects.count, 12)
+ for overview in snap.overviews {
+ XCTAssertEqual(overview.projects.count, 12)
+ XCTAssertEqual(overview.projects.reduce(0) { $0 + $1.tokens.total }, overview.spend.tokens.total)
+ XCTAssertEqual(overview.projects.reduce(0) { $0 + $1.cost }, overview.spend.cost, accuracy: 1e-9)
+ for project in overview.projects {
+ XCTAssertEqual(project.models.reduce(0) { $0 + $1.tokens.total }, project.tokens.total)
+ XCTAssertEqual(project.trend.reduce(0) { $0 + $1.tokens }, project.tokens.total)
+ XCTAssertEqual(project.models.reduce(0) { $0 + $1.cost }, project.cost, accuracy: 1e-9)
+ XCTAssertEqual(project.trend.reduce(0) { $0 + $1.cost }, project.cost, accuracy: 1e-9)
+ }
+ }
+ }
+
+ func testSnapshotCutoffIncludesNowAndExcludesLaterRecords() {
+ let cutoff = now.addingTimeInterval(15 * 60)
+ let records = [
+ record(project: "/CodingBar/tests/active", date: cutoff.addingTimeInterval(-1), tokens: .init(input: 100)),
+ record(project: "/CodingBar/tests/active", date: cutoff, tokens: .init(input: 200)),
+ record(project: "/CodingBar/tests/future", date: cutoff.addingTimeInterval(1), tokens: .init(input: 500)),
+ record(project: "/CodingBar/tests/future", date: cutoff.addingTimeInterval(3600), tokens: .init(input: 1000)),
+ ]
+ let snap = Aggregator.aggregate(claudeRecords: [], codexRecords: records, now: cutoff, gitOutput: noGit)
+ XCTAssertEqual(snap.menu.todayTokens, 300)
+ for overview in snap.overviews {
+ XCTAssertEqual(overview.spend.tokens.total, 300)
+ XCTAssertEqual(overview.models.reduce(0) { $0 + $1.tokens.total }, 300)
+ XCTAssertEqual(overview.projects.reduce(0) { $0 + $1.tokens.total }, 300)
+ XCTAssertEqual(overview.trend.reduce(0) { $0 + $1.tokens }, 300)
+ XCTAssertEqual(overview.trend.reduce(0) { $0 + $1.cost }, overview.spend.cost, accuracy: 1e-9)
+ }
+ }
+
+ func testCodexDirectoryChangesApplyToModernAndLegacyUsage() throws {
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent("rollout-\(UUID().uuidString).jsonl")
+ defer { try? FileManager.default.removeItem(at: url) }
+ let rows: [[String: Any]] = [
+ ["type": "session_meta", "payload": ["cwd": "/project-a"]],
+ ["type": "turn_context", "payload": ["cwd": "/project-b"]],
+ legacyUsage(total: 100, last: 100),
+ ["type": "turn_context", "payload": ["cwd": "/project-c", "model": "gpt-6.1-sol"]],
+ legacyUsage(total: 300, last: 200),
+ ["type": "turn_context", "payload": ["cwd": "/project-d", "model": "gpt-6-astra"]],
+ ["type": "token_usage_record", "timestamp": "2026-10-11T02:00:00Z",
+ "payload": ["response_id": "response-1", "usage": ["input_tokens": 300, "output_tokens": 10]]],
+ ["type": "turn_context", "payload": ["cwd": "", "model": "gpt-6.1-sol"]],
+ ["type": "token_usage_record", "timestamp": "2026-10-11T02:01:00Z",
+ "payload": ["response_id": "response-2", "usage": ["input_tokens": 400, "output_tokens": 20]]],
+ ]
+ let lines = try rows.map { String(data: try JSONSerialization.data(withJSONObject: $0), encoding: .utf8)! }
+ try lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
+ let records = CodexScanner.parseFile(url)
+ XCTAssertEqual(records.map(\.cwd), ["/project-b", "/project-c", "/project-d", "/project-d"])
+ XCTAssertEqual(records.map(\.model), ["unknown", "gpt-6.1-sol", "gpt-6-astra", "gpt-6.1-sol"])
+ XCTAssertEqual(records.map(\.tokens.total), [100, 200, 310, 420])
+ }
+
+ private func legacyUsage(total: Int, last: Int) -> [String: Any] {
+ ["type": "event_msg", "timestamp": "2026-10-11T01:00:00Z", "payload": ["type": "token_count", "info": [
+ "total_token_usage": ["input_tokens": total, "output_tokens": 0],
+ "last_token_usage": ["input_tokens": last, "output_tokens": 0]]]]
+ }
+
+ private func record(project: String, date: Date, tokens: TokenBreakdown) -> RawRecord {
+ RawRecord(provider: .codex, model: "gpt-6.1-sol", timestamp: date, cwd: project,
+ tokens: tokens, toolName: nil, toolNames: [], messageId: nil,
+ sessionKey: project, hasInterrupt: false)
+ }
+}
diff --git a/release-notes/v1.2.1.md b/release-notes/v1.2.1.md
new file mode 100644
index 0000000..35f89ba
--- /dev/null
+++ b/release-notes/v1.2.1.md
@@ -0,0 +1,3 @@
+- Correct usage estimates for GPT-6.1 Sol, Sonnet 5.5, and Haiku 5.5, show all projects in the Cost tab, attribute Codex usage to the current working directory, and keep trend totals aligned with the snapshot cutoff.
+
+**Full Changelog**: https://github.com/Gnonymous/CodingBar/compare/v1.2.0...v1.2.1