Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -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 (
Expand All @@ -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 {
Expand Down Expand Up @@ -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 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
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))
Expand Down
155 changes: 154 additions & 1 deletion Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,151 @@ 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 `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()
Expand Down Expand Up @@ -329,7 +474,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) }
Expand All @@ -343,6 +493,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) ?? "{}"

Expand Down
5 changes: 5 additions & 0 deletions docs/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.