From 0bbed3b23c7a34b3ed383956ad3ae254d447a83c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 25 Sep 2026 10:55:55 -0700 Subject: [PATCH] fix(opencodego): include recorded local token counts Preserve recorded tokens in local daily and model history, prefer step-finish parts, and leave incomplete or invalid totals unknown. Reuse shared daily aggregation and remove duplicate local query and quota wrappers without changing recorded costs. Closes #3995 Co-authored-by: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> --- CHANGELOG.md | 1 + .../OpenCodeGoLocalUsageReader.swift | 196 ++++++++-------- .../OpenCodeGoLocalUsageReaderTests.swift | 221 +++++++++++++++++- .../SpendActivityHeatmapTests.swift | 35 +++ docs/opencode.md | 6 + 5 files changed, 349 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e95193998c..190a79efa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ ### Fixed +- OpenCode Go: include recorded local token counts in daily and per-model history without inventing costs or treating missing counts as zero (#3995). Thanks @Yuxin-Qiao! - Security: preserve browser-cookie denial across restarts and CLI configuration, and stage credential writes privately before atomic replacement (reported in #3986). Thanks @bo-vavrik! - Provider plugins: update bundled QuickJS-NG to 0.17.0 with upstream memory-safety and numeric-correctness fixes (#3987). Thanks @bo-vavrik! - OpenRouter: explain the required API key field instead of reporting no available fetch strategy, and clarify where regular and Management keys belong (#3966, #3969). Thanks @harjothkhara! diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift index f3e29c4e73..36dfa780b9 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift @@ -99,7 +99,7 @@ public struct OpenCodeGoLocalUsageReader: Sendable { let sql = try self.hasTable(named: "part", db: db) ? Self.messageAndPartUsageSQL - : Self.messageUsageSQL + : "SELECT createdMs, cost, modelID, tokens FROM (\(Self.providerMessagesSQL)) WHERE hasCost" var stmt: OpaquePointer? let prepareResult = sqlite3_prepare_v2(db, sql, -1, &stmt, nil) @@ -121,9 +121,15 @@ public struct OpenCodeGoLocalUsageReader: Sendable { let createdMs = sqlite3_column_int64(stmt, 0) 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))) - let model = sqlite3_column_text(stmt, 3).map { String(cString: $0) } ?? "" - rows.append(UsageRow(createdMs: createdMs, cost: cost, requestCount: requestCount, model: model)) + let model = sqlite3_column_text(stmt, 2).map { String(cString: $0) } ?? "" + let tokens = sqlite3_column_text(stmt, 3).flatMap { json in + try? JSONDecoder().decode(TokenCounts.self, from: Data(String(cString: json).utf8)) + } + rows.append(UsageRow( + createdMs: createdMs, + cost: cost, + model: model, + tokens: tokens.flatMap { $0.isValid ? $0 : nil })) } return rows } @@ -162,45 +168,37 @@ public struct OpenCodeGoLocalUsageReader: Sendable { message: db.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown error") } - private static let messageUsageSQL = """ + private static let providerMessagesSQL = """ SELECT + 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, - 1 AS requestCount, - COALESCE(json_extract(data, '$.modelID'), '') AS modelID + json_type(data, '$.cost') IN ('integer', 'real') AS hasCost, + COALESCE(json_extract(data, '$.modelID'), '') AS modelID, + CASE WHEN json_type(data, '$.tokens') = 'object' + THEN json_extract(data, '$.tokens') END AS tokens FROM message WHERE json_valid(data) AND json_extract(data, '$.providerID') = 'opencode-go' AND json_extract(data, '$.role') = 'assistant' - AND json_type(data, '$.cost') IN ('integer', 'real') """ private static let messageAndPartUsageSQL = """ - WITH provider_messages AS ( - SELECT - 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, - COALESCE(json_extract(data, '$.modelID'), '') AS modelID - FROM message - WHERE json_valid(data) - AND json_extract(data, '$.providerID') = 'opencode-go' - AND json_extract(data, '$.role') = 'assistant' - ) + WITH provider_messages AS (\(Self.providerMessagesSQL)) SELECT 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, - m.modelID AS modelID + m.modelID AS modelID, + CASE WHEN json_type(p.data, '$.tokens') = 'object' + THEN json_extract(p.data, '$.tokens') END AS tokens 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, modelID + SELECT createdMs, cost, modelID, tokens FROM provider_messages m WHERE hasCost AND NOT EXISTS ( @@ -216,10 +214,36 @@ public struct OpenCodeGoLocalUsageReader: Sendable { private struct UsageRow { let createdMs: Int64 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 + let tokens: TokenCounts? + } + + private struct TokenCounts: Decodable { + struct Cache: Decodable { + let read: Int? + let write: Int? + } + + let total: Int? + let input: Int? + let output: Int? + let reasoning: Int? + let cache: Cache? + + private var components: [Int?] { + [self.input, self.output, self.reasoning, self.cache?.read, self.cache?.write] + } + + var resolvedTotal: Int? { + // OpenCode separates output from reasoning and input from cache counts. + self.total ?? (self.components.allSatisfy { $0 != nil } + ? CheckedSum.integers(self.components.compactMap(\.self)) : nil) + } + + var isValid: Bool { + (self.components + [self.total]).allSatisfy { ($0 ?? 0) >= 0 } && self.resolvedTotal != nil + } } private struct SQLiteReadFailure: Error { @@ -247,24 +271,29 @@ public struct OpenCodeGoLocalUsageReader: Sendable { let earliestMs = rows.map(\.createdMs).min() let monthBounds = self.monthBounds(now: now, anchorMs: earliestMs) - // Single pass over `rows` for all three window sums plus the oldest-in-session timestamp, - // rather than four separate full scans (one per window plus one for the reset countdown). - let windows = RowAggregateWindows( - sessionStartMs: sessionStart, - nowMs: nowMs, - weekStartMs: weekStartMs, - weekEndMs: weekEndMs, - monthStartMs: monthBounds.startMs, - monthEndMs: monthBounds.endMs) - let aggregates = self.aggregate(rows: rows, windows: windows) - let oldestSessionMs = aggregates.oldestSessionMs ?? nowMs + var sessionCost = 0.0 + var weeklyCost = 0.0 + var monthlyCost = 0.0 + var oldestSessionMs = nowMs + for row in rows { + if row.createdMs >= sessionStart, row.createdMs < nowMs { + sessionCost += row.cost + oldestSessionMs = min(oldestSessionMs, row.createdMs) + } + if row.createdMs >= weekStartMs, row.createdMs < weekEndMs { + weeklyCost += row.cost + } + if row.createdMs >= monthBounds.startMs, row.createdMs < monthBounds.endMs { + monthlyCost += row.cost + } + } let rollingResetInSec = max(0, Int((oldestSessionMs + Int64(Self.fiveHours * 1000) - nowMs) / 1000)) return OpenCodeGoUsageSnapshot( hasMonthlyUsage: true, - rollingUsagePercent: self.percent(used: aggregates.sessionCost, limit: self.limits.session), - weeklyUsagePercent: self.percent(used: aggregates.weeklyCost, limit: self.limits.weekly), - monthlyUsagePercent: self.percent(used: aggregates.monthlyCost, limit: self.limits.monthly), + rollingUsagePercent: self.percent(used: sessionCost, limit: self.limits.session), + weeklyUsagePercent: self.percent(used: weeklyCost, limit: self.limits.weekly), + monthlyUsagePercent: self.percent(used: monthlyCost, limit: self.limits.monthly), rollingResetInSec: rollingResetInSec, weeklyResetInSec: max(0, Int((weekEndMs - nowMs) / 1000)), monthlyResetInSec: max(0, Int((monthBounds.endMs - nowMs) / 1000)), @@ -272,41 +301,6 @@ public struct OpenCodeGoLocalUsageReader: Sendable { updatedAt: now) } - private struct RowAggregateWindows { - let sessionStartMs: Int64 - let nowMs: Int64 - let weekStartMs: Int64 - let weekEndMs: Int64 - let monthStartMs: Int64 - let monthEndMs: Int64 - } - - private struct RowAggregates { - var sessionCost: Double = 0 - var weeklyCost: Double = 0 - var monthlyCost: Double = 0 - var oldestSessionMs: Int64? - } - - private static func aggregate(rows: [UsageRow], windows: RowAggregateWindows) -> RowAggregates { - var result = RowAggregates() - for row in rows { - if row.createdMs >= windows.sessionStartMs, row.createdMs < windows.nowMs { - result.sessionCost += row.cost - if result.oldestSessionMs.map({ row.createdMs < $0 }) ?? true { - result.oldestSessionMs = row.createdMs - } - } - if row.createdMs >= windows.weekStartMs, row.createdMs < windows.weekEndMs { - result.weeklyCost += row.cost - } - if row.createdMs >= windows.monthStartMs, row.createdMs < windows.monthEndMs { - result.monthlyCost += row.cost - } - } - return result - } - /// Buckets local `opencode-go` message costs into calendar-day entries (device local time, /// matching how Codex/Claude cost history is keyed) so the cost history chart can render a /// per-day bar chart the same way it does for those providers. @@ -322,43 +316,39 @@ public struct OpenCodeGoLocalUsageReader: Sendable { } let sinceStartOfDay = calendar.startOfDay(for: since) - var totalsByModel: [String: [String: (cost: Double, requestCount: Int)]] = [:] - for row in rows { + let entries: [CostUsageDailyReport.Entry] = rows.compactMap { row in let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) - guard date >= sinceStartOfDay, date <= now else { continue } + guard date >= sinceStartOfDay, date <= now else { return nil } let key = CostUsageScanner.CostUsageDayRange.dayKey(from: date) let trimmedModel = row.model.trimmingCharacters(in: .whitespacesAndNewlines) - let model = trimmedModel.isEmpty ? Self.unknownModelName : trimmedModel - totalsByModel[key, default: [:]][model, default: (0, 0)].cost += row.cost - totalsByModel[key, default: [:]][model, default: (0, 0)].requestCount += row.requestCount - } - - 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 } + let model = trimmedModel.isEmpty ? "unknown" : trimmedModel + let tokens = row.tokens + let breakdown = CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: row.cost, + totalTokens: tokens?.resolvedTotal, + requestCount: 1, + inputTokens: tokens?.input, + outputTokens: tokens?.output, + cacheReadTokens: tokens?.cache?.read, + cacheCreationTokens: tokens?.cache?.write, + reasoningTokens: tokens?.reasoning) return CostUsageDailyReport.Entry( date: key, - inputTokens: nil, - outputTokens: nil, - totalTokens: nil, - requestCount: totalRequests, - costUSD: totalCost, - modelsUsed: dayTotals.keys.sorted(), - modelBreakdowns: modelBreakdowns) + inputTokens: tokens?.input, + outputTokens: tokens?.output, + cacheReadTokens: tokens?.cache?.read, + cacheCreationTokens: tokens?.cache?.write, + reasoningTokens: tokens?.reasoning, + totalTokens: tokens?.resolvedTotal, + requestCount: 1, + costUSD: row.cost, + modelsUsed: [model], + modelBreakdowns: [breakdown]) } + return CostUsageDailyReport.merged([.init(data: entries, summary: nil)], calendar: calendar).data } - /// 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 64f4bc0e46..6f30723639 100644 --- a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift @@ -374,22 +374,23 @@ struct OpenCodeGoLocalUsageReaderTests { 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) } + let reference = Date(timeIntervalSince1970: 1_772_798_400) + let now = Calendar.current.startOfDay(for: reference).addingTimeInterval(12 * 3600) 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"), + createdMs: Self.ms(now.addingTimeInterval(-3600)), cost: 2.0, model: "claude-sonnet-4-5") try Self.insertMessage( databaseURL: env.databaseURL, - createdMs: Self.ms("2026-03-06T12:00:00.000Z"), + createdMs: Self.ms(now.addingTimeInterval(-1800)), 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) @@ -401,6 +402,204 @@ struct OpenCodeGoLocalUsageReaderTests { #expect(breakdowns.first?.requestCount == 2) } + @Test(arguments: [false, true]) + func `daily entries carry message token counts per day and model`(includeParts: Bool) throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + let reference = Date(timeIntervalSince1970: 1_772_798_400) + let now = Calendar.current.startOfDay(for: reference).addingTimeInterval(12 * 3600) + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL, includeParts: includeParts) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms(now.addingTimeInterval(-3600)), + cost: 3.0, + model: "claude-sonnet-4-5", + tokens: [ + "total": 1600, + "input": 100, + "output": 20, + "reasoning": 30, + "cache": ["read": 1400, "write": 50], + ]) + // Older OpenCode rows omit `total`; it is the sum of every component. + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms(now.addingTimeInterval(-1800)), + cost: 2.0, + model: "gpt-5.1-codex", + tokens: ["input": 10, "output": 5, "reasoning": 1, "cache": ["read": 4, "write": 0]]) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: now, historyDays: 30) + + let entry = try #require(snapshot.daily.first) + #expect(entry.totalTokens == 1620) + #expect(entry.inputTokens == 110) + #expect(entry.outputTokens == 25) + #expect(entry.reasoningTokens == 31) + #expect(entry.cacheReadTokens == 1404) + #expect(entry.cacheCreationTokens == 50) + let breakdowns = try #require(entry.modelBreakdowns) + #expect(breakdowns.first { $0.modelName == "claude-sonnet-4-5" }?.totalTokens == 1600) + #expect(breakdowns.first { $0.modelName == "gpt-5.1-codex" }?.totalTokens == 20) + + let tokenSnapshot = snapshot.toCostUsageTokenSnapshot(historyDays: 30) + #expect(tokenSnapshot.last30DaysTokens == 1620) + } + + @Test + func `step finish tokens replace message tokens when parts exist`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + let reference = Date(timeIntervalSince1970: 1_772_798_400) + let now = Calendar.current.startOfDay(for: reference).addingTimeInterval(12 * 3600) + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + let messageID = try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms(now.addingTimeInterval(-3600)), + cost: 3.0, + tokens: ["total": 999, "input": 999]) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms(now.addingTimeInterval(-3600)), + cost: 1.0, + tokens: ["total": 40, "input": 30, "output": 10]) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms(now.addingTimeInterval(-3300)), + cost: 2.0, + tokens: ["total": 60, "input": 50, "output": 10]) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: now) + + let entry = try #require(snapshot.daily.first) + #expect(entry.totalTokens == 100) + #expect(entry.inputTokens == 80) + #expect(entry.outputTokens == 20) + } + + @Test + func `a day with a tokenless row keeps its token total unknown`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + let reference = Date(timeIntervalSince1970: 1_772_798_400) + let now = Calendar.current.startOfDay(for: reference).addingTimeInterval(12 * 3600) + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms(now.addingTimeInterval(-3600)), + cost: 3.0, + model: "claude-sonnet-4-5", + tokens: ["total": 50, "input": 50]) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms(now.addingTimeInterval(-1800)), + cost: 1.0, + model: "claude-sonnet-4-5") + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: now, historyDays: 30) + + let entry = try #require(snapshot.daily.first) + #expect(entry.costUSD == 4.0) + #expect(entry.totalTokens == nil) + #expect(entry.modelBreakdowns?.first?.totalTokens == nil) + #expect(snapshot.toCostUsageTokenSnapshot(historyDays: 30).last30DaysTokens == nil) + } + + @Test(arguments: [ + #"{}"#, + #"[]"#, + #"12"#, + #"true"#, + #""{\"total\": 10}""#, + #"{"input": 10, "output": 5}"#, + #"{"total": 10, "input": "10"}"#, + #"{"total": 10, "input": true}"#, + #"{"total": 10, "input": 1.5}"#, + #"{"total": 10, "input": -1}"#, + #"{"total": -1}"#, + #"{"total": 9223372036854775808}"#, + #"{"input": 9223372036854775807, "output": 1, "reasoning": 0, "cache": {"read": 0, "write": 0}}"#, + ]) + func `invalid or incomplete token records preserve cost without inventing totals`(json: String) throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let tokens = try JSONSerialization.jsonObject(with: Data(json.utf8), options: [.fragmentsAllowed]) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 2, tokens: tokens) + + let snapshot = try OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + .fetch(now: now) + let entry = try #require(snapshot.daily.first) + #expect(entry.costUSD == 2) + #expect(entry.requestCount == 1) + #expect(entry.totalTokens == nil) + #expect(entry.inputTokens == nil) + #expect(entry.modelBreakdowns?.first?.totalTokens == nil) + #expect(snapshot.toCostUsageTokenSnapshot().last30DaysTokens == nil) + } + + @Test + func `explicit zero total does not invent missing component counts or dollars`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 0, tokens: ["total": 0]) + + let snapshot = try OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + .fetch(now: now) + let entry = try #require(snapshot.daily.first) + #expect(entry.totalTokens == 0) + #expect(entry.inputTokens == nil) + #expect(entry.outputTokens == nil) + #expect(entry.reasoningTokens == nil) + #expect(entry.cacheReadTokens == nil) + #expect(entry.cacheCreationTokens == nil) + #expect(entry.costUSD == 0) + #expect(snapshot.toCostUsageTokenSnapshot().last30DaysTokens == 0) + } + + @Test(arguments: [false, true]) + func `overflowing day totals stay unknown across later rows`(differentModels: Bool) throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + for (index, count) in [Int.max, 1, 10].enumerated() { + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms(now), + cost: 1, + model: differentModels ? "test-model-\(index)" : "test-model", + tokens: ["total": count]) + } + + let snapshot = try OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + .fetch(now: now) + let entry = try #require(snapshot.daily.first) + #expect(entry.totalTokens == nil) + #expect(entry.requestCount == 3) + #expect(entry.costUSD == 3) + if !differentModels { + #expect(entry.modelBreakdowns?.first?.totalTokens == nil) + } + #expect(snapshot.toCostUsageTokenSnapshot().last30DaysTokens == nil) + } + @Test func `missing auth and history is not detected`() throws { let env = try Self.makeEnvironment() @@ -432,7 +631,7 @@ struct OpenCodeGoLocalUsageReaderTests { try data.write(to: url) } - private static func createDatabase(at url: URL) throws { + private static func createDatabase(at url: URL, includeParts: Bool = true) throws { var db: OpaquePointer? guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } defer { sqlite3_close(db) } @@ -455,6 +654,9 @@ struct OpenCodeGoLocalUsageReaderTests { time_updated INTEGER ); """) + if !includeParts { + try Self.exec(db: db, sql: "DROP TABLE part") + } } private static func configureIdleWAL(at url: URL) throws { @@ -481,7 +683,8 @@ struct OpenCodeGoLocalUsageReaderTests { databaseURL: URL, createdMs: Int64, cost: Double?, - model: String? = nil) throws -> String + model: String? = nil, + tokens: Any? = nil) throws -> String { var db: OpaquePointer? guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } @@ -499,6 +702,9 @@ struct OpenCodeGoLocalUsageReaderTests { if let model { payload["modelID"] = model } + if let tokens { + payload["tokens"] = tokens + } let data = try JSONSerialization.data(withJSONObject: payload) let json = String(data: data, encoding: .utf8) ?? "{}" @@ -526,7 +732,8 @@ struct OpenCodeGoLocalUsageReaderTests { databaseURL: URL, messageID: String, createdMs: Int64, - cost: Double) throws + cost: Double, + tokens: [String: Any] = ["input": 1, "output": 1, "total": 2]) throws { var db: OpaquePointer? guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } @@ -535,7 +742,7 @@ struct OpenCodeGoLocalUsageReaderTests { let payload: [String: Any] = [ "type": "step-finish", "cost": cost, - "tokens": ["input": 1, "output": 1, "total": 2], + "tokens": tokens, ] let data = try JSONSerialization.data(withJSONObject: payload) let json = String(data: data, encoding: .utf8) ?? "{}" diff --git a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift index 0e8736c599..8f5e52c229 100644 --- a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift +++ b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift @@ -217,6 +217,41 @@ struct SpendActivityHeatmapTests { #expect(point?.isScanned == true) } + @Test + func `OpenCode Go token history keeps shared activity days covered`() throws { + let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16, hour: 12))) + let today = Self.calendar.startOfDay(for: now) + let yesterday = try #require(Self.calendar.date(byAdding: .day, value: -1, to: today)) + let openCodeGo = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 0, + weeklyUsagePercent: 0, + monthlyUsagePercent: 0, + rollingResetInSec: 0, + weeklyResetInSec: 0, + monthlyResetInSec: 0, + daily: [Self.entry(day: "2026-07-16", cost: 2, tokens: 60)], + updatedAt: now).toCostUsageTokenSnapshot(historyDays: 30) + let claude = Self.snapshot( + entries: [ + Self.entry(day: "2026-07-15", cost: 1, tokens: 15), + Self.entry(day: "2026-07-16", cost: 1, tokens: 40), + ], + historyDays: 30, + last30DaysTokens: 55) + let model = SpendDashboardModel.build( + inputs: [ + .init(id: "claude", provider: .claude, displayName: "Claude", snapshot: claude), + .init(id: "opencodego", provider: .opencodego, displayName: "OpenCode Go", snapshot: openCodeGo), + ], + requestedDays: 30, + now: now, + calendar: Self.calendar) + + #expect(model.tokenActivity.first { $0.day == today }?.totalTokens == 100) + #expect(model.tokenActivity.first { $0.day == yesterday }?.totalTokens == 15) + } + @Test func `covered empty history is zero while unestablished history remains unavailable`() throws { let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) diff --git a/docs/opencode.md b/docs/opencode.md index 2f1a4180c2..09473dfc61 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -108,3 +108,9 @@ usage is a separate [OpenAI provider](openai.md), not Codex subscription quota. 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. +- Local history also includes recorded input, output, reasoning, cache-read, cache-write, and total tokens per day + and model. Step-finish parts take precedence over their parent message so multi-step sessions are not counted + twice. Explicit totals are used as recorded; older rows without a total sum the five complete token components. + Missing, malformed, negative, or overflowing counts remain unknown rather than becoming zero. A day containing + a row without usable tokens has no complete token total. These device-local counts add history detail only: + they do not change account quota, and costs still come from the recorded `cost` field, never token pricing.