401 lines · 16.5 KB · Swift
RawDownload
import Foundation #if canImport(SQLite3)import SQLite3#elseif canImport(CSQLite3)import CSQLite3#endif #if canImport(SQLite3) || canImport(CSQLite3)public enum OpenCodeGoLocalUsageError: LocalizedError, Sendable, Equatable {    case notDetected    case historyUnavailable(String)    case sqliteFailed(String)     public var errorDescription: String? {        switch self {        case .notDetected:            "OpenCode Go not detected. Log in with OpenCode Go or use it locally first."        case let .historyUnavailable(message):            "OpenCode Go local usage history is unavailable: \(message)"        case let .sqliteFailed(message):            "SQLite error reading OpenCode Go usage: \(message)"        }    }} public struct OpenCodeGoLocalUsageReader: Sendable {    private static let fiveHours: TimeInterval = 5 * 60 * 60    private static let week: TimeInterval = 7 * 24 * 60 * 60    private static let limits = (session: 12.0, weekly: 30.0, monthly: 60.0)     private let authURL: URL    private let databaseURL: URL     public init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) {        let openCodeDirectory = homeDirectory            .appendingPathComponent(".local", isDirectory: true)            .appendingPathComponent("share", isDirectory: true)            .appendingPathComponent("opencode", isDirectory: true)        self.authURL = openCodeDirectory.appendingPathComponent("auth.json", isDirectory: false)        self.databaseURL = openCodeDirectory.appendingPathComponent("opencode.db", isDirectory: false)    }     public init(authURL: URL, databaseURL: URL) {        self.authURL = authURL        self.databaseURL = databaseURL    }     public func fetch(now: Date = Date(), historyDays: Int = 30) throws -> OpenCodeGoUsageSnapshot {        let hasAuth = Self.hasAuthKey(at: self.authURL)        guard FileManager.default.fileExists(atPath: self.databaseURL.path) else {            if hasAuth {                throw OpenCodeGoLocalUsageError.historyUnavailable("database not found")            }            throw OpenCodeGoLocalUsageError.notDetected        }         let rows = try self.readRows()        guard hasAuth || !rows.isEmpty else {            throw OpenCodeGoLocalUsageError.notDetected        }        guard !rows.isEmpty else {            throw OpenCodeGoLocalUsageError.historyUnavailable("no local usage rows")        }        return Self.snapshot(rows: rows, now: now, historyDays: historyDays)    }     private func readRows() throws -> [UsageRow] {        var db: OpaquePointer?        guard sqlite3_open_v2(self.databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else {            let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error"            sqlite3_close(db)            throw OpenCodeGoLocalUsageError.sqliteFailed(message)        }        defer { sqlite3_close(db) }        sqlite3_busy_timeout(db, 250)         let sql = self.hasTable(named: "part", db: db) ? Self.messageAndPartUsageSQL : Self.messageUsageSQL         var stmt: OpaquePointer?        guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {            let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error"            throw OpenCodeGoLocalUsageError.sqliteFailed(message)        }        defer { sqlite3_finalize(stmt) }         var rows: [UsageRow] = []        while true {            let step = sqlite3_step(stmt)            if step == SQLITE_DONE {                break            }            guard step == SQLITE_ROW else {                let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error"                throw OpenCodeGoLocalUsageError.sqliteFailed(message)            }             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)))            rows.append(UsageRow(createdMs: createdMs, cost: cost, requestCount: requestCount))        }        return rows    }     private func hasTable(named name: String, db: OpaquePointer?) -> Bool {        var stmt: OpaquePointer?        guard sqlite3_prepare_v2(            db,            "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",            -1,            &stmt,            nil) == SQLITE_OK        else {            return false        }        defer { sqlite3_finalize(stmt) }         let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)        sqlite3_bind_text(stmt, 1, name, -1, transient)        return sqlite3_step(stmt) == SQLITE_ROW    }     private static let messageUsageSQL = """        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        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          FROM message          WHERE json_valid(data)            AND json_extract(data, '$.providerID') = 'opencode-go'            AND json_extract(data, '$.role') = 'assistant'        )        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        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        FROM provider_messages m        WHERE hasCost          AND NOT EXISTS (            SELECT 1            FROM part p            WHERE p.message_id = m.messageID              AND json_valid(p.data)              AND json_extract(p.data, '$.type') = 'step-finish'              AND json_type(p.data, '$.cost') IN ('integer', 'real')          )    """     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    }     private static func hasAuthKey(at url: URL) -> Bool {        guard let data = try? Data(contentsOf: url),              let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],              let entry = object["opencode-go"] as? [String: Any],              let key = entry["key"] as? String        else {            return false        }        return !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty    }     private static func snapshot(rows: [UsageRow], now: Date, historyDays: Int) -> OpenCodeGoUsageSnapshot {        let nowMs = Int64(now.timeIntervalSince1970 * 1000)        let sessionStart = nowMs - Int64(Self.fiveHours * 1000)        let weekStart = self.startOfUTCWeek(now: now).timeIntervalSince1970 * 1000        let weekStartMs = Int64(weekStart)        let weekEndMs = weekStartMs + Int64(Self.week * 1000)        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        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),            rollingResetInSec: rollingResetInSec,            weeklyResetInSec: max(0, Int((weekEndMs - nowMs) / 1000)),            monthlyResetInSec: max(0, Int((monthBounds.endMs - nowMs) / 1000)),            daily: self.dailyEntries(rows: rows, now: now, historyDays: historyDays),            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.    private static func dailyEntries(        rows: [UsageRow],        now: Date,        historyDays: Int) -> [CostUsageDailyReport.Entry]    {        let clampedHistoryDays = max(1, min(365, historyDays))        let calendar = Calendar.current        guard let since = calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) else {            return []        }        let sinceStartOfDay = calendar.startOfDay(for: since)         var totals: [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)            bucket.cost += row.cost            bucket.requestCount += row.requestCount            totals[key] = bucket        }         return totals.keys.sorted().compactMap { key in            guard let bucket = totals[key] else { return nil }            return CostUsageDailyReport.Entry(                date: key,                inputTokens: nil,                outputTokens: nil,                totalTokens: nil,                requestCount: bucket.requestCount,                costUSD: bucket.cost,                modelsUsed: nil,                modelBreakdowns: nil)        }    }     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))        return (value * 10).rounded() / 10    }     private static func startOfUTCWeek(now: Date) -> Date {        var calendar = Calendar(identifier: .gregorian)        calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current        calendar.firstWeekday = 2        calendar.minimumDaysInFirstWeek = 4        let components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now)        return calendar.date(from: components) ?? now    }     private static func monthBounds(now: Date, anchorMs: Int64?) -> (startMs: Int64, endMs: Int64) {        var calendar = Calendar(identifier: .gregorian)        calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current         guard let anchorMs else {            let start = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) ?? now            let end = calendar.date(byAdding: .month, value: 1, to: start) ?? start            return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000))        }         let anchor = Date(timeIntervalSince1970: TimeInterval(anchorMs) / 1000)        let anchorComponents = calendar.dateComponents([.day, .hour, .minute, .second, .nanosecond], from: anchor)        let nowComponents = calendar.dateComponents([.year, .month], from: now)         var startMonthComponents = nowComponents        var start = self.anchoredMonth(calendar: calendar, month: startMonthComponents, anchor: anchorComponents)        if start > now {            guard let previous = calendar.date(byAdding: .month, value: -1, to: start) else {                let end = self.anchoredMonth(                    calendar: calendar,                    month: self.monthComponents(after: startMonthComponents, calendar: calendar),                    anchor: anchorComponents)                return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000))            }            startMonthComponents = calendar.dateComponents([.year, .month], from: previous)            start = self.anchoredMonth(calendar: calendar, month: startMonthComponents, anchor: anchorComponents)        }        let end = self.anchoredMonth(            calendar: calendar,            month: self.monthComponents(after: startMonthComponents, calendar: calendar),            anchor: anchorComponents)        return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000))    }     private static func monthComponents(after month: DateComponents, calendar: Calendar) -> DateComponents {        let monthStart = calendar.date(from: month) ?? Date()        let nextMonth = calendar.date(byAdding: .month, value: 1, to: monthStart) ?? monthStart        return calendar.dateComponents([.year, .month], from: nextMonth)    }     private static func anchoredMonth(        calendar: Calendar,        month: DateComponents,        anchor: DateComponents) -> Date    {        var components = DateComponents()        components.calendar = calendar        components.timeZone = calendar.timeZone        components.year = month.year        components.month = month.month        components.day = anchor.day        components.hour = anchor.hour        components.minute = anchor.minute        components.second = anchor.second        components.nanosecond = anchor.nanosecond         if let date = calendar.date(from: components),           calendar.component(.month, from: date) == month.month        {            return date        }         components.day = calendar.range(of: .day, in: .month, for: calendar.date(from: month) ?? Date())?.count        return calendar.date(from: components) ?? Date()    }} #else public enum OpenCodeGoLocalUsageError: LocalizedError, Sendable, Equatable {    case notSupported     public var errorDescription: String? {        "OpenCode Go local usage is only supported on macOS."    }} public struct OpenCodeGoLocalUsageReader: Sendable {    public init(homeDirectory _: URL = FileManager.default.homeDirectoryForCurrentUser) {}    public init(authURL _: URL, databaseURL _: URL) {}     public func fetch(now _: Date = Date(), historyDays _: Int = 30) throws -> OpenCodeGoUsageSnapshot {        throw OpenCodeGoLocalUsageError.notSupported    }} #endif