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