Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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] {
Expand All @@ -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
Comment on lines +309 to +312

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Namespace single-ID keys by identifier kind

When one accepted log row supplies only message.id = "x" and another supplies only requestId = "x", both branches return the identical key "x"; the second row then overwrites the first during parsing, incremental merging, or cross-file reconciliation, undercounting otherwise distinct calls. Prefix or structurally encode message-only and request-only keys so the two identifier namespaces cannot collide.

Useful? React with 👍 / 👎.

}
return nil
}

private static func claudeRowWins(
Expand Down
41 changes: 39 additions & 2 deletions Tests/CodexBarTests/CostUsageCacheTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
Loading