diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8ad938e49..1df2ce1378 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -291,7 +291,14 @@ jobs: echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> "$GITHUB_ENV" echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> "$GITHUB_ENV" - swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT" + for i in 1 2 3 4 5; do + if swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT"; then + break + fi + if [ "$i" -eq 5 ]; then exit 1; fi + echo "swiftly install failed, retrying in 5s (attempt $i/5)..." + sleep 5 + done if [[ -s "$POST_INSTALL_SCRIPT" ]]; then sudo apt-get update sudo bash "$POST_INSTALL_SCRIPT" @@ -475,7 +482,14 @@ jobs: echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> "$GITHUB_ENV" echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> "$GITHUB_ENV" - swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT" + for i in 1 2 3 4 5; do + if swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT"; then + break + fi + if [ "$i" -eq 5 ]; then exit 1; fi + echo "swiftly install failed, retrying in 5s (attempt $i/5)..." + sleep 5 + done if [[ -s "$POST_INSTALL_SCRIPT" ]]; then sudo apt-get update sudo bash "$POST_INSTALL_SCRIPT" diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index e13e0bcd3b..c2843f5f43 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -13,7 +13,9 @@ enum CostUsageCacheIO { case .codex: 11 case .claude, .vertexai: - 6 + // v7: row identity now falls back to a lone nonempty message/request ID, so cached + // Claude/Vertex rows must be rebuilt under the shared in-file + incremental rule. + 7 default: 1 } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index e862bb349e..8b0eb62571 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -229,10 +229,9 @@ extension CostUsageScanner { costNanos: tokens.costNanos, costPriced: tokens.costPriced) - // Streaming chunks share message.id + requestId inside a file. + // Streaming chunks share message.id and/or requestId inside a file. // Keep overwriting so the final cumulative chunk wins. - if let messageId, let requestId { - let key = "\(messageId):\(requestId)" + if let key = Self.claudeInFileKey(row) { keyedRows[key] = row } else { // Older logs omit IDs; treat each line as distinct to avoid dropping usage. @@ -274,10 +273,7 @@ extension CostUsageScanner { } private static func claudeCanonicalRowKey(_ row: ClaudeUsageRow) -> String? { - guard let messageId = row.messageId, let requestId = row.requestId else { - return nil - } - return "\(messageId):\(requestId)" + self.claudeInFileKey(row) } private static func mergeClaudeRows(existing: [ClaudeUsageRow], delta: [ClaudeUsageRow]) -> [ClaudeUsageRow] { @@ -303,8 +299,19 @@ extension CostUsageScanner { } private static func claudeInFileKey(_ row: ClaudeUsageRow) -> String? { - guard let messageId = row.messageId, let requestId = row.requestId else { return nil } - return "\(messageId):\(requestId)" + let messageId = row.messageId?.trimmingCharacters(in: .whitespacesAndNewlines) + let requestId = row.requestId?.trimmingCharacters(in: .whitespacesAndNewlines) + + if let messageId, !messageId.isEmpty { + if let requestId, !requestId.isEmpty { + return "\(messageId):\(requestId)" + } + return messageId + } + if let requestId, !requestId.isEmpty { + return requestId + } + return nil } private static func claudeRowWins( diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index d06d66c09c..720f30b58f 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -39,8 +39,8 @@ struct CostUsageCacheTests { let vertexURL = CostUsageCacheIO.cacheFileURL(provider: .vertexai, cacheRoot: root) #expect(codexURL.lastPathComponent == "codex-v11.json") - #expect(claudeURL.lastPathComponent == "claude-v6.json") - #expect(vertexURL.lastPathComponent == "vertexai-v6.json") + #expect(claudeURL.lastPathComponent == "claude-v7.json") + #expect(vertexURL.lastPathComponent == "vertexai-v7.json") } @Test @@ -173,6 +173,43 @@ struct CostUsageCacheTests { #expect(loaded.days.isEmpty) } + @Test + func `claude cache ignores predecessor artifact after row identity change`() throws { + // #2393: single-ID dedup changed row identity, so v6 Claude/Vertex caches must be rebuilt. + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + for provider in [UsageProvider.claude, .vertexai] { + let legacyURL = root + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("\(provider.rawValue)-v6.json", isDirectory: false) + try FileManager.default.createDirectory( + at: legacyURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + let legacy = """ + { + "version": 1, + "lastScanUnixMs": 999, + "files": { + "/tmp/claude-session.jsonl": { + "mtimeUnixMs": 1, + "size": 100, + "days": {}, + "parsedBytes": 100 + } + }, + "days": {} + } + """ + try legacy.write(to: legacyURL, atomically: false, encoding: .utf8) + + let loaded = CostUsageCacheIO.load(provider: provider, cacheRoot: root) + + #expect(loaded.lastScanUnixMs == 0) + #expect(loaded.files.isEmpty) + } + } + @Test func `current codex cache rejects pre interleave containment producers`() throws { // Interleave containment (#2037) changed cumulative delta semantics, so caches from diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift index 5cacd77a4e..e7984b7004 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift @@ -775,4 +775,124 @@ struct CostUsageScannerClaudeRegressionTests { #expect(datedCost != nil) #expect(baseCost == datedCost) } + + @Test + func `claude proxy rows sharing only message id dedup on append refresh`() throws { + // #2393: CLIProxyAPI writes thinking/tool_use/text rows with the same message.id and no + // requestId. The incremental merge must reuse the same single-ID key as initial parsing. + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 23) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + + let model = "gpt-5.6-sol" + let sessionId = "session-proxy-single-id" + let messageId = "msg_proxy_single_id" + + func row(timestamp: String, input: Int, cacheRead: Int, output: Int) -> [String: Any] { + [ + "type": "assistant", + "timestamp": timestamp, + "sessionId": sessionId, + "isSidechain": false, + "message": [ + "id": messageId, + "model": model, + "usage": [ + "input_tokens": input, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": cacheRead, + "output_tokens": output, + ], + ], + ] + } + + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/proxy-single-id.jsonl", + contents: env.jsonl([ + row(timestamp: iso0, input: 100, cacheRead: 0, output: 10), + row(timestamp: iso1, input: 100, cacheRead: 40, output: 25), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: nil, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let initial = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(initial.data.count == 1) + #expect(initial.data[0].outputTokens == 25) + #expect(initial.data[0].totalTokens == 165) + + // Simulate a streaming append: the final cumulative row reclassifies interim input as + // cache reads. The incremental refresh must replace, not sum, the earlier snapshot. + let appended = try env.jsonl([row(timestamp: iso2, input: 100, cacheRead: 80, output: 50)]) + let handle = try FileHandle(forWritingTo: fileURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data(appended.utf8)) + try handle.close() + + let refreshed = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(refreshed.data.count == 1) + #expect(refreshed.data[0].outputTokens == 50) + #expect(refreshed.data[0].cacheReadTokens == 80) + #expect(refreshed.data[0].totalTokens == 230) + } + + @Test + func `claude proxy rows with empty lone ids stay distinct`() throws { + // Empty-string IDs must not be treated as a shared key; unrelated rows stay separate. + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 23) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + + func row(timestamp: String, input: Int) -> [String: Any] { + [ + "type": "assistant", + "timestamp": timestamp, + "sessionId": "session-empty-id", + "isSidechain": false, + "message": [ + "id": "", + "model": "claude-sonnet-4-20250514", + "usage": [ + "input_tokens": input, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 1, + ], + ], + ] + } + + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/empty-lone-ids.jsonl", + contents: env.jsonl([row(timestamp: iso0, input: 11), row(timestamp: iso1, input: 13)])) + + let parsed = CostUsageScanner.parseClaudeFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + providerFilter: .all) + + #expect(parsed.rows.count == 2) + #expect(parsed.rows.map(\.input).sorted() == [11, 13]) + } }