From ba573b613071bef8fcc57c9f0bd0dcd0a07a13d0 Mon Sep 17 00:00:00 2001 From: 1 <2> Date: Mon, 10 Aug 2026 00:33:12 +0800 Subject: [PATCH 1/3] Add opt-in ccusage fallback for incomplete Codex history --- Scripts/lint.sh | 5 + Scripts/package_app.sh | 35 +++ Scripts/test_package_ccusage_helper.sh | 75 +++++ .../CostUsage/CCUsageCodexBridge.swift | 286 ++++++++++++++++++ Sources/CodexBarCore/CostUsageFetcher.swift | 30 +- .../CCUsageCodexBridgeTests.swift | 236 +++++++++++++++ docs/THIRD_PARTY_LICENSES.md | 29 ++ docs/packaging.md | 6 + 8 files changed, 701 insertions(+), 1 deletion(-) create mode 100755 Scripts/test_package_ccusage_helper.sh create mode 100644 Sources/CodexBarCore/CostUsage/CCUsageCodexBridge.swift create mode 100644 Tests/CodexBarTests/CCUsageCodexBridgeTests.swift diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 4fb8dc271c..35cd2d5b2b 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -49,6 +49,10 @@ check_package_signing() { "${ROOT_DIR}/Scripts/test_package_signing.sh" } +check_package_ccusage_helper() { + "${ROOT_DIR}/Scripts/test_package_ccusage_helper.sh" +} + check_package_info_plist() { "${ROOT_DIR}/Scripts/test_package_info_plist.sh" } @@ -110,6 +114,7 @@ run_portable_checks() { check_package_product_paths check_package_strip check_package_signing + check_package_ccusage_helper check_package_info_plist check_release_dsym_paths check_sparkle_signing_paths diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 22781b05f8..9fa5340080 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -379,6 +379,37 @@ verify_binary_arches() { done } +install_optional_ccusage_helper() { + local source="${CODEXBAR_CCUSAGE_SOURCE:-}" + local required="${CODEXBAR_REQUIRE_CCUSAGE:-0}" + local destination="$APP/Contents/Helpers/ccusage" + + case "$required" in + 0|1) ;; + *) + echo "ERROR: CODEXBAR_REQUIRE_CCUSAGE must be 0 or 1" >&2 + return 1 + ;; + esac + + if [[ -z "$source" ]]; then + if [[ "$required" == "1" ]]; then + echo "ERROR: CODEXBAR_REQUIRE_CCUSAGE=1 requires CODEXBAR_CCUSAGE_SOURCE" >&2 + return 1 + fi + return 0 + fi + + source="$(printf '%s' "$source" | sed 's#^~/#'"$HOME"'/#')" + if [[ ! -f "$source" ]]; then + echo "ERROR: CODEXBAR_CCUSAGE_SOURCE is not a file: $source" >&2 + return 1 + fi + verify_binary_arches "$source" "${ARCH_LIST[@]}" + cp "$source" "$destination" + chmod 755 "$destination" +} + install_binary() { local name="$1" local dest="$2" @@ -509,6 +540,7 @@ strip_release_binary "$APP/Contents/Helpers/CodexBarCLI" # Watchdog helper: ensures `claude` probes die when CodexBar crashes/gets killed. install_binary "CodexBarClaudeWatchdog" "$APP/Contents/Helpers/CodexBarClaudeWatchdog" strip_release_binary "$APP/Contents/Helpers/CodexBarClaudeWatchdog" +install_optional_ccusage_helper install_widget_extension strip_release_binary "$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget" @@ -596,6 +628,9 @@ fi if [[ -f "${APP}/Contents/Helpers/CodexBarClaudeWatchdog" ]]; then codesign "${CODESIGN_ARGS[@]}" "${APP}/Contents/Helpers/CodexBarClaudeWatchdog" fi +if [[ -f "${APP}/Contents/Helpers/ccusage" ]]; then + codesign "${CODESIGN_ARGS[@]}" "${APP}/Contents/Helpers/ccusage" +fi # Sign widget extension if present if [[ -d "${APP}/Contents/PlugIns/CodexBarWidget.appex" ]]; then diff --git a/Scripts/test_package_ccusage_helper.sh b/Scripts/test_package_ccusage_helper.sh new file mode 100755 index 0000000000..57f12b78f5 --- /dev/null +++ b/Scripts/test_package_ccusage_helper.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +PACKAGE_SCRIPT="$ROOT/Scripts/package_app.sh" +FUNCTIONS_FILE=$(mktemp "${TMPDIR:-/tmp}/codexbar-package-ccusage-functions.XXXXXX") +trap 'rm -f "$FUNCTIONS_FILE"' EXIT + +python3 - "$PACKAGE_SCRIPT" "$FUNCTIONS_FILE" <<'PY' +import sys +from pathlib import Path + +script = Path(sys.argv[1]).read_text() +functions = [] +for name in ("verify_binary_arches", "install_optional_ccusage_helper"): + start = script.index(f"{name}() {{") + end = script.index("\n}\n", start) + 3 + functions.append(script[start:end]) +Path(sys.argv[2]).write_text("\n\n".join(functions)) +PY + +source "$FUNCTIONS_FILE" + +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-package-ccusage.XXXXXX") +trap 'rm -f "$FUNCTIONS_FILE"; rm -rf "$TEMP_DIR"' EXIT +APP="$TEMP_DIR/CodexBar.app" +SOURCE="$TEMP_DIR/ccusage" +mkdir -p "$APP/Contents/Helpers" +printf '#!/bin/sh\nexit 0\n' >"$SOURCE" +chmod 755 "$SOURCE" + +lipo() { + printf 'arm64 x86_64\n' +} + +ARCH_LIST=(arm64 x86_64) +CODEXBAR_CCUSAGE_SOURCE="$SOURCE" +CODEXBAR_REQUIRE_CCUSAGE=1 +install_optional_ccusage_helper +[[ -x "$APP/Contents/Helpers/ccusage" ]] +cmp -s "$SOURCE" "$APP/Contents/Helpers/ccusage" + +rm -f "$APP/Contents/Helpers/ccusage" +unset CODEXBAR_CCUSAGE_SOURCE +CODEXBAR_REQUIRE_CCUSAGE=0 +install_optional_ccusage_helper +[[ ! -e "$APP/Contents/Helpers/ccusage" ]] + +CODEXBAR_REQUIRE_CCUSAGE=1 +if install_optional_ccusage_helper 2>"$TEMP_DIR/missing-source.log"; then + echo "Missing required ccusage source unexpectedly succeeded" >&2 + exit 1 +fi +grep -Fq "requires CODEXBAR_CCUSAGE_SOURCE" "$TEMP_DIR/missing-source.log" + +CODEXBAR_CCUSAGE_SOURCE="$TEMP_DIR/missing" +CODEXBAR_REQUIRE_CCUSAGE=0 +if install_optional_ccusage_helper 2>"$TEMP_DIR/missing-file.log"; then + echo "Missing ccusage file unexpectedly succeeded" >&2 + exit 1 +fi +grep -Fq "is not a file" "$TEMP_DIR/missing-file.log" + +CODEXBAR_CCUSAGE_SOURCE="$SOURCE" +CODEXBAR_REQUIRE_CCUSAGE=0 +lipo() { + printf 'arm64\n' +} +if (install_optional_ccusage_helper) 2>"$TEMP_DIR/arch.log"; then + echo "Architecture mismatch unexpectedly succeeded" >&2 + exit 1 +fi +grep -Fq "arch mismatch" "$TEMP_DIR/arch.log" + +echo "Package ccusage helper tests passed." diff --git a/Sources/CodexBarCore/CostUsage/CCUsageCodexBridge.swift b/Sources/CodexBarCore/CostUsage/CCUsageCodexBridge.swift new file mode 100644 index 0000000000..c517815bcf --- /dev/null +++ b/Sources/CodexBarCore/CostUsage/CCUsageCodexBridge.swift @@ -0,0 +1,286 @@ +import Foundation + +/// Optional bridge to ccusage for Codex archives whose native cache is still catching up. +/// +/// The bridge is deliberately opt-in: CodexBar never searches PATH or downloads a helper. +/// Packagers may bundle `Contents/Helpers/ccusage`, while local users can point at a vetted +/// executable with `CODEXBAR_CCUSAGE_PATH`. +enum CCUsageCodexBridge { + private static let log = CodexBarLog.logger(LogCategories.tokenCost) + private static let defaultTimeout: TimeInterval = 30 + private static let defaultMaxOutputBytes = 16 * 1024 * 1024 + + private struct Output: Decodable { + let daily: [Day] + } + + private struct Day: Decodable { + let period: String + let agents: [Agent] + } + + private struct Agent: Decodable { + let agent: String + let inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + let totalTokens: Int? + let totalCost: Double? + let modelsUsed: [String]? + let modelBreakdowns: [ModelBreakdown]? + } + + private struct ModelBreakdown: Decodable { + let modelName: String + let inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + let totalTokens: Int? + let cost: Double? + } + + package static func executablePath( + environment: [String: String] = ProcessInfo.processInfo.environment, + bundle: Bundle = .main, + fileManager: FileManager = .default) -> String? + { + var candidates: [String] = [] + if let override = environment["CODEXBAR_CCUSAGE_PATH"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + candidates.append((override as NSString).expandingTildeInPath) + } + candidates.append(bundle.bundleURL.appendingPathComponent("Contents/Helpers/ccusage").path) + if let executableURL = bundle.executableURL { + candidates.append(executableURL.deletingLastPathComponent().appendingPathComponent("ccusage").path) + } + return candidates.first(where: { fileManager.isExecutableFile(atPath: $0) }) + } + + package static func arguments( + since: Date, + until: Date, + calendar: Calendar) -> [String] + { + [ + "daily", + "--json", + "--by-agent", + "--offline", + "--no-color", + "--timezone", calendar.timeZone.identifier, + "--since", self.dayKey(for: since, calendar: calendar), + "--until", self.dayKey(for: until, calendar: calendar), + ] + } + + package static func subprocessEnvironment( + environment: [String: String], + codexHomePath: String?) -> [String: String] + { + var resolved = ProcessInfo.processInfo.environment + resolved.merge(environment) { _, scoped in scoped } + if let codexHome = CodexHomeScope.normalizedHomePath(codexHomePath) { + resolved["CODEX_HOME"] = codexHome + } + return resolved + } + + package static func loadFallbackReportIfNeeded( + nativeReport: CostUsageDailyReport, + historyCoverageIsEstablished: Bool, + since: Date, + until: Date, + calendar: Calendar, + environment: [String: String], + codexHomePath: String?, + timeout: TimeInterval = Self.defaultTimeout, + maxOutputBytes: Int = Self.defaultMaxOutputBytes) async -> CostUsageDailyReport? + { + guard !historyCoverageIsEstablished else { return nil } + guard self.executablePath( + environment: self.subprocessEnvironment(environment: environment, codexHomePath: codexHomePath)) != nil + else { + return nil + } + + do { + let fallback = try await self.loadReport( + since: since, + until: until, + calendar: calendar, + environment: environment, + codexHomePath: codexHomePath, + timeout: timeout, + maxOutputBytes: maxOutputBytes) + guard self.isAtLeastAsComplete(fallback, as: nativeReport) else { + self.log.warning( + "Ignoring ccusage Codex fallback with fewer tokens than native scan", + metadata: ["reason": "lower-token-total"]) + return nil + } + return fallback + } catch { + self.log.warning( + "ccusage Codex fallback failed; keeping native scan", + metadata: ["reason": self.failureReason(for: error)]) + return nil + } + } + + package static func loadReport( + since: Date, + until: Date, + calendar: Calendar, + environment: [String: String], + codexHomePath: String?, + timeout: TimeInterval = Self.defaultTimeout, + maxOutputBytes: Int = Self.defaultMaxOutputBytes) async throws -> CostUsageDailyReport + { + let processEnvironment = self.subprocessEnvironment( + environment: environment, + codexHomePath: codexHomePath) + guard let executable = self.executablePath(environment: processEnvironment) else { + throw SubprocessRunnerError.binaryNotFound("ccusage") + } + + let result = try await SubprocessRunner.run( + binary: executable, + arguments: self.arguments(since: since, until: until, calendar: calendar), + environment: processEnvironment, + timeout: timeout, + maxOutputBytes: maxOutputBytes, + label: "ccusage-codex-daily") + return try self.parseReport(Data(result.stdout.utf8)) + } + + package static func parseReport(_ data: Data) throws -> CostUsageDailyReport { + let output = try JSONDecoder().decode(Output.self, from: data) + let entries = output.daily.compactMap { day -> CostUsageDailyReport.Entry? in + let codexAgents = day.agents.filter { + $0.agent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "codex" + } + guard !codexAgents.isEmpty else { return nil } + + let modelBreakdowns = self.mergeModelBreakdowns(codexAgents.flatMap { $0.modelBreakdowns ?? [] }) + let models = Set(codexAgents.flatMap { $0.modelsUsed ?? [] }) + .union(modelBreakdowns.map(\.modelName)) + .sorted() + return CostUsageDailyReport.Entry( + date: day.period, + inputTokens: self.sum(codexAgents.map(\.inputTokens)), + outputTokens: self.sum(codexAgents.map(\.outputTokens)), + cacheReadTokens: self.sum(codexAgents.map(\.cacheReadTokens)), + cacheCreationTokens: self.sum(codexAgents.map(\.cacheCreationTokens)), + totalTokens: self.sum(codexAgents.map { $0.totalTokens ?? self.componentTotal(for: $0) }), + costUSD: self.sum(codexAgents.map(\.totalCost)), + modelsUsed: models.isEmpty ? nil : models, + modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) + } + .sorted { $0.date < $1.date } + + return CostUsageDailyReport( + data: entries, + summary: CostUsageDailyReport.Summary( + totalInputTokens: self.sum(entries.map(\.inputTokens)), + totalOutputTokens: self.sum(entries.map(\.outputTokens)), + cacheReadTokens: self.sum(entries.map(\.cacheReadTokens)), + cacheCreationTokens: self.sum(entries.map(\.cacheCreationTokens)), + totalTokens: self.sum(entries.map(\.totalTokens)) ?? 0, + totalCostUSD: self.sum(entries.map(\.costUSD)) ?? 0)) + } + + private static func mergeModelBreakdowns( + _ rows: [ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown] + { + struct Accumulator { + var tokens: [Int?] = [] + var costs: [Double?] = [] + } + + var models: [String: Accumulator] = [:] + for row in rows { + let componentTotal = self.sum([ + row.inputTokens, + row.outputTokens, + row.cacheReadTokens, + row.cacheCreationTokens, + ]) + models[row.modelName, default: Accumulator()].tokens.append(row.totalTokens ?? componentTotal) + models[row.modelName, default: Accumulator()].costs.append(row.cost) + } + return models.keys.sorted().map { modelName in + let accumulator = models[modelName] ?? Accumulator() + return CostUsageDailyReport.ModelBreakdown( + modelName: modelName, + costUSD: self.sum(accumulator.costs), + totalTokens: self.sum(accumulator.tokens)) + } + } + + private static func isAtLeastAsComplete( + _ fallback: CostUsageDailyReport, + as native: CostUsageDailyReport) -> Bool + { + let fallbackTokens = fallback.summary?.totalTokens ?? self.totalTokens(in: fallback) + let nativeTokens = native.summary?.totalTokens ?? self.totalTokens(in: native) + return fallbackTokens >= nativeTokens + } + + private static func totalTokens(in report: CostUsageDailyReport) -> Int { + report.data.reduce(0) { partial, entry in + partial + (entry.totalTokens ?? 0) + } + } + + private static func componentTotal(for agent: Agent) -> Int? { + self.sum([ + agent.inputTokens, + agent.outputTokens, + agent.cacheReadTokens, + agent.cacheCreationTokens, + ]) + } + + private static func dayKey(for date: Date, calendar: Calendar) -> String { + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = calendar.timeZone + let components = gregorian.dateComponents([.year, .month, .day], from: date) + return String( + format: "%04d-%02d-%02d", + locale: Locale(identifier: "en_US_POSIX"), + components.year ?? 0, + components.month ?? 0, + components.day ?? 0) + } + + private static func sum(_ values: [Int?]) -> Int? { + let values = values.compactMap(\.self) + guard !values.isEmpty else { return nil } + return values.reduce(0) { partial, value in + let addition = partial.addingReportingOverflow(max(0, value)) + return addition.overflow ? Int.max : addition.partialValue + } + } + + private static func sum(_ values: [Double?]) -> Double? { + let values = values.compactMap(\.self) + guard !values.isEmpty else { return nil } + return values.reduce(0, +) + } + + private static func failureReason(for error: Error) -> String { + if error is DecodingError { + return "invalid-json" + } + switch error { + case is SubprocessRunnerError: + return "subprocess-failed" + default: + return "unexpected-error" + } + } +} diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index f459c144ff..048c2d2a70 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -433,12 +433,35 @@ public struct CostUsageFetcher: Sendable { shouldMergePiUsage: shouldMergePiUsage, scanOptions: scanOptions, piOptions: piOptions) - let scanResult = try await Self.loadLocalTokenScanResult( + var scanResult = try await Self.loadLocalTokenScanResult( provider: provider, since: since, now: now, options: localScanOptions) + if provider == .codex, + let nativeCodexDaily = scanResult.nativeCodexDaily, + let fallback = await CCUsageCodexBridge.loadFallbackReportIfNeeded( + nativeReport: nativeCodexDaily, + historyCoverageIsEstablished: scanResult.historyCoverageIsEstablished, + since: since, + until: now, + calendar: scanOptions.calendar, + environment: environment, + codexHomePath: codexHomePath) + { + let effectiveDaily = CostUsageDailyReport.merged( + [fallback, scanResult.piDaily].compactMap(\.self)) + scanResult = LocalTokenScanResult( + daily: effectiveDaily, + nativeCodexDaily: fallback, + piDaily: scanResult.piDaily, + projects: Self.unknownProjectBreakdown(from: effectiveDaily).map { [$0] } ?? [], + sessions: [], + staleSnapshotUpdatedAt: nil, + historyCoverageIsEstablished: true) + } + if allowPricingRefresh, retryUnknownPricing, let request = Self.unknownPricingRefreshRequest( @@ -480,6 +503,8 @@ public struct CostUsageFetcher: Sendable { private struct LocalTokenScanResult: Sendable { let daily: CostUsageDailyReport + let nativeCodexDaily: CostUsageDailyReport? + let piDaily: CostUsageDailyReport? let projects: [CostUsageProjectBreakdown] let sessions: [CostUsageSessionBreakdown] let staleSnapshotUpdatedAt: Date? @@ -563,6 +588,7 @@ public struct CostUsageFetcher: Sendable { sessionRoots: roots) } } + let nativeCodexDaily = provider == .codex ? daily : nil if options.includePiSessions, provider == .claude || (provider == .codex && options.shouldMergePiUsage) { @@ -588,6 +614,8 @@ public struct CostUsageFetcher: Sendable { } return LocalTokenScanResult( daily: daily, + nativeCodexDaily: nativeCodexDaily, + piDaily: piDaily, projects: projects, sessions: sessions, staleSnapshotUpdatedAt: staleSnapshotUpdatedAt, diff --git a/Tests/CodexBarTests/CCUsageCodexBridgeTests.swift b/Tests/CodexBarTests/CCUsageCodexBridgeTests.swift new file mode 100644 index 0000000000..833f478370 --- /dev/null +++ b/Tests/CodexBarTests/CCUsageCodexBridgeTests.swift @@ -0,0 +1,236 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CCUsageCodexBridgeTests { + @Test + func `parses only Codex agent rows and preserves token categories`() throws { + let json = #""" + { + "daily": [ + { + "period": "2026-08-09", + "agents": [ + { + "agent": "codex", + "inputTokens": 120, + "outputTokens": 30, + "cacheReadTokens": 800, + "cacheCreationTokens": 5, + "totalTokens": 955, + "totalCost": 1.25, + "modelsUsed": ["gpt-5.6-sol"], + "modelBreakdowns": [ + { + "modelName": "gpt-5.6-sol", + "inputTokens": 120, + "outputTokens": 30, + "cacheReadTokens": 800, + "cacheCreationTokens": 5, + "cost": 1.25 + } + ] + }, + { + "agent": "claude", + "inputTokens": 999, + "outputTokens": 999, + "totalTokens": 1998, + "totalCost": 9.99 + } + ] + } + ], + "totals": { "totalTokens": 2953, "totalCost": 11.24 } + } + """# + + let report = try CCUsageCodexBridge.parseReport(Data(json.utf8)) + + #expect(report.data.count == 1) + #expect(report.data[0].date == "2026-08-09") + #expect(report.data[0].inputTokens == 120) + #expect(report.data[0].outputTokens == 30) + #expect(report.data[0].cacheReadTokens == 800) + #expect(report.data[0].cacheCreationTokens == 5) + #expect(report.data[0].totalTokens == 955) + #expect(report.data[0].costUSD == 1.25) + #expect(report.data[0].modelBreakdowns?.first?.totalTokens == 955) + #expect(report.summary?.totalTokens == 955) + #expect(report.summary?.totalCostUSD == 1.25) + } + + @Test + func `builds scoped offline arguments and environment`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) + let since = Date(timeIntervalSince1970: 1_786_205_600) + let until = Date(timeIntervalSince1970: 1_786_292_000) + + let arguments = CCUsageCodexBridge.arguments(since: since, until: until, calendar: calendar) + let environment = CCUsageCodexBridge.subprocessEnvironment( + environment: ["TEST_SCOPE": "yes"], + codexHomePath: "/tmp/codex-profile") + + #expect(arguments == [ + "daily", "--json", "--by-agent", "--offline", "--no-color", + "--timezone", "Asia/Shanghai", "--since", "2026-08-09", "--until", "2026-08-10", + ]) + #expect(environment["TEST_SCOPE"] == "yes") + #expect(environment["CODEX_HOME"] == "/tmp/codex-profile") + } + + @Test + func `successful fallback uses the selected Codex home`() async throws { + let fixture = try Self.fixture() + defer { fixture.cleanup() } + let helper = try fixture.writeHelper(""" + case "$CODEX_HOME" in */codex-home) ;; *) exit 7 ;; esac + printf '%s\\n' '{"daily":[{"period":"2026-08-09","agents":[{"agent":"codex","inputTokens":120,"outputTokens":30,"cacheReadTokens":800,"cacheCreationTokens":5,"totalTokens":955,"totalCost":1.25,"modelsUsed":["gpt-5.6-sol"],"modelBreakdowns":[{"modelName":"gpt-5.6-sol","totalTokens":955,"cost":1.25}]}]}]}' + """) + + let report = await CCUsageCodexBridge.loadFallbackReportIfNeeded( + nativeReport: Self.report(tokens: 10), + historyCoverageIsEstablished: false, + since: fixture.since, + until: fixture.until, + calendar: fixture.calendar, + environment: ["CODEXBAR_CCUSAGE_PATH": helper.path], + codexHomePath: fixture.codexHome.path) + + #expect(report?.summary?.totalTokens == 955) + #expect(report?.summary?.totalCostUSD == 1.25) + } + + @Test + func `does not invoke helper when native coverage is established`() async throws { + let fixture = try Self.fixture() + defer { fixture.cleanup() } + let marker = fixture.root.appendingPathComponent("invoked") + let helper = try fixture.writeHelper(""" + touch "$CCUSAGE_TEST_MARKER" + exit 1 + """) + + let report = await CCUsageCodexBridge.loadFallbackReportIfNeeded( + nativeReport: Self.report(tokens: 10), + historyCoverageIsEstablished: true, + since: fixture.since, + until: fixture.until, + calendar: fixture.calendar, + environment: [ + "CODEXBAR_CCUSAGE_PATH": helper.path, + "CCUSAGE_TEST_MARKER": marker.path, + ], + codexHomePath: fixture.codexHome.path) + + #expect(report == nil) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + + @Test + func `failed helper executions retain native result`() async throws { + let cases = [ + ("exit 9", "subprocess failure"), + ("printf '%s\\n' 'not json'", "invalid json"), + ("sleep 1", "timeout"), + ] + + for (body, _) in cases { + let fixture = try Self.fixture() + let helper = try fixture.writeHelper(body) + let report = await CCUsageCodexBridge.loadFallbackReportIfNeeded( + nativeReport: Self.report(tokens: 10), + historyCoverageIsEstablished: false, + since: fixture.since, + until: fixture.until, + calendar: fixture.calendar, + environment: ["CODEXBAR_CCUSAGE_PATH": helper.path], + codexHomePath: fixture.codexHome.path, + timeout: body == "sleep 1" ? 0.05 : 30) + #expect(report == nil) + fixture.cleanup() + } + } + + @Test + func `missing helper and lower token fallback are rejected`() async throws { + let fixture = try Self.fixture() + defer { fixture.cleanup() } + let missing = fixture.root.appendingPathComponent("missing-ccusage") + let missingReport = await CCUsageCodexBridge.loadFallbackReportIfNeeded( + nativeReport: Self.report(tokens: 10), + historyCoverageIsEstablished: false, + since: fixture.since, + until: fixture.until, + calendar: fixture.calendar, + environment: ["CODEXBAR_CCUSAGE_PATH": missing.path], + codexHomePath: fixture.codexHome.path) + #expect(missingReport == nil) + + let lowerHelper = try fixture + .writeHelper( + "printf '%s\\n' '{\"daily\":[{\"period\":\"2026-08-09\",\"agents\":[{\"agent\":\"codex\",\"totalTokens\":1,\"totalCost\":0.01}]}]}'") + let lowerReport = await CCUsageCodexBridge.loadFallbackReportIfNeeded( + nativeReport: Self.report(tokens: 10), + historyCoverageIsEstablished: false, + since: fixture.since, + until: fixture.until, + calendar: fixture.calendar, + environment: ["CODEXBAR_CCUSAGE_PATH": lowerHelper.path], + codexHomePath: fixture.codexHome.path) + #expect(lowerReport == nil) + } + + private static func report(tokens: Int) -> CostUsageDailyReport { + CostUsageDailyReport( + data: [CostUsageDailyReport.Entry( + date: "2026-08-09", + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: 0.1, + modelsUsed: nil, + modelBreakdowns: nil)], + summary: CostUsageDailyReport.Summary( + totalInputTokens: nil, + totalOutputTokens: nil, + totalTokens: tokens, + totalCostUSD: 0.1)) + } + + private struct Fixture { + let root: URL + let codexHome: URL + let since: Date + let until: Date + let calendar: Calendar + + func writeHelper(_ body: String) throws -> URL { + let url = self.root.appendingPathComponent("ccusage-\(UUID().uuidString)") + try ("#!/bin/sh\nset -eu\n\(body)\n").write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } + + func cleanup() { + try? FileManager.default.removeItem(at: self.root) + } + } + + private static func fixture() throws -> Fixture { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-ccusage-\(UUID().uuidString)", isDirectory: true) + let codexHome = root.appendingPathComponent("codex-home", isDirectory: true) + try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + return Fixture( + root: root, + codexHome: codexHome, + since: Date(timeIntervalSince1970: 1_786_205_600), + until: Date(timeIntervalSince1970: 1_786_292_000), + calendar: calendar) + } +} diff --git a/docs/THIRD_PARTY_LICENSES.md b/docs/THIRD_PARTY_LICENSES.md index 057d24e89c..cafa5ec813 100644 --- a/docs/THIRD_PARTY_LICENSES.md +++ b/docs/THIRD_PARTY_LICENSES.md @@ -28,3 +28,32 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## ccusage + +CodexBar optionally accepts a separately supplied [ccusage](https://github.com/ccusage/ccusage) executable for the +Codex history fallback. The helper is not downloaded by CodexBar and is not committed to this repository. Packagers +who bundle one must retain its upstream version and provenance and provide the source through +`CODEXBAR_CCUSAGE_SOURCE`; the package script verifies that all requested app architectures are present. + +MIT License + +Copyright (c) 2025 ryoppippi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/packaging.md b/docs/packaging.md index f2f1f7598a..1e15a71e8f 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -14,6 +14,12 @@ read_when: - `Scripts/make_appcast.sh`: wrapper around the shared `mac-release make-appcast` helper; app metadata comes from `.mac-release.env`. - `Scripts/changelog-to-html.sh`: converts the per-version changelog section to HTML for Sparkle. +## Optional ccusage fallback + +CodexBar can use a locally supplied `ccusage` executable only when the native Codex history scan reports incomplete coverage. It never downloads the helper or searches `PATH`. + +For a local run, set `CODEXBAR_CCUSAGE_PATH` to a vetted executable. For a packaged app, set `CODEXBAR_CCUSAGE_SOURCE` while running `Scripts/package_app.sh`; the helper must contain every architecture in `ARCHES` and is copied to `Contents/Helpers/ccusage`. Set `CODEXBAR_REQUIRE_CCUSAGE=1` to make packaging fail when the source is missing. The fallback is best-effort: missing helpers, timeouts, invalid JSON, and non-zero exits retain the native result. + ## Bundle contents - `CodexBarWidget.appex` is built by `WidgetExtension/CodexBarWidgetExtension.xcodeproj` as a real macOS app extension, then bundled with app-group entitlements. - `CodexBarCLI` copied to `CodexBar.app/Contents/Helpers/` for symlinking. From dbcb0035edc6019d8941d34e8d229c68a458676d Mon Sep 17 00:00:00 2001 From: 1 <2> Date: Mon, 10 Aug 2026 09:28:27 +0800 Subject: [PATCH 2/3] Fix fallback coverage and verify ccusage provenance --- Scripts/package_app.sh | 23 ++++++++++ Scripts/test_package_ccusage_helper.sh | 16 ++++++- .../CodexBar/SpendDashboardController.swift | 1 + Sources/CodexBar/SpendDashboardModel.swift | 4 +- Sources/CodexBarCLI/CLICostCommand.swift | 4 ++ Sources/CodexBarCore/CostUsageFetcher.swift | 10 ++++- Sources/CodexBarCore/CostUsageModels.swift | 4 ++ Tests/CodexBarTests/CLICostTests.swift | 16 +++++++ .../CodexBarTests/CostUsageFetcherTests.swift | 44 +++++++++++++++++++ .../SpendDashboardModelTests.swift | 25 +++++++++++ docs/THIRD_PARTY_LICENSES.md | 5 ++- docs/cli.md | 1 + docs/packaging.md | 2 +- 13 files changed, 148 insertions(+), 7 deletions(-) diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 9fa5340080..986e05a3b2 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -382,7 +382,10 @@ verify_binary_arches() { install_optional_ccusage_helper() { local source="${CODEXBAR_CCUSAGE_SOURCE:-}" local required="${CODEXBAR_REQUIRE_CCUSAGE:-0}" + local version="${CODEXBAR_CCUSAGE_VERSION:-}" + local expected_sha256="${CODEXBAR_CCUSAGE_SHA256:-}" local destination="$APP/Contents/Helpers/ccusage" + local provenance="$destination.provenance" case "$required" in 0|1) ;; @@ -397,17 +400,37 @@ install_optional_ccusage_helper() { echo "ERROR: CODEXBAR_REQUIRE_CCUSAGE=1 requires CODEXBAR_CCUSAGE_SOURCE" >&2 return 1 fi + rm -f "$destination" "$provenance" return 0 fi + if [[ -z "$version" || -z "$expected_sha256" ]]; then + echo "ERROR: CODEXBAR_CCUSAGE_SOURCE requires CODEXBAR_CCUSAGE_VERSION and CODEXBAR_CCUSAGE_SHA256" >&2 + return 1 + fi + if [[ ! "$expected_sha256" =~ ^[[:xdigit:]]{64}$ ]]; then + echo "ERROR: CODEXBAR_CCUSAGE_SHA256 must be a 64-character hexadecimal SHA-256 digest" >&2 + return 1 + fi + source="$(printf '%s' "$source" | sed 's#^~/#'"$HOME"'/#')" if [[ ! -f "$source" ]]; then echo "ERROR: CODEXBAR_CCUSAGE_SOURCE is not a file: $source" >&2 return 1 fi verify_binary_arches "$source" "${ARCH_LIST[@]}" + local actual_sha256 + actual_sha256=$(shasum -a 256 "$source" | awk '{print $1}') + local normalized_actual normalized_expected + normalized_actual=$(printf '%s' "$actual_sha256" | tr '[:upper:]' '[:lower:]') + normalized_expected=$(printf '%s' "$expected_sha256" | tr '[:upper:]' '[:lower:]') + if [[ "$normalized_actual" != "$normalized_expected" ]]; then + echo "ERROR: ccusage SHA-256 mismatch (expected: $expected_sha256, actual: $actual_sha256)" >&2 + return 1 + fi cp "$source" "$destination" chmod 755 "$destination" + printf 'version=%s\nsha256=%s\n' "$version" "$normalized_actual" >"$provenance" } install_binary() { diff --git a/Scripts/test_package_ccusage_helper.sh b/Scripts/test_package_ccusage_helper.sh index 57f12b78f5..f0a44c12f0 100755 --- a/Scripts/test_package_ccusage_helper.sh +++ b/Scripts/test_package_ccusage_helper.sh @@ -36,15 +36,20 @@ lipo() { ARCH_LIST=(arm64 x86_64) CODEXBAR_CCUSAGE_SOURCE="$SOURCE" CODEXBAR_REQUIRE_CCUSAGE=1 +CODEXBAR_CCUSAGE_VERSION="20.0.19" +CODEXBAR_CCUSAGE_SHA256="$(shasum -a 256 "$SOURCE" | awk '{print $1}')" install_optional_ccusage_helper [[ -x "$APP/Contents/Helpers/ccusage" ]] cmp -s "$SOURCE" "$APP/Contents/Helpers/ccusage" +grep -Fq "version=20.0.19" "$APP/Contents/Helpers/ccusage.provenance" +grep -Fq "sha256=$CODEXBAR_CCUSAGE_SHA256" "$APP/Contents/Helpers/ccusage.provenance" -rm -f "$APP/Contents/Helpers/ccusage" +rm -f "$APP/Contents/Helpers/ccusage" "$APP/Contents/Helpers/ccusage.provenance" unset CODEXBAR_CCUSAGE_SOURCE CODEXBAR_REQUIRE_CCUSAGE=0 install_optional_ccusage_helper [[ ! -e "$APP/Contents/Helpers/ccusage" ]] +[[ ! -e "$APP/Contents/Helpers/ccusage.provenance" ]] CODEXBAR_REQUIRE_CCUSAGE=1 if install_optional_ccusage_helper 2>"$TEMP_DIR/missing-source.log"; then @@ -61,8 +66,17 @@ if install_optional_ccusage_helper 2>"$TEMP_DIR/missing-file.log"; then fi grep -Fq "is not a file" "$TEMP_DIR/missing-file.log" +CODEXBAR_CCUSAGE_SOURCE="$SOURCE" +CODEXBAR_CCUSAGE_SHA256="$(printf '0%.0s' {1..64})" +if install_optional_ccusage_helper 2>"$TEMP_DIR/hash.log"; then + echo "SHA-256 mismatch unexpectedly succeeded" >&2 + exit 1 +fi +grep -Fq "SHA-256 mismatch" "$TEMP_DIR/hash.log" + CODEXBAR_CCUSAGE_SOURCE="$SOURCE" CODEXBAR_REQUIRE_CCUSAGE=0 +CODEXBAR_CCUSAGE_SHA256="$(shasum -a 256 "$SOURCE" | awk '{print $1}')" lipo() { printf 'arm64\n' } diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 606b989435..bfef47d902 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -483,6 +483,7 @@ enum SpendDashboardSource { encoder.append(snapshot.currencyCode) encoder.append(snapshot.historyDays) encoder.append(snapshot.historyCoverageIsEstablished) + encoder.append(snapshot.historyFallbackCoverageIsEstablished) encoder.append(snapshot.updatedAt.timeIntervalSinceReferenceDate) encoder.append(snapshot.last30DaysTokens) encoder.append(snapshot.last30DaysCostUSD) diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 1ec6fde68e..3faf5d42e3 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -568,7 +568,9 @@ struct SpendDashboardModel: Equatable, Sendable { bounds: ClosedRange, displayCalendar: Calendar) -> ClosedRange? { - guard input.snapshot.historyCoverageIsEstablished else { return nil } + guard input.snapshot.historyCoverageIsEstablished + || input.snapshot.historyFallbackCoverageIsEstablished + else { return nil } let sourceCoverage = Self.sourceCoverageInterval(input: input, displayCalendar: displayCalendar) let overlapStart = max(bounds.lowerBound, sourceCoverage.lowerBound) let overlapEnd = min(bounds.upperBound, sourceCoverage.upperBound) diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 9faa15b057..e549f5d7b0 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -256,6 +256,7 @@ extension CodexBarCLI { sessionCostUSD: snapshot?.sessionCostUSD, historyDays: snapshot?.historyDays, historyCoverageIsEstablished: snapshot?.historyCoverageIsEstablished, + historyFallbackCoverageIsEstablished: snapshot?.historyFallbackCoverageIsEstablished, last30DaysTokens: snapshot?.last30DaysTokens, last30DaysCostUSD: snapshot?.last30DaysCostUSD, meteredCostUSD: snapshot?.meteredCostUSD, @@ -490,6 +491,7 @@ struct CostPayload: Encodable, Sendable { let sessionCostUSD: Double? let historyDays: Int? let historyCoverageIsEstablished: Bool? + let historyFallbackCoverageIsEstablished: Bool? let last30DaysTokens: Int? let last30DaysCostUSD: Double? let meteredCostUSD: Double? @@ -507,6 +509,7 @@ struct CostPayload: Encodable, Sendable { sessionCostUSD: Double?, historyDays: Int?, historyCoverageIsEstablished: Bool? = nil, + historyFallbackCoverageIsEstablished: Bool? = nil, last30DaysTokens: Int?, last30DaysCostUSD: Double?, meteredCostUSD: Double? = nil, @@ -523,6 +526,7 @@ struct CostPayload: Encodable, Sendable { self.sessionCostUSD = sessionCostUSD self.historyDays = historyDays self.historyCoverageIsEstablished = historyCoverageIsEstablished + self.historyFallbackCoverageIsEstablished = historyFallbackCoverageIsEstablished self.last30DaysTokens = last30DaysTokens self.last30DaysCostUSD = last30DaysCostUSD self.meteredCostUSD = meteredCostUSD diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 048c2d2a70..a47c51014e 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -459,7 +459,8 @@ public struct CostUsageFetcher: Sendable { projects: Self.unknownProjectBreakdown(from: effectiveDaily).map { [$0] } ?? [], sessions: [], staleSnapshotUpdatedAt: nil, - historyCoverageIsEstablished: true) + historyCoverageIsEstablished: scanResult.historyCoverageIsEstablished, + historyFallbackCoverageIsEstablished: true) } if allowPricingRefresh, @@ -496,6 +497,7 @@ public struct CostUsageFetcher: Sendable { historyDays: clampedHistoryDays, calendar: scanOptions.calendar, historyCoverageIsEstablished: scanResult.historyCoverageIsEstablished, + historyFallbackCoverageIsEstablished: scanResult.historyFallbackCoverageIsEstablished, projects: scanResult.projects, sessions: scanResult.sessions, updatedAt: scanResult.staleSnapshotUpdatedAt) @@ -509,6 +511,7 @@ public struct CostUsageFetcher: Sendable { let sessions: [CostUsageSessionBreakdown] let staleSnapshotUpdatedAt: Date? let historyCoverageIsEstablished: Bool + let historyFallbackCoverageIsEstablished: Bool } private struct LocalTokenScanOptions: Sendable { @@ -620,7 +623,8 @@ public struct CostUsageFetcher: Sendable { sessions: sessions, staleSnapshotUpdatedAt: staleSnapshotUpdatedAt, historyCoverageIsEstablished: provider != .codex - || Self.codexHistoryCoverageIsEstablished(options: options.scanOptions)) + || Self.codexHistoryCoverageIsEstablished(options: options.scanOptions), + historyFallbackCoverageIsEstablished: false) } } @@ -1066,6 +1070,7 @@ public struct CostUsageFetcher: Sendable { useCurrentLocalDayForSession: Bool = true, calendar: Calendar = .current, historyCoverageIsEstablished: Bool = true, + historyFallbackCoverageIsEstablished: Bool = false, meteredCostUSD: Double? = nil, credentialScopeFingerprint: String? = nil, historyLabel: String? = nil, @@ -1106,6 +1111,7 @@ public struct CostUsageFetcher: Sendable { last30DaysCostUSD: last30DaysCostUSD, historyDays: historyDays, historyCoverageIsEstablished: historyCoverageIsEstablished, + historyFallbackCoverageIsEstablished: historyFallbackCoverageIsEstablished, historyLabel: historyLabel, meteredCostUSD: meteredCostUSD, credentialScopeFingerprint: credentialScopeFingerprint, diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 322f7d59f5..24f39d7d1f 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -88,6 +88,8 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { public let currencyCode: String public let historyDays: Int public let historyCoverageIsEstablished: Bool + /// True when a separate fallback source covers the requested history while the native scan is pending. + public let historyFallbackCoverageIsEstablished: Bool public let historyLabel: String? /// Provider-metered spend over the same window as `last30DaysCostUSD` — what the plan /// actually deducts, as opposed to the API-rate estimate. Only some providers (e.g. Cursor) @@ -111,6 +113,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { currencyCode: String = "USD", historyDays: Int = 30, historyCoverageIsEstablished: Bool = true, + historyFallbackCoverageIsEstablished: Bool = false, historyLabel: String? = nil, meteredCostUSD: Double? = nil, credentialScopeFingerprint: String? = nil, @@ -129,6 +132,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.currencyCode = normalizedCurrencyCode.isEmpty ? "XXX" : normalizedCurrencyCode self.historyDays = historyDays self.historyCoverageIsEstablished = historyCoverageIsEstablished + self.historyFallbackCoverageIsEstablished = historyFallbackCoverageIsEstablished self.historyLabel = historyLabel self.meteredCostUSD = meteredCostUSD self.credentialScopeFingerprint = credentialScopeFingerprint diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index f2c2c0370e..2ddb6fd979 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -185,7 +185,23 @@ struct CLICostTests { #expect(object.keys.contains("historyCoverageIsEstablished")) #expect(object["historyCoverageIsEstablished"] as? Bool == coverage) + #expect(object["historyFallbackCoverageIsEstablished"] as? Bool == false) } + + let fallbackSnapshot = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 0.01, + last30DaysTokens: 40, + last30DaysCostUSD: 0.04, + historyCoverageIsEstablished: false, + historyFallbackCoverageIsEstablished: true, + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + let fallbackPayload = CodexBarCLI.makeCostPayload(provider: .codex, snapshot: fallbackSnapshot, error: nil) + let fallbackData = try JSONEncoder().encode(fallbackPayload) + let fallbackObject = try #require(JSONSerialization.jsonObject(with: fallbackData) as? [String: Any]) + #expect(fallbackObject["historyCoverageIsEstablished"] as? Bool == false) + #expect(fallbackObject["historyFallbackCoverageIsEstablished"] as? Bool == true) } @Test diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 014fceaf06..b0a09f9048 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -97,6 +97,50 @@ extension CostUsageFetcherTests { #expect(covered.historyCoverageIsEstablished) } + @Test + func `ccusage fallback does not claim native history is complete`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "incomplete.jsonl", + tokens: 42) + + let helper = env.root.appendingPathComponent("ccusage") + try #""" + #!/bin/sh + set -eu + printf '%s\n' '{"daily":[{"period":"2026-04-08","agents":[{"agent":"codex","inputTokens":120,"outputTokens":30,"totalTokens":955,"totalCost":1.25}]}]}' + """#.write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + maxCodexSessionFileBytes: 1, + maxCodexScanBytesPerRefresh: 1) + options.refreshMinIntervalSeconds = 0 + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + environment: ["CODEXBAR_CCUSAGE_PATH": helper.path], + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + + #expect(snapshot.historyCoverageIsEstablished == false) + #expect(snapshot.historyFallbackCoverageIsEstablished) + #expect(snapshot.last30DaysTokens == 955) + #expect(snapshot.last30DaysCostUSD == 1.25) + } + @Test func `fetcher refreshes codex cache when legacy roots metadata is missing`() async throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 929239016e..a36262ab1d 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -293,6 +293,31 @@ struct SpendDashboardModelTests { #expect(spendDashboardModelHistoryPresentation(group) == .unavailable) } + @Test + func `fallback coverage keeps dashboard totals usable while native scan is pending`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1, + last30DaysTokens: 10, + last30DaysCostUSD: 1, + currencyCode: "USD", + historyDays: 7, + historyCoverageIsEstablished: false, + historyFallbackCoverageIsEstablished: true, + daily: [Self.entry(day: "2026-07-16", cost: 1)], + updatedAt: Self.now) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .codex, displayName: "Codex", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.coveredDayCount == 7) + #expect(group.totalTokens == 10) + #expect(group.totalCost == 1) + #expect(group.modelHistoryCompleteness == .complete) + } + @Test func `uncovered source affects only its own currency model history`() throws { let covered = Self.input(id: "covered", provider: .claude, currency: "USD", cost: 4) diff --git a/docs/THIRD_PARTY_LICENSES.md b/docs/THIRD_PARTY_LICENSES.md index cafa5ec813..79775ea7c8 100644 --- a/docs/THIRD_PARTY_LICENSES.md +++ b/docs/THIRD_PARTY_LICENSES.md @@ -33,8 +33,9 @@ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR CodexBar optionally accepts a separately supplied [ccusage](https://github.com/ccusage/ccusage) executable for the Codex history fallback. The helper is not downloaded by CodexBar and is not committed to this repository. Packagers -who bundle one must retain its upstream version and provenance and provide the source through -`CODEXBAR_CCUSAGE_SOURCE`; the package script verifies that all requested app architectures are present. +who bundle one must provide `CODEXBAR_CCUSAGE_VERSION` and `CODEXBAR_CCUSAGE_SHA256` with the source through +`CODEXBAR_CCUSAGE_SOURCE`; the package script verifies the digest and all requested app architectures, then records +the verified provenance beside the helper. MIT License diff --git a/docs/cli.md b/docs/cli.md index a6b7a1f064..8b7c1e858d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -193,6 +193,7 @@ payloads include the visible account label in `account`. - `sessionTokens`, `sessionCostUSD` - `last30DaysTokens`, `last30DaysCostUSD` - `historyCoverageIsEstablished`: `false` while a bounded Codex scan still has catch-up work pending; `true` once the requested history is covered. +- `historyFallbackCoverageIsEstablished`: `true` when an opt-in fallback such as `ccusage` covers the requested history while the native Codex scan is still pending. This does not change the native coverage flag. - Cursor only: `meteredCostUSD` — what Cursor's plan actually deducts over the window, alongside the API-rate estimate in `last30DaysCostUSD`. - `daily[]`: `date`, `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost`, `modelsUsed`, `modelBreakdowns[]` (`modelName`, `cost`) - Codex only: `projects[]`: `name`, `path`, `totalTokens`, `totalCost`, `daily[]`, `modelBreakdowns[]`, `sources[]` diff --git a/docs/packaging.md b/docs/packaging.md index 1e15a71e8f..cb4c91f5de 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -18,7 +18,7 @@ read_when: CodexBar can use a locally supplied `ccusage` executable only when the native Codex history scan reports incomplete coverage. It never downloads the helper or searches `PATH`. -For a local run, set `CODEXBAR_CCUSAGE_PATH` to a vetted executable. For a packaged app, set `CODEXBAR_CCUSAGE_SOURCE` while running `Scripts/package_app.sh`; the helper must contain every architecture in `ARCHES` and is copied to `Contents/Helpers/ccusage`. Set `CODEXBAR_REQUIRE_CCUSAGE=1` to make packaging fail when the source is missing. The fallback is best-effort: missing helpers, timeouts, invalid JSON, and non-zero exits retain the native result. +For a local run, set `CODEXBAR_CCUSAGE_PATH` to a vetted executable. For a packaged app, set `CODEXBAR_CCUSAGE_SOURCE`, `CODEXBAR_CCUSAGE_VERSION`, and `CODEXBAR_CCUSAGE_SHA256` while running `Scripts/package_app.sh`; the helper must contain every architecture in `ARCHES`, and its SHA-256 must match before it is copied to `Contents/Helpers/ccusage`. The package records the version and verified digest in `Contents/Helpers/ccusage.provenance`. Set `CODEXBAR_REQUIRE_CCUSAGE=1` to make packaging fail when the source or provenance metadata is missing. The fallback is best-effort: missing helpers, timeouts, invalid JSON, and non-zero exits retain the native result. ## Bundle contents - `CodexBarWidget.appex` is built by `WidgetExtension/CodexBarWidgetExtension.xcodeproj` as a real macOS app extension, then bundled with app-group entitlements. From 0a9c393f8cdf1a331a04366df84dd3a24c6ad7c5 Mon Sep 17 00:00:00 2001 From: 1 <2> Date: Mon, 10 Aug 2026 20:57:02 +0800 Subject: [PATCH 3/3] Fix Codex fallback pricing for Luna and Terra --- .../CostUsage/CCUsageCodexBridge.swift | 82 +++++++++++++++++-- .../Vendored/CostUsage/CostUsagePricing.swift | 67 +++++++++++---- .../CCUsageCodexBridgeTests.swift | 68 +++++++++++++-- 3 files changed, 188 insertions(+), 29 deletions(-) diff --git a/Sources/CodexBarCore/CostUsage/CCUsageCodexBridge.swift b/Sources/CodexBarCore/CostUsage/CCUsageCodexBridge.swift index c517815bcf..09bab46aad 100644 --- a/Sources/CodexBarCore/CostUsage/CCUsageCodexBridge.swift +++ b/Sources/CodexBarCore/CostUsage/CCUsageCodexBridge.swift @@ -165,10 +165,15 @@ enum CCUsageCodexBridge { } guard !codexAgents.isEmpty else { return nil } - let modelBreakdowns = self.mergeModelBreakdowns(codexAgents.flatMap { $0.modelBreakdowns ?? [] }) + let rawModelBreakdowns = codexAgents.flatMap { $0.modelBreakdowns ?? [] } + let mergedModelBreakdowns = self.mergeModelBreakdowns(rawModelBreakdowns) + let modelBreakdowns = mergedModelBreakdowns.rows let models = Set(codexAgents.flatMap { $0.modelsUsed ?? [] }) .union(modelBreakdowns.map(\.modelName)) .sorted() + let costUSD = rawModelBreakdowns.isEmpty + ? self.repriceSingleModelAgent(codexAgents) + : mergedModelBreakdowns.totalCostUSD return CostUsageDailyReport.Entry( date: day.period, inputTokens: self.sum(codexAgents.map(\.inputTokens)), @@ -176,7 +181,7 @@ enum CCUsageCodexBridge { cacheReadTokens: self.sum(codexAgents.map(\.cacheReadTokens)), cacheCreationTokens: self.sum(codexAgents.map(\.cacheCreationTokens)), totalTokens: self.sum(codexAgents.map { $0.totalTokens ?? self.componentTotal(for: $0) }), - costUSD: self.sum(codexAgents.map(\.totalCost)), + costUSD: costUSD, modelsUsed: models.isEmpty ? nil : models, modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) } @@ -193,12 +198,21 @@ enum CCUsageCodexBridge { totalCostUSD: self.sum(entries.map(\.costUSD)) ?? 0)) } + private struct MergedModelBreakdowns { + let rows: [CostUsageDailyReport.ModelBreakdown] + let totalCostUSD: Double? + } + private static func mergeModelBreakdowns( - _ rows: [ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown] + _ rows: [ModelBreakdown]) -> MergedModelBreakdowns { struct Accumulator { var tokens: [Int?] = [] - var costs: [Double?] = [] + var inputTokens: [Int?] = [] + var outputTokens: [Int?] = [] + var cacheReadTokens: [Int?] = [] + var cacheCreationTokens: [Int?] = [] + var hasCompleteComponents = true } var models: [String: Accumulator] = [:] @@ -210,15 +224,69 @@ enum CCUsageCodexBridge { row.cacheCreationTokens, ]) models[row.modelName, default: Accumulator()].tokens.append(row.totalTokens ?? componentTotal) - models[row.modelName, default: Accumulator()].costs.append(row.cost) + models[row.modelName, default: Accumulator()].inputTokens.append(row.inputTokens) + models[row.modelName, default: Accumulator()].outputTokens.append(row.outputTokens) + models[row.modelName, default: Accumulator()].cacheReadTokens.append(row.cacheReadTokens) + models[row.modelName, default: Accumulator()].cacheCreationTokens.append(row.cacheCreationTokens) + if row.inputTokens == nil || row.outputTokens == nil + || row.cacheReadTokens == nil || row.cacheCreationTokens == nil + { + models[row.modelName, default: Accumulator()].hasCompleteComponents = false + } } - return models.keys.sorted().map { modelName in + var allCostsKnown = true + let merged = models.keys.sorted().map { modelName in let accumulator = models[modelName] ?? Accumulator() + let inputTokens = self.sum(accumulator.inputTokens) + let outputTokens = self.sum(accumulator.outputTokens) + let cacheReadTokens = self.sum(accumulator.cacheReadTokens) + let cacheCreationTokens = self.sum(accumulator.cacheCreationTokens) + let costUSD: Double? + if accumulator.hasCompleteComponents, + let inputTokens, + let outputTokens, + let cacheReadTokens, + let cacheCreationTokens, + let totalInputTokens = self.sum([inputTokens, cacheReadTokens, cacheCreationTokens]) + { + costUSD = CostUsagePricing.codexStandardCostUSD( + model: modelName, + inputTokens: totalInputTokens, + cachedInputTokens: cacheReadTokens, + outputTokens: outputTokens, + cacheWriteInputTokens: cacheCreationTokens) + } else { + costUSD = nil + } + allCostsKnown = allCostsKnown && costUSD != nil return CostUsageDailyReport.ModelBreakdown( modelName: modelName, - costUSD: self.sum(accumulator.costs), + costUSD: costUSD, totalTokens: self.sum(accumulator.tokens)) } + return MergedModelBreakdowns( + rows: merged.isEmpty ? [] : merged, + totalCostUSD: allCostsKnown ? self.sum(merged.map { $0.costUSD }) : nil) + } + + private static func repriceSingleModelAgent(_ agents: [Agent]) -> Double? { + let models = Set(agents.flatMap { $0.modelsUsed ?? [] }) + guard models.count == 1, + let model = models.first, + let inputTokens = self.sum(agents.map(\.inputTokens)), + let outputTokens = self.sum(agents.map(\.outputTokens)), + let cacheReadTokens = self.sum(agents.map(\.cacheReadTokens)), + let cacheCreationTokens = self.sum(agents.map(\.cacheCreationTokens)), + let totalInputTokens = self.sum([inputTokens, cacheReadTokens, cacheCreationTokens]) + else { + return nil + } + return CostUsagePricing.codexStandardCostUSD( + model: model, + inputTokens: totalInputTokens, + cachedInputTokens: cacheReadTokens, + outputTokens: outputTokens, + cacheWriteInputTokens: cacheCreationTokens) } private static func isAtLeastAsComplete( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 1bd0c5e624..471b522fce 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -492,7 +492,8 @@ enum CostUsagePricing { outputTokens: Int, cacheWriteInputTokens: Int = 0, modelsDevCatalog: ModelsDevCatalog? = nil, - modelsDevCacheRoot: URL? = nil) -> Double? + modelsDevCacheRoot: URL? = nil, + applyLongContextRates: Bool = true) -> Double? { let key = self.normalizeCodexModel(model) guard key != self.codexUnattributedModel else { return nil } @@ -524,23 +525,29 @@ enum CostUsagePricing { ?? lookup.pricing.inputCostPerTokenAboveThreshold ?? lookup.pricing.inputCostPerToken : bundledLongContext?.cacheWriteInputCostPerTokenAboveThreshold) + let thresholdTokens = bundled?.thresholdTokens ?? lookup.pricing.thresholdTokens + let inputAboveThreshold = lookup.pricing.inputCostPerTokenAboveThreshold + ?? bundledLongContext?.inputCostPerTokenAboveThreshold + let outputAboveThreshold = lookup.pricing.outputCostPerTokenAboveThreshold + ?? bundledLongContext?.outputCostPerTokenAboveThreshold + let cacheReadInputRate = lookup.pricing.cacheReadInputCostPerToken + ?? bundled?.cacheReadInputCostPerToken + let cacheWriteInputRate = lookup.pricing.cacheCreationInputCostPerToken + ?? bundled?.cacheWriteInputCostPerToken return self.codexCostUSD( pricing: lookup.pricing, - thresholdTokens: bundled?.thresholdTokens ?? lookup.pricing.thresholdTokens, - inputCostPerTokenAboveThreshold: lookup.pricing.inputCostPerTokenAboveThreshold - ?? bundledLongContext?.inputCostPerTokenAboveThreshold, - outputCostPerTokenAboveThreshold: lookup.pricing.outputCostPerTokenAboveThreshold - ?? bundledLongContext?.outputCostPerTokenAboveThreshold, - cacheReadInputCostPerToken: lookup.pricing.cacheReadInputCostPerToken - ?? bundled?.cacheReadInputCostPerToken, + thresholdTokens: thresholdTokens, + inputCostPerTokenAboveThreshold: inputAboveThreshold, + outputCostPerTokenAboveThreshold: outputAboveThreshold, + cacheReadInputCostPerToken: cacheReadInputRate, cacheReadInputCostPerTokenAboveThreshold: cacheReadAboveThreshold, - cacheWriteInputCostPerToken: lookup.pricing.cacheCreationInputCostPerToken - ?? bundled?.cacheWriteInputCostPerToken, + cacheWriteInputCostPerToken: cacheWriteInputRate, cacheWriteInputCostPerTokenAboveThreshold: cacheWriteAboveThreshold, inputTokens: inputTokens, cachedInputTokens: cachedInputTokens, cacheWriteInputTokens: cacheWriteInputTokens, - outputTokens: outputTokens) + outputTokens: outputTokens, + applyLongContextRates: applyLongContextRates) } guard let pricing = self.codex[key] else { return nil } @@ -549,7 +556,31 @@ enum CostUsagePricing { inputTokens: inputTokens, cachedInputTokens: cachedInputTokens, cacheWriteInputTokens: cacheWriteInputTokens, - outputTokens: outputTokens) + outputTokens: outputTokens, + applyLongContextRates: applyLongContextRates) + } + + /// Reprice an aggregated usage row with CodexBar's bundled standard rates. + /// + /// ccusage's daily report does not retain per-request context sizes, so applying a long-context + /// threshold to the whole day's aggregate would overcharge otherwise ordinary requests. The + /// fallback bridge uses this path instead of importing the helper's own cost fields. + static func codexStandardCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + cacheWriteInputTokens: Int = 0) -> Double? + { + let key = self.normalizeCodexModel(model) + guard key != self.codexUnattributedModel, let pricing = self.codex[key] else { return nil } + return self.codexCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, + outputTokens: outputTokens, + applyLongContextRates: false) } static func codexPriorityCostUSD( @@ -594,7 +625,8 @@ enum CostUsagePricing { inputTokens: Int, cachedInputTokens: Int, cacheWriteInputTokens: Int = 0, - outputTokens: Int) -> Double + outputTokens: Int, + applyLongContextRates: Bool = true) -> Double { // Codex/OpenAI reports `input_tokens` as the total prompt size, with cached reads as a // SUBSET of it. Cache writes (when tracked separately, e.g. Pi) are also a subset of the @@ -606,7 +638,8 @@ enum CostUsagePricing { let nonCached = remainingAfterCache - cacheWrite let cachedRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken - let usesLongContextRates = pricing.thresholdTokens.map { totalInput > $0 } ?? false + let usesLongContextRates = applyLongContextRates + && (pricing.thresholdTokens.map { totalInput > $0 } ?? false) let inputRate = usesLongContextRates ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken : pricing.inputCostPerToken @@ -640,7 +673,8 @@ enum CostUsagePricing { inputTokens: Int, cachedInputTokens: Int, cacheWriteInputTokens: Int = 0, - outputTokens: Int) -> Double + outputTokens: Int, + applyLongContextRates: Bool = true) -> Double { self.codexCostUSD( pricing: CodexPricing( @@ -663,7 +697,8 @@ enum CostUsagePricing { inputTokens: inputTokens, cachedInputTokens: cachedInputTokens, cacheWriteInputTokens: cacheWriteInputTokens, - outputTokens: outputTokens) + outputTokens: outputTokens, + applyLongContextRates: applyLongContextRates) } static func claudeCostUSD( diff --git a/Tests/CodexBarTests/CCUsageCodexBridgeTests.swift b/Tests/CodexBarTests/CCUsageCodexBridgeTests.swift index 833f478370..2dd8e47c6a 100644 --- a/Tests/CodexBarTests/CCUsageCodexBridgeTests.swift +++ b/Tests/CodexBarTests/CCUsageCodexBridgeTests.swift @@ -19,7 +19,7 @@ struct CCUsageCodexBridgeTests { "cacheReadTokens": 800, "cacheCreationTokens": 5, "totalTokens": 955, - "totalCost": 1.25, + "totalCost": 999.99, "modelsUsed": ["gpt-5.6-sol"], "modelBreakdowns": [ { @@ -28,7 +28,7 @@ struct CCUsageCodexBridgeTests { "outputTokens": 30, "cacheReadTokens": 800, "cacheCreationTokens": 5, - "cost": 1.25 + "cost": 999.99 } ] }, @@ -55,10 +55,66 @@ struct CCUsageCodexBridgeTests { #expect(report.data[0].cacheReadTokens == 800) #expect(report.data[0].cacheCreationTokens == 5) #expect(report.data[0].totalTokens == 955) - #expect(report.data[0].costUSD == 1.25) + #expect(abs((report.data[0].costUSD ?? 0) - 0.00193125) < 0.0000000001) #expect(report.data[0].modelBreakdowns?.first?.totalTokens == 955) + #expect(abs((report.data[0].modelBreakdowns?.first?.costUSD ?? 0) - 0.00193125) < 0.0000000001) #expect(report.summary?.totalTokens == 955) - #expect(report.summary?.totalCostUSD == 1.25) + #expect(abs((report.summary?.totalCostUSD ?? 0) - 0.00193125) < 0.0000000001) + } + + @Test + func `reprices Luna and Terra from token categories instead of helper costs`() throws { + let json = #""" + { + "daily": [ + { + "period": "2026-08-10", + "agents": [ + { + "agent": "codex", + "inputTokens": 2000000, + "outputTokens": 200000, + "cacheReadTokens": 20000000, + "cacheCreationTokens": 0, + "totalTokens": 22200000, + "totalCost": 260.0, + "modelsUsed": ["gpt-5.6-luna", "gpt-5.6-terra"], + "modelBreakdowns": [ + { + "modelName": "gpt-5.6-luna", + "inputTokens": 1000000, + "outputTokens": 100000, + "cacheReadTokens": 10000000, + "cacheCreationTokens": 0, + "totalTokens": 11100000, + "cost": 52.0 + }, + { + "modelName": "gpt-5.6-terra", + "inputTokens": 1000000, + "outputTokens": 100000, + "cacheReadTokens": 10000000, + "cacheCreationTokens": 0, + "totalTokens": 11100000, + "cost": 208.0 + } + ] + } + ] + } + ] + } + """# + + let report = try CCUsageCodexBridge.parseReport(Data(json.utf8)) + let breakdowns = try #require(report.data[0].modelBreakdowns) + let luna = try #require(breakdowns.first { $0.modelName == "gpt-5.6-luna" }) + let terra = try #require(breakdowns.first { $0.modelName == "gpt-5.6-terra" }) + + #expect(abs((luna.costUSD ?? 0) - 0.52) < 0.0000000001) + #expect(abs((terra.costUSD ?? 0) - 5.2) < 0.0000000001) + #expect(abs((report.data[0].costUSD ?? 0) - 5.72) < 0.0000000001) + #expect(abs((report.summary?.totalCostUSD ?? 0) - 5.72) < 0.0000000001) } @Test @@ -87,7 +143,7 @@ struct CCUsageCodexBridgeTests { defer { fixture.cleanup() } let helper = try fixture.writeHelper(""" case "$CODEX_HOME" in */codex-home) ;; *) exit 7 ;; esac - printf '%s\\n' '{"daily":[{"period":"2026-08-09","agents":[{"agent":"codex","inputTokens":120,"outputTokens":30,"cacheReadTokens":800,"cacheCreationTokens":5,"totalTokens":955,"totalCost":1.25,"modelsUsed":["gpt-5.6-sol"],"modelBreakdowns":[{"modelName":"gpt-5.6-sol","totalTokens":955,"cost":1.25}]}]}]}' + printf '%s\\n' '{"daily":[{"period":"2026-08-09","agents":[{"agent":"codex","inputTokens":120,"outputTokens":30,"cacheReadTokens":800,"cacheCreationTokens":5,"totalTokens":955,"totalCost":1.25,"modelsUsed":["gpt-5.6-sol"],"modelBreakdowns":[{"modelName":"gpt-5.6-sol","inputTokens":120,"outputTokens":30,"cacheReadTokens":800,"cacheCreationTokens":5,"totalTokens":955,"cost":1.25}]}]}]}' """) let report = await CCUsageCodexBridge.loadFallbackReportIfNeeded( @@ -100,7 +156,7 @@ struct CCUsageCodexBridgeTests { codexHomePath: fixture.codexHome.path) #expect(report?.summary?.totalTokens == 955) - #expect(report?.summary?.totalCostUSD == 1.25) + #expect(abs((report?.summary?.totalCostUSD ?? 0) - 0.00193125) < 0.0000000001) } @Test