diff --git a/CHANGELOG.md b/CHANGELOG.md index e88e92c2c6..3862dc9a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Fireworks: track 30-day rated billing spend with an API key and account slug (#2687). Thanks @x0mh0x! ### Fixed +- Codex: reject Standard/Fast pricing rows that exceed canonical fork-deduplicated usage, preventing copied fork rows and their Fast surcharge from inflating cost estimates (#2754). Thanks @1328189205 for the report and @Yuxin-Qiao for the initial fix and regression-test approach! - Claude: stop rotating Claude Code's own refresh-token chain on keychain-only installs — ownership evidence is now tri-state with indeterminate treated as CLI-owned, so delegated refreshes can never invalidate credentials CodexBar cannot read (#2745, refs #2634). Thanks @avenoxai! - Plugins: run the QuickJS worker and TypeScript transpiler on Thread subclasses instead of Thread(block:) closures — binaries built with the Xcode 26.3 SDK inferred @MainActor on those blocks, and macOS runtimes that enforce dynamic isolation crashed (SIGTRAP) on the first plugin fetch; this was the deterministic macOS CI shard crash since #2775 and could crash shipped builds at runtime. - Plugins: the QuickJS HTTP/cookie bridge now starts a request's per-call timeout when the transport actually begins executing instead of when it is scheduled, so short deadlines (like OpenRouter's one-second key fast join) no longer fire spuriously under CPU load (refs #2778). @@ -14,7 +15,6 @@ - OpenRouter: keep optional key-quota enrichment on its one-second production fast join while making degraded results explicit and preventing loaded CI parity runs from mistaking the fallback snapshot for a golden mismatch (fixes #2778). - Codex: the SQLite cost store now writes each save cycle inside one transaction, so a crash or kill mid-save can never leave session rows updated against stale day aggregates — the previous state survives intact, matching the old JSON path's atomic file replace (refs #2760). - Provider plugins: run each QuickJS context on a dedicated 4 MiB-stack thread, refresh stack bounds at every JavaScript entry, and leave 3 MiB of native headroom so deep recursion raises a clean stack-overflow error instead of crashing. ->>>>>>> bae013ccb (feat: make QuickJS the default plugin engine everywhere) - Codex: SQLite cost saves no longer rescan every stored row and snapshot per file — baseline counts and file lookups are precomputed once, cutting a large-corpus (1,700+ sessions) save pass from minutes of CPU to seconds (refs #2760). - Codex: restore JSON-cache retention semantics lost in the SQLite cutover — discovery pruning now reaches the scanner's round-tripped payload so deleted files stop resurfacing, the row budget never sacrifices in-window or recently active sessions, and fork-parent protection again drops stale lineage-only parents (refs #2760). - Codex: the SQLite cost store no longer deletes the whole database on transient failures — lock contention from a concurrent CLI/app writer, disk-full, or a constraint violation now preserve history and only genuine corruption or schema drift triggers a rebuild, which is now logged (refs #2760). diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 98e0b75dc4..b901ff1ba8 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "b9175d171384dd58" + static let value = "b975eb705f905b9a" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 80ee163c74..d471dc018a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -135,6 +135,11 @@ extension CostUsageScanner { var hasModeSplit: Bool { self.sawPriorityCost || self.priorityTokens > 0 } + + func isTrusted(canonicalTotalTokens: Int) -> Bool { + let (rowTokenTotal, overflow) = self.standardTokens.addingReportingOverflow(self.priorityTokens) + return !overflow && rowTokenTotal <= canonicalTotalTokens + } } static func codexRowCostBreakdown( @@ -1412,19 +1417,24 @@ extension CostUsageScanner { priorityTurns: priorityTurns, modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), modelsDevCacheRoot: modelsDevCacheRoot) + let rowCostIsTrusted = rowCost?.isTrusted(canonicalTotalTokens: totalTokens) ?? true let authoritativeCost = authoritativeCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } - let cost = rowCost?.totalCostUSD - ?? authoritativeCost - ?? CostUsagePricing.codexCostUSD( - model: model, - inputTokens: input, - cachedInputTokens: cached, - outputTokens: output, - modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), - modelsDevCacheRoot: modelsDevCacheRoot) - let hasModeSplit = rowCost?.hasModeSplit == true + let canonicalCost = CostUsagePricing.codexCostUSD( + model: model, + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), + modelsDevCacheRoot: modelsDevCacheRoot) + // Physical pricing rows can retain fork-copied usage after canonical ownership + // has deduplicated the day/model totals. Reject the whole row-derived price so + // Fast uplift from the same unowned rows cannot leak into the fallback cost. + let cost = rowCostIsTrusted + ? rowCost?.totalCostUSD ?? authoritativeCost ?? canonicalCost + : canonicalCost + let hasModeSplit = rowCostIsTrusted && rowCost?.hasModeSplit == true breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, diff --git a/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift b/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift new file mode 100644 index 0000000000..03342ddd5e --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift @@ -0,0 +1,177 @@ +import Foundation +#if canImport(SQLite3) +import Testing +@testable import CodexBarCore + +struct CostUsageScannerForkSplitTests { + @Test + func `codex report rejects fork inflated row split and its fast uplift`() throws { + let fixture = try self.makeFixture() + defer { fixture.environment.cleanup() } + + var cache = fixture.cache + let parent = try #require(cache.files.first { $0.value.sessionId == "parent-session" }) + let child = try #require(cache.files.first { $0.value.sessionId == "child-session" }) + let copiedParentRows = try #require(parent.value.codexRows) + var inflatedChild = child.value + inflatedChild.codexRows = (inflatedChild.codexRows ?? []) + copiedParentRows + cache.files[child.key] = inflatedChild + + let canonical = try #require(cache.days[fixture.dayKey]?[fixture.model]) + #expect(canonical == [150, 60, 15]) + let canonicalTokens = canonical[0] + canonical[2] + let rowTokens = cache.files.values + .flatMap { $0.codexRows ?? [] } + .reduce(0) { $0 + $1.input + $1.output } + #expect(rowTokens > canonicalTokens) + + let report = CostUsageScanner.buildCodexReportFromCache(cache: cache, range: fixture.range) + let breakdown = try #require(report.data.first?.modelBreakdowns?.first) + let canonicalCost = try #require(CostUsagePricing.codexCostUSD( + model: fixture.model, + inputTokens: canonical[0], + cachedInputTokens: canonical[1], + outputTokens: canonical[2])) + + #expect(abs((breakdown.costUSD ?? 0) - canonicalCost) < 1e-12) + #expect(breakdown.standardCostUSD == nil) + #expect(breakdown.priorityCostUSD == nil) + #expect(breakdown.standardTokens == nil) + #expect(breakdown.priorityTokens == nil) + #expect(abs((report.summary?.totalCostUSD ?? 0) - canonicalCost) < 1e-12) + } + + @Test + func `codex report keeps trusted fork deduplicated row split`() throws { + let fixture = try self.makeFixture() + defer { fixture.environment.cleanup() } + + let canonical = try #require(fixture.cache.days[fixture.dayKey]?[fixture.model]) + #expect(canonical == [150, 60, 15]) + let child = try #require(fixture.cache.files.first { $0.value.sessionId == "child-session" }?.value) + #expect(child.days[fixture.dayKey]?[fixture.model] == [50, 20, 5]) + + let report = CostUsageScanner.buildCodexReportFromCache(cache: fixture.cache, range: fixture.range) + let breakdown = try #require(report.data.first?.modelBreakdowns?.first) + let standardCost = try #require(CostUsagePricing.codexCostUSD( + model: fixture.model, + inputTokens: 50, + cachedInputTokens: 20, + outputTokens: 5)) + let priorityCost = try #require(CostUsagePricing.codexPriorityCostUSD( + model: fixture.model, + inputTokens: 100, + cachedInputTokens: 40, + outputTokens: 10)) + + #expect(abs((breakdown.costUSD ?? 0) - (standardCost + priorityCost)) < 1e-12) + #expect(abs((breakdown.standardCostUSD ?? 0) - standardCost) < 1e-12) + #expect(abs((breakdown.priorityCostUSD ?? 0) - priorityCost) < 1e-12) + #expect(breakdown.standardTokens == 55) + #expect(breakdown.priorityTokens == 110) + } + + private struct Fixture { + let environment: CostUsageTestEnvironment + let range: CostUsageScanner.CostUsageDayRange + let dayKey: String + let model: String + let cache: CostUsageCache + } + + private func makeFixture() throws -> Fixture { + let env = try CostUsageTestEnvironment() + let day = try env.makeLocalNoon(year: 2026, month: 8, day: 6) + let parentTimestamp = env.isoString(for: day) + let parentUsageTimestamp = env.isoString(for: day.addingTimeInterval(1)) + let forkTimestamp = env.isoString(for: day.addingTimeInterval(2)) + let childUsageTimestamp = env.isoString(for: day.addingTimeInterval(3)) + let model = "gpt-5.5" + + _ = try env.writeCodexSessionFile( + day: day, + filename: "a-parent.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": parentTimestamp, + "payload": ["id": "parent-session", "timestamp": parentTimestamp], + ], + ["type": "turn_context", "timestamp": parentTimestamp, "payload": ["model": model]], + [ + "type": "event_msg", + "timestamp": parentUsageTimestamp, + "payload": ["type": "task_started", "turn_id": "priority-turn"], + ], + self.totalTokenCount(timestamp: parentUsageTimestamp, input: 100, cached: 40, output: 10), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "z-child.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": forkTimestamp, + ], + ], + ["type": "turn_context", "timestamp": forkTimestamp, "payload": ["model": model]], + [ + "type": "event_msg", + "timestamp": childUsageTimestamp, + "payload": ["type": "task_started", "turn_id": "standard-turn"], + ], + self.totalTokenCount(timestamp: childUsageTimestamp, input: 150, cached: 60, output: 15), + ])) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try CostUsageScannerCodexPriorityTests.insertTestLog( + dbURL: dbURL, + timestamp: parentUsageTimestamp, + body: "thread_id=thread turn.id=priority-turn websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL, + forceRescan: true, + preferNewestCodexSessionsFirst: false) + options.refreshMinIntervalSeconds = 0 + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + return Fixture( + environment: env, + range: range, + dayKey: range.sinceKey, + model: model, + cache: CostUsageStoreAccess.read(cacheRoot: env.cacheRoot, calendar: range.calendar)) + } + + private func totalTokenCount(timestamp: String, input: Int, cached: Int, output: Int) -> [String: Any] { + [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": input, + "cached_input_tokens": cached, + "output_tokens": output, + ], + ], + ], + ] + } +} +#endif