From b37b1a1f1001327dd1aa998edcf97bef948f352c Mon Sep 17 00:00:00 2001 From: "kentoku.matsunami" Date: Tue, 4 Aug 2026 22:59:23 +0900 Subject: [PATCH 1/3] feat: per-model cost breakdown for OpenCode Go's daily cost history Extract each local assistant message's modelID from opencode.db (the real model behind the constant opencode-go Zen-proxy providerID) and group cost/request counts by (day, model) instead of just by day, so the shared Cost history chart shows a per-model breakdown for OpenCode Go the same way it already does for Claude/Codex. Rows with no modelID fall back to an "unknown" bucket instead of being dropped. --- .../OpenCodeGoLocalUsageReader.swift | 50 ++++++--- .../OpenCodeGoLocalUsageReaderTests.swift | 102 +++++++++++++++++- docs/opencode.md | 5 + 3 files changed, 142 insertions(+), 15 deletions(-) diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift index 1d39203ee6..34813173c7 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift @@ -122,7 +122,8 @@ public struct OpenCodeGoLocalUsageReader: Sendable { let cost = sqlite3_column_double(stmt, 1) guard createdMs > 0, cost >= 0, cost.isFinite else { continue } let requestCount = max(1, Int(sqlite3_column_int64(stmt, 2))) - rows.append(UsageRow(createdMs: createdMs, cost: cost, requestCount: requestCount)) + let model = sqlite3_column_text(stmt, 3).map { String(cString: $0) } ?? "" + rows.append(UsageRow(createdMs: createdMs, cost: cost, requestCount: requestCount, model: model)) } return rows } @@ -165,7 +166,8 @@ public struct OpenCodeGoLocalUsageReader: Sendable { SELECT CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, CAST(json_extract(data, '$.cost') AS REAL) AS cost, - 1 AS requestCount + 1 AS requestCount, + COALESCE(json_extract(data, '$.modelID'), '') AS modelID FROM message WHERE json_valid(data) AND json_extract(data, '$.providerID') = 'opencode-go' @@ -179,7 +181,8 @@ public struct OpenCodeGoLocalUsageReader: Sendable { id AS messageID, CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, CAST(json_extract(data, '$.cost') AS REAL) AS cost, - json_type(data, '$.cost') IN ('integer', 'real') AS hasCost + json_type(data, '$.cost') IN ('integer', 'real') AS hasCost, + COALESCE(json_extract(data, '$.modelID'), '') AS modelID FROM message WHERE json_valid(data) AND json_extract(data, '$.providerID') = 'opencode-go' @@ -189,14 +192,15 @@ public struct OpenCodeGoLocalUsageReader: Sendable { CAST(COALESCE(json_extract(p.data, '$.time.created'), p.time_created, m.createdMs) AS INTEGER) AS createdMs, CAST(json_extract(p.data, '$.cost') AS REAL) AS cost, - 1 AS requestCount + 1 AS requestCount, + m.modelID AS modelID FROM part p JOIN provider_messages m ON m.messageID = p.message_id WHERE json_valid(p.data) AND json_extract(p.data, '$.type') = 'step-finish' AND json_type(p.data, '$.cost') IN ('integer', 'real') UNION ALL - SELECT createdMs, cost, 1 AS requestCount + SELECT createdMs, cost, 1 AS requestCount, modelID FROM provider_messages m WHERE hasCost AND NOT EXISTS ( @@ -214,6 +218,8 @@ public struct OpenCodeGoLocalUsageReader: Sendable { let cost: Double /// One provider invocation per step-finish part; message-only databases fall back to one. let requestCount: Int + /// The underlying model behind the `opencode-go` Zen proxy; empty when unattributed. + let model: String } private struct SQLiteReadFailure: Error { @@ -316,31 +322,47 @@ public struct OpenCodeGoLocalUsageReader: Sendable { } let sinceStartOfDay = calendar.startOfDay(for: since) - var totals: [String: (cost: Double, requestCount: Int)] = [:] + var totalsByModel: [String: [String: (cost: Double, requestCount: Int)]] = [:] for row in rows { let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) guard date >= sinceStartOfDay, date <= now else { continue } let key = CostUsageScanner.CostUsageDayRange.dayKey(from: date) - var bucket = totals[key] ?? (cost: 0, requestCount: 0) + let model = row.model.trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty ? Self.unknownModelName : row.model + var dayTotals = totalsByModel[key] ?? [:] + var bucket = dayTotals[model] ?? (cost: 0, requestCount: 0) bucket.cost += row.cost bucket.requestCount += row.requestCount - totals[key] = bucket + dayTotals[model] = bucket + totalsByModel[key] = dayTotals } - return totals.keys.sorted().compactMap { key in - guard let bucket = totals[key] else { return nil } + return totalsByModel.keys.sorted().compactMap { key in + guard let dayTotals = totalsByModel[key] else { return nil } + let modelBreakdowns = dayTotals.keys.sorted().map { model in + let bucket = dayTotals[model] ?? (cost: 0, requestCount: 0) + return CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: bucket.cost, + requestCount: bucket.requestCount) + }.sorted { ($0.costUSD ?? 0) > ($1.costUSD ?? 0) } + let totalCost = dayTotals.values.reduce(0) { $0 + $1.cost } + let totalRequests = dayTotals.values.reduce(0) { $0 + $1.requestCount } return CostUsageDailyReport.Entry( date: key, inputTokens: nil, outputTokens: nil, totalTokens: nil, - requestCount: bucket.requestCount, - costUSD: bucket.cost, - modelsUsed: nil, - modelBreakdowns: nil) + requestCount: totalRequests, + costUSD: totalCost, + modelsUsed: dayTotals.keys.sorted(), + modelBreakdowns: modelBreakdowns) } } + /// Bucket label for rows whose local `modelID` is missing or blank. + private static let unknownModelName = "unknown" + private static func percent(used: Double, limit: Double) -> Double { guard used.isFinite, limit > 0 else { return 0 } let value = max(0, min(100, used / limit * 100)) diff --git a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift index bfe7bbaf1a..eb5df03a84 100644 --- a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift @@ -253,6 +253,98 @@ struct OpenCodeGoLocalUsageReaderTests { #expect(snapshot.daily.last?.requestCount == 1) } + @Test + func `daily entries group cost by model within a day`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0, + model: "claude-sonnet-4-5") + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T12:00:00.000Z"), + cost: 2.0, + model: "gpt-5.1-codex") + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T13:00:00.000Z"), + cost: 1.0, + model: "claude-sonnet-4-5") + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-06T15:00:00.000Z")) / 1000) + let snapshot = try reader.fetch(now: now, historyDays: 30) + + #expect(snapshot.daily.count == 1) + let entry = try #require(snapshot.daily.first) + #expect(entry.costUSD == 6.0) + #expect(entry.requestCount == 3) + #expect(entry.modelsUsed == ["claude-sonnet-4-5", "gpt-5.1-codex"]) + + let breakdowns = try #require(entry.modelBreakdowns) + #expect(breakdowns.count == 2) + #expect(breakdowns.first?.modelName == "claude-sonnet-4-5") + #expect(breakdowns.first?.costUSD == 4.0) + #expect(breakdowns.first?.requestCount == 2) + #expect(breakdowns.last?.modelName == "gpt-5.1-codex") + #expect(breakdowns.last?.costUSD == 2.0) + #expect(breakdowns.last?.requestCount == 1) + } + + @Test + func `step finish parts inherit their model from the parent message`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + let messageID = try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: nil, + model: "grok-code-fast-1") + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + let entry = try #require(snapshot.daily.first) + #expect(entry.modelsUsed == ["grok-code-fast-1"]) + #expect(entry.modelBreakdowns?.first?.modelName == "grok-code-fast-1") + #expect(entry.modelBreakdowns?.first?.costUSD == 3.0) + } + + @Test + func `messages without a model fall back to the unknown model bucket`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 4.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + let entry = try #require(snapshot.daily.first) + #expect(entry.costUSD == 4.0) + #expect(entry.modelsUsed == ["unknown"]) + #expect(entry.modelBreakdowns?.first?.modelName == "unknown") + #expect(entry.modelBreakdowns?.first?.costUSD == 4.0) + } + @Test func `missing auth and history is not detected`() throws { let env = try Self.makeEnvironment() @@ -329,7 +421,12 @@ struct OpenCodeGoLocalUsageReaderTests { } @discardableResult - private static func insertMessage(databaseURL: URL, createdMs: Int64, cost: Double?) throws -> String { + private static func insertMessage( + databaseURL: URL, + createdMs: Int64, + cost: Double?, + model: String? = nil) throws -> String + { var db: OpaquePointer? guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } defer { sqlite3_close(db) } @@ -343,6 +440,9 @@ struct OpenCodeGoLocalUsageReaderTests { if let cost { payload["cost"] = cost } + if let model { + payload["modelID"] = model + } let data = try JSONSerialization.data(withJSONObject: payload) let json = String(data: data, encoding: .utf8) ?? "{}" diff --git a/docs/opencode.md b/docs/opencode.md index 1a8c80c3e6..5f397311c0 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -40,3 +40,8 @@ read_when: come from local `opencode-go` assistant costs in `opencode.db`, keyed by device-local calendar day. Successful web usage remains workspace-scoped and is never blended with device-wide local costs, so it does not show cost history. Explicit Web mode never reads the local database either. +- Each day's bucket also carries a per-model cost breakdown, read from each local assistant message's `modelID` + (the real model behind the constant `opencode-go` Zen proxy `providerID`). This lets the shared Cost history + chart show a per-model breakdown for OpenCode Go the same way it already does for Claude (see the "Cost usage" + section in [docs/CLAUDE.md](CLAUDE.md)). Rows with no `modelID` are grouped under an "unknown" bucket instead of + being dropped. From 821c51d82df95c68ce54e4b7354947a9a86e8d99 Mon Sep 17 00:00:00 2001 From: "kentoku.matsunami" Date: Tue, 4 Aug 2026 23:05:05 +0900 Subject: [PATCH 2/3] fix: use lowercase docs/claude.md link to pass case-sensitive CI lint macOS resolves docs/CLAUDE.md to the same file as docs/claude.md, but the Linux lint runner's case-sensitive filesystem doesn't, failing the documentation-link check. --- docs/opencode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/opencode.md b/docs/opencode.md index 5f397311c0..100c5dda53 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -43,5 +43,5 @@ read_when: - Each day's bucket also carries a per-model cost breakdown, read from each local assistant message's `modelID` (the real model behind the constant `opencode-go` Zen proxy `providerID`). This lets the shared Cost history chart show a per-model breakdown for OpenCode Go the same way it already does for Claude (see the "Cost usage" - section in [docs/CLAUDE.md](CLAUDE.md)). Rows with no `modelID` are grouped under an "unknown" bucket instead of + section in [docs/claude.md](claude.md)). Rows with no `modelID` are grouped under an "unknown" bucket instead of being dropped. From b25c008ddbd76190acd576544c083db14ce61865 Mon Sep 17 00:00:00 2001 From: "kentoku.matsunami" Date: Wed, 5 Aug 2026 00:08:37 +0900 Subject: [PATCH 3/3] fix: normalize the trimmed model id when grouping OpenCode Go costs The grouping key used the untrimmed modelID even though emptiness was checked against the trimmed value, so a model id with incidental leading/trailing whitespace would form its own bucket instead of merging with the clean value for the same model. Group by the trimmed value consistently, and add regression tests for whitespace-only and whitespace-padded model ids. --- .../OpenCodeGoLocalUsageReader.swift | 4 +- .../OpenCodeGoLocalUsageReaderTests.swift | 53 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift index 34813173c7..34e22062d8 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift @@ -327,8 +327,8 @@ public struct OpenCodeGoLocalUsageReader: Sendable { let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) guard date >= sinceStartOfDay, date <= now else { continue } let key = CostUsageScanner.CostUsageDayRange.dayKey(from: date) - let model = row.model.trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty ? Self.unknownModelName : row.model + let trimmedModel = row.model.trimmingCharacters(in: .whitespacesAndNewlines) + let model = trimmedModel.isEmpty ? Self.unknownModelName : trimmedModel var dayTotals = totalsByModel[key] ?? [:] var bucket = dayTotals[model] ?? (cost: 0, requestCount: 0) bucket.cost += row.cost diff --git a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift index eb5df03a84..7477226693 100644 --- a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift @@ -345,6 +345,59 @@ struct OpenCodeGoLocalUsageReaderTests { #expect(entry.modelBreakdowns?.first?.costUSD == 4.0) } + @Test + func `whitespace only model ids fall back to the unknown model bucket`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 5.0, + model: " ") + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + let entry = try #require(snapshot.daily.first) + #expect(entry.modelsUsed == ["unknown"]) + #expect(entry.modelBreakdowns?.first?.modelName == "unknown") + #expect(entry.modelBreakdowns?.first?.costUSD == 5.0) + } + + @Test + func `model ids with incidental whitespace merge with the trimmed model bucket`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 2.0, + model: "claude-sonnet-4-5") + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T12:00:00.000Z"), + cost: 3.0, + model: " claude-sonnet-4-5 ") + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-06T15:00:00.000Z")) / 1000) + let snapshot = try reader.fetch(now: now, historyDays: 30) + + let entry = try #require(snapshot.daily.first) + #expect(entry.modelsUsed == ["claude-sonnet-4-5"]) + let breakdowns = try #require(entry.modelBreakdowns) + #expect(breakdowns.count == 1) + #expect(breakdowns.first?.modelName == "claude-sonnet-4-5") + #expect(breakdowns.first?.costUSD == 5.0) + #expect(breakdowns.first?.requestCount == 2) + } + @Test func `missing auth and history is not detected`() throws { let env = try Self.makeEnvironment()