diff --git a/Sources/OpenUsage/Providers/ErrorCategory.swift b/Sources/OpenUsage/Providers/ErrorCategory.swift index ad6a3e4e7..d7493ff6f 100644 --- a/Sources/OpenUsage/Providers/ErrorCategory.swift +++ b/Sources/OpenUsage/Providers/ErrorCategory.swift @@ -177,6 +177,11 @@ extension OpenCodeUsageError: CategorizedError { switch self { case .notLoggedIn: .notLoggedIn case .credentialsUnreadable, .databaseUnreadable: .credentialAccess + case .unauthorized: .authExpired + case .noGoSubscription: .notAvailable + case .connectionFailed: .network + case .invalidResponse: .decoding + case .requestFailed(let status): ErrorCategory.http(status) } } } diff --git a/Sources/OpenUsage/Providers/OpenCode/OpenCodeAuthStore.swift b/Sources/OpenUsage/Providers/OpenCode/OpenCodeAuthStore.swift index 6b34be89d..0ff10d369 100644 --- a/Sources/OpenUsage/Providers/OpenCode/OpenCodeAuthStore.swift +++ b/Sources/OpenUsage/Providers/OpenCode/OpenCodeAuthStore.swift @@ -1,8 +1,8 @@ import Foundation -/// Reads the OpenCode Go/Zen credential already on the machine. Local-only — never the network. The -/// `opencode-go` key is both the first-run detection signal and (for a future `/zen/go/v1/usage` API) -/// the Bearer token, so it lives behind one loader. +/// Reads the OpenCode Go credential already on the machine. Local-only — never the network. The +/// `opencode-go` key is both the first-run detection signal and the Bearer token for +/// `GET /zen/go/v1/usage`, so it lives behind one loader. struct OpenCodeAuthStore: Sendable { var files: TextFileAccessing var environment: EnvironmentReading diff --git a/Sources/OpenUsage/Providers/OpenCode/OpenCodeGoWindows.swift b/Sources/OpenUsage/Providers/OpenCode/OpenCodeGoWindows.swift deleted file mode 100644 index 6e5564742..000000000 --- a/Sources/OpenUsage/Providers/OpenCode/OpenCodeGoWindows.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Foundation - -/// The three OpenCode Go plan windows, as observed-local spend against the published caps -/// ($12 / rolling 5h, $30 / week, $60 / month). Built by `OpenCodeGoWindowMath` from the local -/// `opencode-go` messages; only the meters read these (the spend tiles use combined hosted spend). -struct OpenCodeGoWindows: Sendable, Equatable { - var sessionSpend: Double - var sessionResetsAt: Date? - var weeklySpend: Double - var weeklyResetsAt: Date? - var monthlySpend: Double - var monthlyResetsAt: Date? - var monthlyPeriodMs: Int? -} - -/// Window math ported faithfully from the legacy `opencode-go` plugin (and matching CodexBar): a rolling -/// 5-hour session, a UTC-ISO week (Monday start), and a month anchored to the day-of-month of the -/// earliest-ever local Go usage (calendar-month fallback when there is none). Pure and UTC-based so it is -/// deterministic and unit-testable; `now`/anchor come from the caller. -enum OpenCodeGoWindowMath { - static let fiveHoursMs = Double(MetricPeriod.sessionMs) - static let weekMs = Double(MetricPeriod.weekMs) - - private static let utc: Calendar = { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(identifier: "UTC")! - return calendar - }() - - /// - Parameters: - /// - costs: `(timestampMs, cost)` for every local `opencode-go` assistant message in range; only - /// rows inside a window contribute to that window. - /// - anchorMs: earliest-ever `opencode-go` usage (ms) for the monthly cycle anchor; `nil` → UTC - /// calendar month. - static func compute(costs: [(ms: Double, cost: Double)], anchorMs: Double?, now: Date) -> OpenCodeGoWindows { - let nowMs = ms(now) - - let sessionStart = nowMs - fiveHoursMs - let sessionSpend = sumRange(costs, start: sessionStart, end: nowMs) - let oldestInSession = costs.lazy.filter { $0.ms >= sessionStart && $0.ms < nowMs }.map(\.ms).min() - let sessionResetsAt = date(ms: (oldestInSession ?? nowMs) + fiveHoursMs) - - let weekStart = startOfUtcWeek(nowMs) - let weekEnd = weekStart + weekMs - let weeklySpend = sumRange(costs, start: weekStart, end: weekEnd) - - let month = anchoredMonthBounds(nowMs: nowMs, anchorMs: anchorMs) - let monthlySpend = sumRange(costs, start: month.start, end: month.end) - - return OpenCodeGoWindows( - sessionSpend: sessionSpend, - sessionResetsAt: sessionResetsAt, - weeklySpend: weeklySpend, - weeklyResetsAt: date(ms: weekEnd), - monthlySpend: monthlySpend, - monthlyResetsAt: date(ms: month.end), - monthlyPeriodMs: Int((month.end - month.start).rounded()) - ) - } - - private static func sumRange(_ costs: [(ms: Double, cost: Double)], start: Double, end: Double) -> Double { - let total = costs.reduce(0.0) { partial, row in - (row.ms >= start && row.ms < end) ? partial + row.cost : partial - } - // Snap to a hundredth of a cent to shed float-summation noise before the meter divides by the cap. - return (total * 10000).rounded() / 10000 - } - - // MARK: - Week - - private static func startOfUtcWeek(_ nowMs: Double) -> Double { - let startOfToday = utc.startOfDay(for: date(ms: nowMs)) - let weekday = utc.component(.weekday, from: startOfToday) // 1=Sun ... 7=Sat - let daysSinceMonday = (weekday + 5) % 7 // Mon→0, Sun→6 - let monday = utc.date(byAdding: .day, value: -daysSinceMonday, to: startOfToday) ?? startOfToday - return ms(monday) - } - - // MARK: - Month (anchored to earliest usage's day-of-month) - - private static func anchoredMonthBounds(nowMs: Double, anchorMs: Double?) -> (start: Double, end: Double) { - guard let anchorMs, anchorMs.isFinite else { - let components = utc.dateComponents([.year, .month], from: date(ms: nowMs)) - let start = utcDate(year: components.year!, month: components.month!, day: 1) - let end = utcDate(year: components.year!, month: components.month! + 1, day: 1) - return (ms(start), ms(end)) - } - - let anchor = date(ms: anchorMs) - let nowComponents = utc.dateComponents([.year, .month], from: date(ms: nowMs)) - var year = nowComponents.year! - var month = nowComponents.month! // 1-based - var start = anchoredMonthStart(year: year, month: month, anchor: anchor) - - // The current calendar month's anchored start can land in the future (anchor day-of-month is later - // than today) — then the live cycle actually started last month. - if ms(start) > nowMs { - (year, month) = shiftMonth(year: year, month: month, delta: -1) - start = anchoredMonthStart(year: year, month: month, anchor: anchor) - } - let (nextYear, nextMonth) = shiftMonth(year: year, month: month, delta: 1) - let end = anchoredMonthStart(year: nextYear, month: nextMonth, anchor: anchor) - return (ms(start), ms(end)) - } - - /// The anchored cycle start within a given month: the anchor's day-of-month (clamped to the month's - /// length) at the anchor's time-of-day, in UTC. - private static func anchoredMonthStart(year: Int, month: Int, anchor: Date) -> Date { - let anchorParts = utc.dateComponents([.day, .hour, .minute, .second, .nanosecond], from: anchor) - let day = min(anchorParts.day ?? 1, daysInMonth(year: year, month: month)) - return utcDate( - year: year, month: month, day: day, - hour: anchorParts.hour ?? 0, minute: anchorParts.minute ?? 0, - second: anchorParts.second ?? 0, nanosecond: anchorParts.nanosecond ?? 0 - ) - } - - private static func shiftMonth(year: Int, month: Int, delta: Int) -> (year: Int, month: Int) { - // `month` is 1-based; move to 0-based for the modular arithmetic, then back. - let total = year * 12 + (month - 1) + delta - let normalizedMonth = ((total % 12) + 12) % 12 - return (Int((Double(total) / 12).rounded(.down)), normalizedMonth + 1) - } - - private static func daysInMonth(year: Int, month: Int) -> Int { - let first = utcDate(year: year, month: month, day: 1) - return utc.range(of: .day, in: .month, for: first)?.count ?? 28 - } - - private static func utcDate( - year: Int, month: Int, day: Int, - hour: Int = 0, minute: Int = 0, second: Int = 0, nanosecond: Int = 0 - ) -> Date { - var components = DateComponents() - components.year = year - components.month = month // Calendar normalizes out-of-range month/day (matches JS Date.UTC) - components.day = day - components.hour = hour - components.minute = minute - components.second = second - components.nanosecond = nanosecond - return utc.date(from: components) ?? Date(timeIntervalSince1970: 0) - } - - private static func ms(_ date: Date) -> Double { date.timeIntervalSince1970 * 1000 } - private static func date(ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } -} diff --git a/Sources/OpenUsage/Providers/OpenCode/OpenCodeProvider.swift b/Sources/OpenUsage/Providers/OpenCode/OpenCodeProvider.swift index f96c2f5d4..53b4f0c0d 100644 --- a/Sources/OpenUsage/Providers/OpenCode/OpenCodeProvider.swift +++ b/Sources/OpenUsage/Providers/OpenCode/OpenCodeProvider.swift @@ -8,8 +8,15 @@ enum OpenCodeUsageError: Error, LocalizedError, Equatable { /// carries the underlying cause for the log file; the user-facing description stays friendly. case credentialsUnreadable(detail: String) /// OpenCode databases exist on disk but none could be read this refresh. Failing loudly here beats - /// rendering authoritative-looking $0 meters from an empty scan. + /// rendering authoritative-looking $0 tiles from an empty scan. case databaseUnreadable + case connectionFailed + case invalidResponse + case requestFailed(Int) + /// The local Go key was rejected (HTTP 401 / `AuthError`). + case unauthorized + /// Valid key, but this account has no Go subscription (HTTP 403 / `EntitlementError`). + case noGoSubscription var errorDescription: String? { switch self { @@ -19,13 +26,22 @@ enum OpenCodeUsageError: Error, LocalizedError, Equatable { return "Couldn't read OpenCode's auth.json. Check its file permissions or log into OpenCode Go again." case .databaseUnreadable: return "Couldn't read OpenCode's local database. Quit OpenCode and refresh, or check the data directory's permissions." + case .connectionFailed: + return ProviderUsageErrorText.connectionFailed + case .invalidResponse: + return ProviderUsageErrorText.invalidResponse + case .requestFailed(let status): + return ProviderUsageErrorText.requestFailed(statusCode: status) + case .unauthorized: + return "OpenCode Go key was rejected. Log into OpenCode Go again." + case .noGoSubscription: + return "No OpenCode Go subscription on this key." } } } -/// Tracks OpenCode-hosted usage (the Go subscription + the Zen pay-as-you-go gateway) from OpenCode's -/// local SQLite logs. Cookie-free and network-free — see `OpenCodeUsageScanner`. The card shows the Go -/// plan caps as dollar meters plus honest local spend tiles + a usage trend. +/// Tracks OpenCode-hosted usage: Go plan windows from the official usage API, plus local spend tiles +/// and a usage trend from OpenCode's SQLite logs (Go + Zen). @MainActor final class OpenCodeProvider: ProviderRuntime { let provider = Provider( @@ -38,6 +54,7 @@ final class OpenCodeProvider: ProviderRuntime { ) let authStore: OpenCodeAuthStore + let usageClient: OpenCodeUsageClient let usageScanner: OpenCodeUsageScanner let now: @Sendable () -> Date @@ -52,24 +69,26 @@ final class OpenCodeProvider: ProviderRuntime { init( authStore: OpenCodeAuthStore = OpenCodeAuthStore(), + usageClient: OpenCodeUsageClient = OpenCodeUsageClient(), usageScanner: OpenCodeUsageScanner = OpenCodeUsageScanner(), now: @escaping @Sendable () -> Date = Date.init ) { self.authStore = authStore + self.usageClient = usageClient self.usageScanner = usageScanner self.now = now } var widgetDescriptors: [WidgetDescriptor] { - // Go plan caps read from local `opencode-go` spend (Session/Weekly above the fold, Monthly on - // demand); the spend tiles + trend below sum combined OpenCode-hosted (Go + Zen) spend. + // Go plan windows from `/zen/go/v1/usage` (Session/Weekly/Monthly + trend above the fold); + // the spend tiles below sum combined OpenCode-hosted (Go + Zen) spend from local logs. [ - .boundedDollars(id: "opencode.session", provider: provider, title: "Session", limit: OpenCodeUsageMapper.sessionCap) - .exportingLimit("session", unit: "usd", estimated: true), - .boundedDollars(id: "opencode.weekly", provider: provider, title: "Weekly", limit: OpenCodeUsageMapper.weeklyCap) - .exportingLimit("weekly", unit: "usd", estimated: true), - .boundedDollars(id: "opencode.monthly", provider: provider, title: "Monthly", limit: OpenCodeUsageMapper.monthlyCap) - .exportingLimit("monthly", unit: "usd", estimated: true), + .percent(id: "opencode.session", provider: provider, title: "Session", isSessionWindow: true) + .exportingLimit("session", unit: "percent"), + .percent(id: "opencode.weekly", provider: provider, title: "Weekly") + .exportingLimit("weekly", unit: "percent"), + .percent(id: "opencode.monthly", provider: provider, title: "Monthly") + .exportingLimit("monthly", unit: "percent"), .usageTrend(provider: provider) .exportingHistory( scope: .machineLocal, @@ -98,12 +117,10 @@ final class OpenCodeProvider: ProviderRuntime { // can't straddle a midnight boundary. let refreshedAt = now() - // An unreadable auth.json must not kill a refresh that can still read the database (a Zen user - // stays live), but it stays distinguishable from "not logged in" when nothing else loads. - var hasGoKey = false + var goKey: String? var authReadError: OpenCodeUsageError? do { - hasGoKey = try await loadOffMainActor { [authStore] in try authStore.goAPIKey() != nil } + goKey = try await loadOffMainActor { [authStore] in try authStore.goAPIKey() } loggedAuthReadFailure = false } catch let error as OpenCodeUsageError { authReadError = error @@ -115,56 +132,102 @@ final class OpenCodeProvider: ProviderRuntime { authReadError = .credentialsUnreadable(detail: error.localizedDescription) } + var meterLines: [MetricLine] = [] + var plan: String? + if let goKey { + switch await fetchGoMeters(apiKey: goKey) { + case .meters(let lines): + meterLines = lines + plan = "Go" + case .noSubscription: + AppLog.info(LogTag.plugin("opencode"), "Go usage endpoint: no active subscription") + case .failed(let error): + return ProviderSnapshot.error(provider: provider, error: error) + } + } + let scan: OpenCodeUsageScan? do { - scan = try await usageScanner.scan(now: refreshedAt, hasGoKey: hasGoKey) + scan = try await usageScanner.scan(now: refreshedAt) } catch { - return ProviderSnapshot.error(provider: provider, error: error) + if meterLines.isEmpty { + return ProviderSnapshot.error(provider: provider, error: error) + } + AppLog.warn( + LogTag.plugin("opencode"), + "local database unreadable; showing Go meters only: \(error.localizedDescription)" + ) + scan = nil } - guard let scan else { - // No OpenCode database on disk at all. - if hasGoKey { - // Freshly logged into Go, before the first local message: the key alone establishes the - // plan, so show the published caps at $0 rather than a bare "No usage data". - let windows = OpenCodeGoWindowMath.compute(costs: [], anchorMs: nil, now: refreshedAt) - return ProviderSnapshot.make( - provider: provider, plan: "Go", - lines: OpenCodeUsageMapper.meterLines(windows), refreshedAt: refreshedAt - ) - } - return ProviderSnapshot.error( - provider: provider, error: authReadError ?? OpenCodeUsageError.notLoggedIn + var lines = meterLines + if let scan { + SpendTileMapper.appendTokenUsage( + scan.logScan.series, to: &lines, now: refreshedAt, + estimated: false, + unknownModelsByDay: scan.logScan.unknownModelsByDay, + modelUsage: scan.logScan.modelUsage, + modelSourceNote: sourceNote ) + SpendTileMapper.appendUsageTrend(scan.logScan.series, to: &lines, now: refreshedAt, note: sourceNote) } - var lines: [MetricLine] = [] - if let windows = scan.goWindows { - lines.append(contentsOf: OpenCodeUsageMapper.meterLines(windows)) + if lines.isEmpty { + if goKey != nil { + return ProviderSnapshot.error(provider: provider, error: OpenCodeUsageError.noGoSubscription) + } + if scan == nil { + return ProviderSnapshot.error( + provider: provider, error: authReadError ?? OpenCodeUsageError.notLoggedIn + ) + } } - SpendTileMapper.appendTokenUsage( - scan.logScan.series, to: &lines, now: refreshedAt, - estimated: false, - unknownModelsByDay: scan.logScan.unknownModelsByDay, - modelUsage: scan.logScan.modelUsage, - modelSourceNote: sourceNote - ) - SpendTileMapper.appendUsageTrend(scan.logScan.series, to: &lines, now: refreshedAt, note: sourceNote) MetricLine.appendNoDataIfNeeded(&lines) - // `goWindows` is present only on a current Go signal (key or recent spend), never a stale anchor, - // so it's the honest source for the plan badge too. - let plan: String? = scan.goWindows != nil ? "Go" : nil return ProviderSnapshot.make( provider: provider, plan: plan, lines: lines, refreshedAt: refreshedAt, - usageHistory: ProviderUsageHistory( - series: scan.logScan.series, - modelUsage: scan.logScan.modelUsage, - unknownModelsByDay: scan.logScan.unknownModelsByDay - ) + usageHistory: scan.map { + ProviderUsageHistory( + series: $0.logScan.series, + modelUsage: $0.logScan.modelUsage, + unknownModelsByDay: $0.logScan.unknownModelsByDay + ) + } ) } + + private enum GoFetch { + case meters([MetricLine]) + case noSubscription + case failed(OpenCodeUsageError) + } + + private func fetchGoMeters(apiKey: String) async -> GoFetch { + let response: HTTPResponse + do { + response = try await usageClient.fetchUsage(apiKey: apiKey) + } catch { + return .failed(.connectionFailed) + } + + if response.statusCode == 401 { + return .failed(.unauthorized) + } + if response.statusCode == 403, OpenCodeUsageMapper.errorType(in: response) == "EntitlementError" { + return .noSubscription + } + guard (200..<300).contains(response.statusCode) else { + return .failed(.requestFailed(response.statusCode)) + } + do { + return .meters(try OpenCodeUsageMapper.meterLines(response)) + } catch let error as OpenCodeUsageError { + return .failed(error) + } catch { + return .failed(.invalidResponse) + } + } } diff --git a/Sources/OpenUsage/Providers/OpenCode/OpenCodeUsageClient.swift b/Sources/OpenUsage/Providers/OpenCode/OpenCodeUsageClient.swift new file mode 100644 index 000000000..12a6ee611 --- /dev/null +++ b/Sources/OpenUsage/Providers/OpenCode/OpenCodeUsageClient.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Calls OpenCode's official Go usage endpoint with the local `opencode-go` API key. +struct OpenCodeUsageClient: Sendable { + static let usageURL = URL(string: "https://opencode.ai/zen/go/v1/usage")! + + var http: any HTTPClient + + init(http: any HTTPClient = URLSessionHTTPClient()) { + self.http = http + } + + func fetchUsage(apiKey: String) async throws -> HTTPResponse { + try await http.send(HTTPRequest( + method: "GET", + url: Self.usageURL, + headers: [ + "Authorization": "Bearer \(apiKey)", + "Accept": "application/json" + ], + timeout: 15 + )) + } +} diff --git a/Sources/OpenUsage/Providers/OpenCode/OpenCodeUsageMapper.swift b/Sources/OpenUsage/Providers/OpenCode/OpenCodeUsageMapper.swift index ee8374bd0..c1c349c66 100644 --- a/Sources/OpenUsage/Providers/OpenCode/OpenCodeUsageMapper.swift +++ b/Sources/OpenUsage/Providers/OpenCode/OpenCodeUsageMapper.swift @@ -1,28 +1,52 @@ import Foundation -/// Turns the Go plan windows into the three cap meters. The published OpenCode Go caps are dollar-based, -/// so each is a `.dollars` progress meter of observed-local spend against its cap. Local spend can only -/// undercount true account usage (this machine only), which is why the card leads with these caps but -/// also shows honest spend tiles. +/// Turns `GET /zen/go/v1/usage` into the three Go plan meters. The endpoint reports percent used +/// (same numbers as the OpenCode dashboard) plus an ISO reset time — not dollar spend — so each row +/// is a `.percent` progress meter. enum OpenCodeUsageMapper { - static let sessionCap: Double = 12 // per rolling 5 hours - static let weeklyCap: Double = 30 // per UTC week - static let monthlyCap: Double = 60 // per anchored month + static func meterLines(_ response: HTTPResponse) throws -> [MetricLine] { + guard let body = ProviderParse.jsonObject(response.body) else { + throw OpenCodeUsageError.invalidResponse + } + return try meterLines(body: body) + } - static func meterLines(_ windows: OpenCodeGoWindows) -> [MetricLine] { - [ - .progress( - label: "Session", used: windows.sessionSpend, limit: sessionCap, format: .dollars, - resetsAt: windows.sessionResetsAt, periodDurationMs: MetricPeriod.sessionMs - ), - .progress( - label: "Weekly", used: windows.weeklySpend, limit: weeklyCap, format: .dollars, - resetsAt: windows.weeklyResetsAt, periodDurationMs: MetricPeriod.weekMs - ), - .progress( - label: "Monthly", used: windows.monthlySpend, limit: monthlyCap, format: .dollars, - resetsAt: windows.monthlyResetsAt, periodDurationMs: windows.monthlyPeriodMs - ) + static func meterLines(body: [String: Any]) throws -> [MetricLine] { + guard let usage = body["usage"] as? [String: Any] else { + throw OpenCodeUsageError.invalidResponse + } + return [ + try window(usage["rolling"], label: "Session", periodMs: MetricPeriod.sessionMs), + try window(usage["weekly"], label: "Weekly", periodMs: MetricPeriod.weekMs), + try window(usage["monthly"], label: "Monthly", periodMs: MetricPeriod.monthMs) ] } + + /// The upstream error discriminator (`AuthError`, `EntitlementError`, …), when the body is the + /// documented `{ type, error: { type, message } }` shape. `nil` for HTML/Cloudflare/empty bodies. + static func errorType(in response: HTTPResponse) -> String? { + guard let body = ProviderParse.jsonObject(response.body), + let error = body["error"] as? [String: Any], + let type = (error["type"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !type.isEmpty + else { return nil } + return type + } + + private static func window(_ raw: Any?, label: String, periodMs: Int) throws -> MetricLine { + guard let object = raw as? [String: Any], + let percent = ProviderParse.number(object["percent"]) + else { + throw OpenCodeUsageError.invalidResponse + } + let resetsAt = (object["resetsAt"] as? String).flatMap(OpenUsageISO8601.date(from:)) + return .progress( + label: label, + used: ProviderParse.clampPercent(percent), + limit: 100, + format: .percent, + resetsAt: resetsAt, + periodDurationMs: periodMs + ) + } } diff --git a/Sources/OpenUsage/Providers/OpenCode/OpenCodeUsageScanner.swift b/Sources/OpenUsage/Providers/OpenCode/OpenCodeUsageScanner.swift index 1e12d6dc3..192fe041f 100644 --- a/Sources/OpenUsage/Providers/OpenCode/OpenCodeUsageScanner.swift +++ b/Sources/OpenUsage/Providers/OpenCode/OpenCodeUsageScanner.swift @@ -1,17 +1,14 @@ import Foundation -/// The result of a local OpenCode scan: the combined-hosted daily series (for the spend tiles + trend, -/// via `SpendTileMapper`) and the Go-only plan windows (for the meters). `goWindows` is `nil` when the -/// machine has no `opencode-go` footprint at all, so a Zen-only user sees spend tiles without empty caps. +/// The result of a local OpenCode scan: the combined-hosted daily series for the spend tiles + trend. struct OpenCodeUsageScan: Sendable { var logScan: LogUsageScan - var goWindows: OpenCodeGoWindows? } -/// Reads OpenCode's local SQLite logs (`~/.local/share/opencode/opencode*.db`, all release channels) and -/// builds the usage the provider renders. Cookie-free and network-free: the per-message `cost` OpenCode -/// writes for its own hosted gateways is authoritative (Zen models aren't in our pricing snapshots), so -/// it is summed directly rather than re-priced. +/// Reads OpenCode's local SQLite logs (`~/.local/share/opencode/opencode*.db`, all release channels) +/// for the spend tiles and usage trend. Cookie-free: the per-message `cost` OpenCode writes for its +/// own hosted gateways is authoritative (Zen models aren't in our pricing snapshots), so it is summed +/// directly rather than re-priced. Go plan windows come from the usage API, not this scan. /// /// A `Sendable` struct (like the Grok scanner), `async` and nonisolated, so the SQLite reads run off the /// main actor when the `@MainActor` provider `await`s it. @@ -19,7 +16,6 @@ struct OpenCodeUsageScanner: Sendable { /// The OpenCode-hosted providerIDs we track: the Go subscription and the Zen pay-as-you-go gateway. /// Both write an authoritative `cost`; other (BYO-key) providerIDs log `cost: 0` and are out of scope. static let hostedProviderIDs = ["opencode-go", "opencode"] - static let goProviderID = "opencode-go" var sqlite: SQLiteAccessing var databasePaths: @Sendable () throws -> [String] @@ -46,13 +42,10 @@ struct OpenCodeUsageScanner: Sendable { return try OpenCodePaths.databaseFiles(in: dir) } - /// Scan the last `daysBack` days. Returns `nil` only when there is no OpenCode database at all (→ the - /// provider shows "No data"); a present-but-empty database yields an empty scan (idle tiles collapse - /// to "No data" via `SpendTileMapper`). Throws `databaseUnreadable` when databases exist but none - /// could be read — an all-failed refresh has no data source and must not render as zero usage. - /// 33 days covers the widest meter window (anchored month) plus slack; the tiles/trend are - /// re-bounded to 31 calendar days below. - func scan(now: Date, daysBack: Int = 33, hasGoKey: Bool = false) async throws -> OpenCodeUsageScan? { + /// Scan the last `daysBack` days. Returns `nil` only when there is no OpenCode database at all; + /// a present-but-empty database yields an empty scan (idle tiles collapse to "No data" via + /// `SpendTileMapper`). Throws `databaseUnreadable` when databases exist but none could be read. + func scan(now: Date, daysBack: Int = 30) async throws -> OpenCodeUsageScan? { let paths: [String] do { paths = try databasePaths() @@ -71,9 +64,11 @@ struct OpenCodeUsageScanner: Sendable { return nil } - let cutoffMs = Int((now.timeIntervalSince1970 - Double(daysBack) * 86_400) * 1000) + // Same calendar bound the tiles/trend use. A wall-clock `now - daysBack×86400` cutoff sits + // later the same day, so morning rows on the oldest day never leave SQLite. + let tileSince = JSONLScanning.sinceDate(daysBack: daysBack, now: now) + let cutoffMs = Int(tileSince.timeIntervalSince1970 * 1000) var rows: [Row] = [] - var anchorMs: Double? var checked: Set = [] var failures: [String: String] = [:] @@ -87,12 +82,6 @@ struct OpenCodeUsageScanner: Sendable { failures[path] = error.localizedDescription continue } - // Monthly cycle anchor: the earliest-ever local Go usage (unbounded, so it survives the - // day-window cutoff). Cheap and best-effort — a failure just falls back to the calendar month. - if let text = (try? sqlite.queryValue(path: path, sql: Self.anchorSQL)) ?? nil, - let value = Double(text.trimmingCharacters(in: .whitespacesAndNewlines)) { - anchorMs = Swift.min(anchorMs ?? value, value) - } } // Per-path detail is logged only for newly failing paths (the reporter edge-triggers), so a // persistently locked database warns once, not on every 5-minute refresh. @@ -104,9 +93,6 @@ struct OpenCodeUsageScanner: Sendable { throw OpenCodeUsageError.databaseUnreadable } - // Combined hosted daily series (opencode-go + opencode) → the spend tiles + usage trend. Cost is - // authoritative, so every row is "priced": feed it straight into the shared accumulator. - let tileSince = JSONLScanning.sinceDate(daysBack: 30, now: now) var accumulator = DailyUsageAccumulator() for row in rows { let date = Date(timeIntervalSince1970: row.ms / 1000) @@ -116,20 +102,7 @@ struct OpenCodeUsageScanner: Sendable { tokens: row.tokens, cost: row.cost, model: row.model ) } - let logScan = accumulator.build() - - // Go-only windows → the Session / Weekly / Monthly caps. Shown only on a CURRENT Go signal: the - // user is logged into Go (`hasGoKey`), or has spent on Go within the window. A stale anchor from - // old usage must NOT resurrect the caps or the "Go" plan for a lapsed or Zen-only user — the - // anchor only sets the monthly-cycle boundary once we've decided to show the meters. - let goCosts = rows - .filter { $0.providerID == Self.goProviderID } - .map { (ms: $0.ms, cost: $0.cost) } - let goWindows: OpenCodeGoWindows? = (hasGoKey || !goCosts.isEmpty) - ? OpenCodeGoWindowMath.compute(costs: goCosts, anchorMs: anchorMs, now: now) - : nil - - return OpenCodeUsageScan(logScan: logScan, goWindows: goWindows) + return OpenCodeUsageScan(logScan: accumulator.build()) } /// Cheap local probe for `hasLocalCredentials()`: does any tracked database hold at least one hosted @@ -163,7 +136,6 @@ struct OpenCodeUsageScanner: Sendable { var cost: Double var tokens: Int var model: String - var providerID: String } /// Parse the `json_group_array(json_array(...))` payload: an array of @@ -180,19 +152,13 @@ struct OpenCodeUsageScanner: Sendable { guard let entry = element as? [Any], entry.count >= 5, let ms = ProviderParse.number(entry[0]), let cost = ProviderParse.number(entry[1]), cost >= 0, - let providerID = entry[4] as? String + entry[4] is String else { continue } // Clamp before the Int conversion so a corrupt, absurdly large token count can't trap // (Int(Double) crashes above Int.max). 1e15 is far above any real token total. let tokens = Int(min(max(ProviderParse.number(entry[2]) ?? 0, 0), 1e15)) let model = (entry[3] as? String) ?? "" - rows.append(Row( - ms: ms, - cost: cost, - tokens: tokens, - model: model, - providerID: providerID - )) + rows.append(Row(ms: ms, cost: cost, tokens: tokens, model: model)) } return rows } @@ -219,14 +185,6 @@ struct OpenCodeUsageScanner: Sendable { """ } - static let anchorSQL = """ - SELECT MIN(time_created) FROM message - WHERE json_valid(data) - AND json_extract(data,'$.role') = 'assistant' - AND json_extract(data,'$.providerID') = '\(goProviderID)' - AND json_type(data,'$.cost') IN ('integer','real'); - """ - static let probeSQL = """ SELECT 1 FROM message WHERE json_valid(data) diff --git a/Tests/OpenUsageTests/OpenCodeGoWindowsTests.swift b/Tests/OpenUsageTests/OpenCodeGoWindowsTests.swift deleted file mode 100644 index 3090231a5..000000000 --- a/Tests/OpenUsageTests/OpenCodeGoWindowsTests.swift +++ /dev/null @@ -1,85 +0,0 @@ -import XCTest -@testable import OpenUsage - -/// The Go plan window math: rolling 5h session, UTC-Monday week, and the earliest-usage-anchored month -/// (with day-of-month clamping and a calendar-month fallback). -final class OpenCodeGoWindowsTests: XCTestCase { - private let utc: Calendar = { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(identifier: "UTC")! - return calendar - }() - private func d(_ iso: String) -> Date { OpenUsageISO8601.date(from: iso)! } - private func epochMs(_ iso: String) -> Double { d(iso).timeIntervalSince1970 * 1000 } - - func testSessionRolling5Hours() { - let now = d("2026-07-12T12:00:00.000Z") - let costs: [(ms: Double, cost: Double)] = [ - (ms: epochMs("2026-07-12T11:00:00.000Z"), cost: 2.0), // 1h ago, in window - (ms: epochMs("2026-07-12T08:30:00.000Z"), cost: 1.5), // 3.5h ago, in window (oldest) - (ms: epochMs("2026-07-12T06:00:00.000Z"), cost: 5.0) // 6h ago, outside 5h window - ] - let windows = OpenCodeGoWindowMath.compute(costs: costs, anchorMs: nil, now: now) - XCTAssertEqual(windows.sessionSpend, 3.5, accuracy: 0.0001) - // Reset is 5h after the oldest in-window row (08:30 → 13:30). - XCTAssertEqual(windows.sessionResetsAt, d("2026-07-12T13:30:00.000Z")) - } - - func testSessionResetIsFiveHoursAheadWhenIdle() { - let now = d("2026-07-12T12:00:00.000Z") - let windows = OpenCodeGoWindowMath.compute(costs: [], anchorMs: nil, now: now) - XCTAssertEqual(windows.sessionSpend, 0, accuracy: 0.0001) - XCTAssertEqual(windows.sessionResetsAt, d("2026-07-12T17:00:00.000Z")) - } - - func testWeeklyUTCMondayBoundary() { - let now = d("2026-07-12T12:00:00.000Z") // Sunday - let costs: [(ms: Double, cost: Double)] = [ - (ms: epochMs("2026-07-06T00:00:00.000Z"), cost: 4.0), // Monday 00:00 — first instant of week - (ms: epochMs("2026-07-05T23:59:59.000Z"), cost: 9.0), // just before week start — excluded - (ms: epochMs("2026-07-12T11:00:00.000Z"), cost: 1.0) // in week - ] - let windows = OpenCodeGoWindowMath.compute(costs: costs, anchorMs: nil, now: now) - XCTAssertEqual(windows.weeklySpend, 5.0, accuracy: 0.0001) - XCTAssertEqual(windows.weeklyResetsAt, d("2026-07-13T00:00:00.000Z")) - XCTAssertEqual(utc.component(.weekday, from: windows.weeklyResetsAt!), 2) // Monday - } - - func testMonthlyAnchoredToEarliestDayOfMonth() { - let now = d("2026-07-12T12:00:00.000Z") - let anchor = epochMs("2026-03-05T09:30:00.000Z") // day 5 @ 09:30 - let costs: [(ms: Double, cost: Double)] = [ - (ms: epochMs("2026-07-05T09:30:00.000Z"), cost: 10.0), // cycle-start instant — included - (ms: epochMs("2026-07-05T09:29:59.000Z"), cost: 7.0), // one second before — excluded - (ms: epochMs("2026-07-11T00:00:00.000Z"), cost: 5.0) // in cycle - ] - let windows = OpenCodeGoWindowMath.compute(costs: costs, anchorMs: anchor, now: now) - XCTAssertEqual(windows.monthlySpend, 15.0, accuracy: 0.0001) - XCTAssertEqual(windows.monthlyResetsAt, d("2026-08-05T09:30:00.000Z")) - let start = d("2026-07-05T09:30:00.000Z") - let end = d("2026-08-05T09:30:00.000Z") - XCTAssertEqual(windows.monthlyPeriodMs, Int((end.timeIntervalSince1970 - start.timeIntervalSince1970) * 1000)) - } - - func testMonthlyAnchorLaterInMonthUsesPreviousCycle() { - let now = d("2026-07-12T12:00:00.000Z") - let anchor = epochMs("2026-01-20T00:00:00.000Z") // day 20 — after today's 12th - let windows = OpenCodeGoWindowMath.compute(costs: [], anchorMs: anchor, now: now) - // July 20 start is in the future, so the live cycle is June 20 → July 20. - XCTAssertEqual(windows.monthlyResetsAt, d("2026-07-20T00:00:00.000Z")) - } - - func testMonthlyAnchorDayClampedForShortMonth() { - let now = d("2026-06-15T12:00:00.000Z") // June has 30 days - let anchor = epochMs("2026-01-31T00:00:00.000Z") // day 31 - let windows = OpenCodeGoWindowMath.compute(costs: [], anchorMs: anchor, now: now) - // June clamps 31→30; June 30 start is future → cycle is May 31 → June 30. - XCTAssertEqual(windows.monthlyResetsAt, d("2026-06-30T00:00:00.000Z")) - } - - func testMonthlyCalendarFallbackWithoutAnchor() { - let now = d("2026-07-12T12:00:00.000Z") - let windows = OpenCodeGoWindowMath.compute(costs: [], anchorMs: nil, now: now) - XCTAssertEqual(windows.monthlyResetsAt, d("2026-08-01T00:00:00.000Z")) - } -} diff --git a/Tests/OpenUsageTests/OpenCodeProviderTests.swift b/Tests/OpenUsageTests/OpenCodeProviderTests.swift index 63267543c..37219e141 100644 --- a/Tests/OpenUsageTests/OpenCodeProviderTests.swift +++ b/Tests/OpenUsageTests/OpenCodeProviderTests.swift @@ -1,8 +1,8 @@ import XCTest @testable import OpenUsage -/// End-to-end provider behavior: detection via the Go auth key or local usage, and a refresh that yields -/// the Go meters + combined spend tiles + trend, plus the not-logged-in path. +/// End-to-end provider behavior: detection via the Go auth key or local usage, Go meters from the +/// usage API, and local spend tiles + trend, plus auth/empty paths. @MainActor final class OpenCodeProviderTests: XCTestCase { private func d(_ iso: String) -> Date { OpenUsageISO8601.date(from: iso)! } @@ -11,6 +11,7 @@ final class OpenCodeProviderTests: XCTestCase { "[\(epochMs(iso)),\(cost),\(tokens),\"\(model)\",\"\(provider)\"]" } private let authJSON = #"{"opencode-go":{"type":"api","key":"sk-test"}}"# + private let now = OpenUsageISO8601.date(from: "2026-07-12T12:00:00.000Z")! private func authStore(files: TextFileAccessing) -> OpenCodeAuthStore { OpenCodeAuthStore( @@ -20,10 +21,41 @@ final class OpenCodeProviderTests: XCTestCase { ) } + private func usageJSON(rolling: Int = 12, weekly: Int = 8, monthly: Int = 35) -> Data { + let body: [String: Any] = [ + "usage": [ + "rolling": ["status": "ok", "percent": rolling, "resetsAt": "2026-07-12T17:00:00.000Z"], + "weekly": ["status": "ok", "percent": weekly, "resetsAt": "2026-07-13T00:00:00.000Z"], + "monthly": ["status": "ok", "percent": monthly, "resetsAt": "2026-08-04T11:18:32.000Z"] + ] + ] + return try! JSONSerialization.data(withJSONObject: body) + } + + private func okClient() -> OpenCodeUsageClient { + OpenCodeUsageClient(http: FakeHTTPClient(response: HTTPResponse( + statusCode: 200, headers: [:], body: usageJSON() + ))) + } + + private func provider( + files: TextFileAccessing, + scanner: OpenCodeUsageScanner, + client: OpenCodeUsageClient? = nil + ) -> OpenCodeProvider { + let now = self.now + return OpenCodeProvider( + authStore: authStore(files: files), + usageClient: client ?? okClient(), + usageScanner: scanner, + now: { now } + ) + } + func testHasLocalCredentialsViaGoAuthKey() async { - let provider = OpenCodeProvider( - authStore: authStore(files: FakeFiles(["/oc/auth.json": authJSON])), - usageScanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }) + let provider = provider( + files: FakeFiles(["/oc/auth.json": authJSON]), + scanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }) ) let has = await provider.hasLocalCredentials() XCTAssertTrue(has) @@ -31,9 +63,9 @@ final class OpenCodeProviderTests: XCTestCase { func testHasLocalCredentialsViaLocalUsage() async { let db = "[" + row("2026-07-12T10:00:00.000Z", "1.0", 500, "gpt-5.5", "opencode") + "]" - let provider = OpenCodeProvider( - authStore: authStore(files: FakeFiles()), - usageScanner: OpenCodeUsageScanner( + let provider = provider( + files: FakeFiles(), + scanner: OpenCodeUsageScanner( sqlite: StubSQLite(data: ["/oc/opencode.db": db]), databasePaths: { ["/oc/opencode.db"] } ) @@ -43,9 +75,9 @@ final class OpenCodeProviderTests: XCTestCase { } func testHasLocalCredentialsFalseWhenAbsent() async { - let provider = OpenCodeProvider( - authStore: authStore(files: FakeFiles()), - usageScanner: OpenCodeUsageScanner( + let provider = provider( + files: FakeFiles(), + scanner: OpenCodeUsageScanner( sqlite: StubSQLite(data: ["/oc/opencode.db": "[]"]), databasePaths: { ["/oc/opencode.db"] } ) @@ -55,23 +87,32 @@ final class OpenCodeProviderTests: XCTestCase { } func testRefreshProducesMetersTilesAndTrend() async { - let now = d("2026-07-12T12:00:00.000Z") let db = "[" + [ row("2026-07-12T11:00:00.000Z", "2.0", 1000, "glm-5.2", "opencode-go"), row("2026-07-12T10:00:00.000Z", "1.0", 500, "gpt-5.5", "opencode") ].joined(separator: ",") + "]" - let provider = OpenCodeProvider( - authStore: authStore(files: FakeFiles(["/oc/auth.json": authJSON])), - usageScanner: OpenCodeUsageScanner( + let http = FakeHTTPClient(response: HTTPResponse(statusCode: 200, headers: [:], body: usageJSON())) + let snapshot = await provider( + files: FakeFiles(["/oc/auth.json": authJSON]), + scanner: OpenCodeUsageScanner( sqlite: StubSQLite(data: ["/oc/opencode.db": db]), databasePaths: { ["/oc/opencode.db"] } ), - now: { now } - ) - let snapshot = await provider.refresh() + client: OpenCodeUsageClient(http: http) + ).refresh() + XCTAssertEqual(snapshot.plan, "Go") XCTAssertNil(snapshot.errorCategory) - XCTAssertNotNil(snapshot.line(label: "Session")) + XCTAssertEqual(http.requests.count, 1) + XCTAssertEqual(http.requests.first?.url, OpenCodeUsageClient.usageURL) + XCTAssertEqual(http.requests.first?.headers["Authorization"], "Bearer sk-test") + + guard case let .progress(_, used, limit, format, _, _, _)? = snapshot.line(label: "Session") else { + return XCTFail("expected a Session meter") + } + XCTAssertEqual(used, 12) + XCTAssertEqual(limit, 100) + XCTAssertEqual(format, .percent) XCTAssertNotNil(snapshot.line(label: "Weekly")) XCTAssertNotNil(snapshot.line(label: "Monthly")) XCTAssertNotNil(snapshot.line(label: "Usage Trend")) @@ -79,129 +120,173 @@ final class OpenCodeProviderTests: XCTestCase { } func testRefreshNotLoggedInWhenNoKeyAndNoDatabase() async { - let now = d("2026-07-12T12:00:00.000Z") - let provider = OpenCodeProvider( - authStore: authStore(files: FakeFiles()), - usageScanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }), - now: { now } - ) - let snapshot = await provider.refresh() + let snapshot = await provider( + files: FakeFiles(), + scanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }) + ).refresh() XCTAssertEqual(snapshot.errorCategory, .notLoggedIn) } - func testRefreshShowsZeroCapMetersWithGoKeyButNoDatabase() async { - // Freshly logged into Go, before the first local message: the key alone establishes the plan, - // so the published caps show at $0 instead of a bare "No usage data". - let now = d("2026-07-12T12:00:00.000Z") - let provider = OpenCodeProvider( - authStore: authStore(files: FakeFiles(["/oc/auth.json": authJSON])), - usageScanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }), - now: { now } - ) - let snapshot = await provider.refresh() + func testRefreshShowsAPIMetersWithGoKeyButNoDatabase() async { + let snapshot = await provider( + files: FakeFiles(["/oc/auth.json": authJSON]), + scanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }) + ).refresh() XCTAssertNil(snapshot.errorCategory) XCTAssertEqual(snapshot.plan, "Go") - guard case .progress(_, let used, let limit, _, _, _, _)? = snapshot.line(label: "Session") else { + guard case let .progress(_, used, limit, format, _, _, _)? = snapshot.line(label: "Session") else { return XCTFail("expected a Session meter") } - XCTAssertEqual(used, 0) - XCTAssertEqual(limit, OpenCodeUsageMapper.sessionCap) - XCTAssertNotNil(snapshot.line(label: "Weekly")) - XCTAssertNotNil(snapshot.line(label: "Monthly")) + XCTAssertEqual(used, 12) + XCTAssertEqual(limit, 100) + XCTAssertEqual(format, .percent) + XCTAssertNil(snapshot.line(label: "Today")) } - func testRefreshErrorsWhenAllDatabasesUnreadable() async { - // A valid Go key with a locked/corrupt database must surface a read error, not $0 meters. - let now = d("2026-07-12T12:00:00.000Z") - let provider = OpenCodeProvider( - authStore: authStore(files: FakeFiles(["/oc/auth.json": authJSON])), - usageScanner: OpenCodeUsageScanner( + func testRefreshKeepsGoMetersWhenDatabasesUnreadable() async { + let snapshot = await provider( + files: FakeFiles(["/oc/auth.json": authJSON]), + scanner: OpenCodeUsageScanner( sqlite: StubSQLite(failing: ["/oc/opencode.db"]), databasePaths: { ["/oc/opencode.db"] } - ), - now: { now } - ) - let snapshot = await provider.refresh() + ) + ).refresh() + XCTAssertNil(snapshot.errorCategory) + XCTAssertEqual(snapshot.plan, "Go") + XCTAssertNotNil(snapshot.line(label: "Session")) + XCTAssertNil(snapshot.line(label: "Today")) + } + + func testRefreshErrorsWhenDatabasesUnreadableWithoutGoKey() async { + let snapshot = await provider( + files: FakeFiles(), + scanner: OpenCodeUsageScanner( + sqlite: StubSQLite(failing: ["/oc/opencode.db"]), + databasePaths: { ["/oc/opencode.db"] } + ) + ).refresh() XCTAssertEqual(snapshot.errorCategory, .credentialAccess) XCTAssertNil(snapshot.line(label: "Session")) } func testRefreshSurfacesUnreadableAuthFileInsteadOfNotLoggedIn() async { - // auth.json exists but can't be read, and there's no database: broken storage, not logout. - let now = d("2026-07-12T12:00:00.000Z") - let provider = OpenCodeProvider( - authStore: authStore(files: UnreadableFiles(present: ["/oc/auth.json"])), - usageScanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }), - now: { now } - ) - let snapshot = await provider.refresh() + let snapshot = await provider( + files: UnreadableFiles(present: ["/oc/auth.json"]), + scanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }) + ).refresh() XCTAssertEqual(snapshot.errorCategory, .credentialAccess) } func testHasLocalCredentialsTrueWhenAuthFileUnreadable() async { - // An unreadable auth.json is itself an OpenCode footprint — enable the provider so refresh() - // can show the actionable error rather than staying invisible. - let provider = OpenCodeProvider( - authStore: authStore(files: UnreadableFiles(present: ["/oc/auth.json"])), - usageScanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }) + let provider = provider( + files: UnreadableFiles(present: ["/oc/auth.json"]), + scanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }) ) let has = await provider.hasLocalCredentials() XCTAssertTrue(has) } func testSpendTilesAreNotMarkedEstimated() async { - // OpenCode records its own per-message cost — the tiles must not carry the local-estimate ⓘ. - let now = d("2026-07-12T12:00:00.000Z") let db = "[" + row("2026-07-12T10:00:00.000Z", "1.0", 500, "gpt-5.5", "opencode") + "]" - let provider = OpenCodeProvider( - authStore: authStore(files: FakeFiles()), - usageScanner: OpenCodeUsageScanner( + let snapshot = await provider( + files: FakeFiles(), + scanner: OpenCodeUsageScanner( sqlite: StubSQLite(data: ["/oc/opencode.db": db]), databasePaths: { ["/oc/opencode.db"] } - ), - now: { now } - ) - let snapshot = await provider.refresh() + ) + ).refresh() guard case .values(_, let values, _, _, _, _)? = snapshot.line(label: "Today") else { return XCTFail("expected a Today tile") } XCTAssertFalse(values.contains(where: \.estimated)) + XCTAssertNil(snapshot.plan) + XCTAssertNil(snapshot.line(label: "Session")) + } + + func testUnauthorizedKeyFailsLoudly() async { + let snapshot = await provider( + files: FakeFiles(["/oc/auth.json": authJSON]), + scanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }), + client: OpenCodeUsageClient(http: FakeHTTPClient(response: HTTPResponse( + statusCode: 401, + headers: [:], + body: Data(#"{"type":"error","error":{"type":"AuthError","message":"Unauthorized"}}"#.utf8) + ))) + ).refresh() + XCTAssertEqual(snapshot.errorCategory, .authExpired) + } + + func testEntitlementErrorWithoutLocalUsageIsNoGoSubscription() async { + let snapshot = await provider( + files: FakeFiles(["/oc/auth.json": authJSON]), + scanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }), + client: OpenCodeUsageClient(http: FakeHTTPClient(response: HTTPResponse( + statusCode: 403, + headers: [:], + body: Data(#"{"type":"error","error":{"type":"EntitlementError","message":"OpenCode Go subscription required."}}"#.utf8) + ))) + ).refresh() + XCTAssertEqual(snapshot.errorCategory, .notAvailable) } - func testStaleGoHistoryDoesNotShowGoPlanOrMeters() async { - // Zen-only recent usage + an old opencode-go anchor + no Go key: no "Go" badge, no cap meters, - // but the Zen spend still shows in the tiles. - let now = d("2026-07-12T12:00:00.000Z") + func testEntitlementErrorWithZenUsageShowsTilesWithoutGoMeters() async { let db = "[" + row("2026-07-12T10:00:00.000Z", "1.0", 500, "gpt-5.5", "opencode") + "]" - let provider = OpenCodeProvider( - authStore: authStore(files: FakeFiles()), - usageScanner: OpenCodeUsageScanner( - sqlite: StubSQLite(data: ["/oc/opencode.db": db], anchor: "1700000000000"), + let snapshot = await provider( + files: FakeFiles(["/oc/auth.json": authJSON]), + scanner: OpenCodeUsageScanner( + sqlite: StubSQLite(data: ["/oc/opencode.db": db]), databasePaths: { ["/oc/opencode.db"] } ), - now: { now } - ) - let snapshot = await provider.refresh() + client: OpenCodeUsageClient(http: FakeHTTPClient(response: HTTPResponse( + statusCode: 403, + headers: [:], + body: Data(#"{"type":"error","error":{"type":"EntitlementError","message":"OpenCode Go subscription required."}}"#.utf8) + ))) + ).refresh() + XCTAssertNil(snapshot.errorCategory) XCTAssertNil(snapshot.plan) XCTAssertNil(snapshot.line(label: "Session")) XCTAssertNotNil(snapshot.line(label: "Today")) } + + func testGeneric403FailsLoudly() async { + let snapshot = await provider( + files: FakeFiles(["/oc/auth.json": authJSON]), + scanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }), + client: OpenCodeUsageClient(http: FakeHTTPClient(response: HTTPResponse( + statusCode: 403, headers: [:], body: Data("denied".utf8) + ))) + ).refresh() + XCTAssertEqual(snapshot.errorCategory, .http4xx) + } + + func testConnectionFailureFailsLoudly() async { + let snapshot = await provider( + files: FakeFiles(["/oc/auth.json": authJSON]), + scanner: OpenCodeUsageScanner(sqlite: StubSQLite(), databasePaths: { [] }), + client: OpenCodeUsageClient(http: ThrowingHTTPClient()) + ).refresh() + XCTAssertEqual(snapshot.errorCategory, .network) + } +} + +private final class ThrowingHTTPClient: HTTPClient, @unchecked Sendable { + func send(_ request: HTTPRequest) async throws -> HTTPResponse { + throw URLError(.notConnectedToInternet) + } } private final class StubSQLite: SQLiteAccessing, @unchecked Sendable { var data: [String: String] - var anchor: String? var failing: Set - init(data: [String: String] = [:], anchor: String? = nil, failing: Set = []) { + init(data: [String: String] = [:], failing: Set = []) { self.data = data - self.anchor = anchor self.failing = failing } func queryValue(path: String, sql: String) throws -> String? { if failing.contains(path) { throw SQLiteError.queryFailed("boom") } if sql.contains("json_group_array") { return data[path] } - if sql.contains("MIN(time_created)") { return anchor } if sql.contains("SELECT 1") { let payload = data[path] return (payload != nil && payload != "[]" && !(payload ?? "").isEmpty) ? "1" : nil diff --git a/Tests/OpenUsageTests/OpenCodeUsageMapperTests.swift b/Tests/OpenUsageTests/OpenCodeUsageMapperTests.swift index 92ea031d0..5b37437a5 100644 --- a/Tests/OpenUsageTests/OpenCodeUsageMapperTests.swift +++ b/Tests/OpenUsageTests/OpenCodeUsageMapperTests.swift @@ -1,40 +1,109 @@ import XCTest @testable import OpenUsage -/// The Go cap meters: correct caps, dollar format, resets, and periods. +/// Go plan meters from `/zen/go/v1/usage`: percent format, resets, periods, and boundary failures. final class OpenCodeUsageMapperTests: XCTestCase { - func testMeterLinesCarryCapsFormatsResetsAndPeriods() { - let reset = OpenUsageISO8601.date(from: "2026-07-12T13:30:00.000Z")! - let windows = OpenCodeGoWindows( - sessionSpend: 6.0, sessionResetsAt: reset, - weeklySpend: 12.0, weeklyResetsAt: reset, - monthlySpend: 40.0, monthlyResetsAt: reset, monthlyPeriodMs: 2_592_000_000 - ) - let lines = OpenCodeUsageMapper.meterLines(windows) + private let sampleBody: [String: Any] = [ + "usage": [ + "rolling": ["status": "ok", "percent": 12, "resetsAt": "2026-07-12T13:30:00.662Z"], + "weekly": ["status": "ok", "percent": 8, "resetsAt": "2026-07-13T00:00:00.662Z"], + "monthly": ["status": "rate-limited", "percent": 100, "resetsAt": "2026-08-04T11:18:32.662Z"] + ] + ] + + func testMeterLinesMatchDashboardPercentsAndResets() throws { + let lines = try OpenCodeUsageMapper.meterLines(body: sampleBody) XCTAssertEqual(lines.map(\.label), ["Session", "Weekly", "Monthly"]) guard case let .progress(_, sessionUsed, sessionLimit, sessionFormat, sessionReset, sessionPeriod, _) = lines[0] else { return XCTFail("session is not a progress line") } - XCTAssertEqual(sessionUsed, 6.0) - XCTAssertEqual(sessionLimit, 12) - XCTAssertEqual(sessionFormat, .dollars) - XCTAssertEqual(sessionReset, reset) - XCTAssertEqual(sessionPeriod, 5 * 60 * 60 * 1000) + XCTAssertEqual(sessionUsed, 12) + XCTAssertEqual(sessionLimit, 100) + XCTAssertEqual(sessionFormat, .percent) + XCTAssertEqual(sessionReset, OpenUsageISO8601.date(from: "2026-07-12T13:30:00.662Z")) + XCTAssertEqual(sessionPeriod, MetricPeriod.sessionMs) - guard case let .progress(_, _, weeklyLimit, weeklyFormat, _, weeklyPeriod, _) = lines[1] else { + guard case let .progress(_, weeklyUsed, _, weeklyFormat, weeklyReset, weeklyPeriod, _) = lines[1] else { return XCTFail("weekly is not a progress line") } - XCTAssertEqual(weeklyLimit, 30) - XCTAssertEqual(weeklyFormat, .dollars) - XCTAssertEqual(weeklyPeriod, 7 * 24 * 60 * 60 * 1000) + XCTAssertEqual(weeklyUsed, 8) + XCTAssertEqual(weeklyFormat, .percent) + XCTAssertEqual(weeklyReset, OpenUsageISO8601.date(from: "2026-07-13T00:00:00.662Z")) + XCTAssertEqual(weeklyPeriod, MetricPeriod.weekMs) - guard case let .progress(_, monthlyUsed, monthlyLimit, monthlyFormat, _, monthlyPeriod, _) = lines[2] else { + guard case let .progress(_, monthlyUsed, _, monthlyFormat, _, monthlyPeriod, _) = lines[2] else { return XCTFail("monthly is not a progress line") } - XCTAssertEqual(monthlyUsed, 40.0) - XCTAssertEqual(monthlyLimit, 60) - XCTAssertEqual(monthlyFormat, .dollars) - XCTAssertEqual(monthlyPeriod, 2_592_000_000) + XCTAssertEqual(monthlyUsed, 100) + XCTAssertEqual(monthlyFormat, .percent) + XCTAssertEqual(monthlyPeriod, MetricPeriod.monthMs) + } + + func testZeroPercentIsARealMeterNotNoData() throws { + let body: [String: Any] = [ + "usage": [ + "rolling": ["percent": 0, "resetsAt": "2026-07-12T17:00:00.000Z"], + "weekly": ["percent": 0, "resetsAt": "2026-07-13T00:00:00.000Z"], + "monthly": ["percent": 0, "resetsAt": "2026-08-04T00:00:00.000Z"] + ] + ] + let lines = try OpenCodeUsageMapper.meterLines(body: body) + guard case let .progress(_, used, limit, format, _, _, _) = lines[0] else { + return XCTFail("session is not a progress line") + } + XCTAssertEqual(used, 0) + XCTAssertEqual(limit, 100) + XCTAssertEqual(format, .percent) + } + + func testPercentIsClamped() throws { + let body: [String: Any] = [ + "usage": [ + "rolling": ["percent": 150], + "weekly": ["percent": -4], + "monthly": ["percent": 35] + ] + ] + let lines = try OpenCodeUsageMapper.meterLines(body: body) + guard case let .progress(_, rolling, _, _, _, _, _) = lines[0], + case let .progress(_, weekly, _, _, _, _, _) = lines[1] else { + return XCTFail("expected progress lines") + } + XCTAssertEqual(rolling, 100) + XCTAssertEqual(weekly, 0) + } + + func testHTTPResponseBodyRoundTrip() throws { + let data = try JSONSerialization.data(withJSONObject: sampleBody) + let lines = try OpenCodeUsageMapper.meterLines(HTTPResponse(statusCode: 200, headers: [:], body: data)) + XCTAssertEqual(lines.count, 3) + } + + func testMissingUsageOrWindowIsInvalid() { + XCTAssertThrowsError(try OpenCodeUsageMapper.meterLines(body: [:])) { error in + XCTAssertEqual(error as? OpenCodeUsageError, .invalidResponse) + } + XCTAssertThrowsError(try OpenCodeUsageMapper.meterLines(body: ["usage": ["weekly": ["percent": 1]]])) { error in + XCTAssertEqual(error as? OpenCodeUsageError, .invalidResponse) + } + } + + func testErrorTypeFromDocumentedErrorBody() { + let entitlement = """ + {"type":"error","error":{"type":"EntitlementError","message":"OpenCode Go subscription required."}} + """.data(using: .utf8)! + let auth = """ + {"type":"error","error":{"type":"AuthError","message":"Unauthorized"}} + """.data(using: .utf8)! + XCTAssertEqual( + OpenCodeUsageMapper.errorType(in: HTTPResponse(statusCode: 403, headers: [:], body: entitlement)), + "EntitlementError" + ) + XCTAssertEqual( + OpenCodeUsageMapper.errorType(in: HTTPResponse(statusCode: 401, headers: [:], body: auth)), + "AuthError" + ) + XCTAssertNil(OpenCodeUsageMapper.errorType(in: HTTPResponse(statusCode: 403, headers: [:], body: Data("".utf8)))) } } diff --git a/Tests/OpenUsageTests/OpenCodeUsageScannerTests.swift b/Tests/OpenUsageTests/OpenCodeUsageScannerTests.swift index f58db4943..cd8d866f2 100644 --- a/Tests/OpenUsageTests/OpenCodeUsageScannerTests.swift +++ b/Tests/OpenUsageTests/OpenCodeUsageScannerTests.swift @@ -1,9 +1,8 @@ import XCTest @testable import OpenUsage -/// The SQLite scanner: unions `opencode*.db` files, sums combined hosted spend for the tiles/trend, and -/// derives Go-only windows for the meters. Fed a stub `SQLiteAccessing` that returns crafted -/// `json_group_array` payloads keyed by path. +/// The SQLite scanner: unions `opencode*.db` files and sums combined hosted spend for the tiles/trend. +/// Fed a stub `SQLiteAccessing` that returns crafted `json_group_array` payloads keyed by path. final class OpenCodeUsageScannerTests: XCTestCase { private func d(_ iso: String) -> Date { OpenUsageISO8601.date(from: iso)! } private func epochMs(_ iso: String) -> Int { Int(d(iso).timeIntervalSince1970 * 1000) } @@ -14,11 +13,11 @@ final class OpenCodeUsageScannerTests: XCTestCase { private var db1: String { "[" + [ - row("2026-07-12T11:00:00.000Z", "2.0", 1000, "glm-5.2", "opencode-go"), // today, go, in session - row("2026-07-12T10:00:00.000Z", "1.0", 500, "gpt-5.5", "opencode"), // today, zen - row("2026-07-11T10:00:00.000Z", "3.0", 2000, "kimi-k2.6", "opencode-go"),// yesterday, go - row("2026-07-12T11:00:00.000Z", "null", 100, "x", "opencode-go"), // null cost → skipped - "\"garbage\"" // non-array → skipped + row("2026-07-12T11:00:00.000Z", "2.0", 1000, "glm-5.2", "opencode-go"), + row("2026-07-12T10:00:00.000Z", "1.0", 500, "gpt-5.5", "opencode"), + row("2026-07-11T10:00:00.000Z", "3.0", 2000, "kimi-k2.6", "opencode-go"), + row("2026-07-12T11:00:00.000Z", "null", 100, "x", "opencode-go"), + "\"garbage\"" ].joined(separator: ",") + "]" } private var db2: String { @@ -42,22 +41,13 @@ final class OpenCodeUsageScannerTests: XCTestCase { XCTAssertEqual(totalTokens, 4300) // 1000 + 500 + 2000 + 800 } - func testSessionSumsOnlyGoAcrossDatabases() async throws { - guard let scan = try await standardScanner().scan(now: now) else { return XCTFail("expected a scan") } - XCTAssertNotNil(scan.goWindows) - // Session window (last 5h) contains the two go rows (11:00 = 2.0, 09:00 = 4.0); the Zen row at - // 10:00 is excluded from the Go cap even though it counts toward combined spend. - XCTAssertEqual(scan.goWindows?.sessionSpend ?? -1, 6.0, accuracy: 0.0001) - } - - func testZenOnlyUsageHasNoGoWindows() async throws { + func testZenOnlyUsageStillScans() async throws { let db = "[" + row("2026-07-12T10:00:00.000Z", "1.0", 500, "gpt-5.5", "opencode") + "]" let scanner = OpenCodeUsageScanner( sqlite: FakeSQLite(data: ["/oc/opencode.db": db]), databasePaths: { ["/oc/opencode.db"] } ) guard let scan = try await scanner.scan(now: now) else { return XCTFail("expected a scan") } - XCTAssertNil(scan.goWindows) // no Go footprint → no empty cap meters XCTAssertEqual(scan.logScan.series.daily.compactMap(\.costUSD).reduce(0, +), 1.0, accuracy: 0.0001) } @@ -74,7 +64,6 @@ final class OpenCodeUsageScannerTests: XCTestCase { ) guard let scan = try await scanner.scan(now: now) else { return XCTFail("expected a scan") } XCTAssertTrue(scan.logScan.series.daily.isEmpty) - XCTAssertNil(scan.goWindows) } func testFailingDatabaseIsSkippedNotFatal() async throws { @@ -87,8 +76,6 @@ final class OpenCodeUsageScannerTests: XCTestCase { } func testAllDatabasesFailingThrowsInsteadOfEmptyScan() async { - // Every DB locked/corrupt → the refresh has no data source; an empty "success" would render - // authoritative-looking $0 meters (regression for the silent-empty-scan bug). let scanner = OpenCodeUsageScanner( sqlite: FakeSQLite(failing: ["/oc/opencode.db", "/oc/opencode-next.db"]), databasePaths: { ["/oc/opencode.db", "/oc/opencode-next.db"] } @@ -102,7 +89,6 @@ final class OpenCodeUsageScannerTests: XCTestCase { } func testUnreadableDataDirectoryThrowsInsteadOfNil() async { - // The data dir exists but can't be enumerated → broken access, not "never used OpenCode". let scanner = OpenCodeUsageScanner( sqlite: FakeSQLite(), databasePaths: { throw CocoaError(.fileReadNoPermission) } @@ -130,8 +116,18 @@ final class OpenCodeUsageScannerTests: XCTestCase { XCTAssertFalse(empty.hasHostedUsage()) } + func testSQLCutoffMatchesCalendarTileWindow() async throws { + let now = d("2026-07-12T18:00:00.000Z") + let sqlite = FakeSQLite(data: ["/oc/opencode.db": "[]"]) + let scanner = OpenCodeUsageScanner(sqlite: sqlite, databasePaths: { ["/oc/opencode.db"] }) + _ = try await scanner.scan(now: now) + + let tileSinceMs = Int(JSONLScanning.sinceDate(daysBack: 30, now: now).timeIntervalSince1970 * 1000) + guard let sql = sqlite.lastDataSQL else { return XCTFail("expected a data query") } + XCTAssertTrue(sql.contains("time_created >= \(tileSinceMs)"), sql) + } + func testAbsurdTokenCountIsClampedNotCrashing() async throws { - // A corrupt token count over Int.max must clamp (to 1e15), not trap the Int(Double) conversion. let db = "[[\(epochMs("2026-07-12T10:00:00.000Z")),1.0,1e19,\"glm-5.2\",\"opencode-go\"]]" let scanner = OpenCodeUsageScanner( sqlite: FakeSQLite(data: ["/oc/opencode.db": db]), @@ -141,48 +137,25 @@ final class OpenCodeUsageScannerTests: XCTestCase { let tokens = scan.logScan.series.daily.reduce(0) { $0 + $1.totalTokens } XCTAssertEqual(tokens, 1_000_000_000_000_000) } - - func testStaleGoAnchorWithoutRecentSpendOrKeyHasNoGoWindows() async throws { - // Old opencode-go usage left an anchor, but there's no recent Go spend and no auth key: the caps - // (and the "Go" badge) must NOT come back for a lapsed/Zen-only user. - let db = "[" + row("2026-07-12T10:00:00.000Z", "1.0", 500, "gpt-5.5", "opencode") + "]" - let scanner = OpenCodeUsageScanner( - sqlite: FakeSQLite(data: ["/oc/opencode.db": db], anchors: ["/oc/opencode.db": "1700000000000"]), - databasePaths: { ["/oc/opencode.db"] } - ) - guard let scan = try await scanner.scan(now: now, hasGoKey: false) else { return XCTFail("expected a scan") } - XCTAssertNil(scan.goWindows) - } - - func testGoKeyShowsWindowsEvenWithoutRecentSpend() async throws { - // Logged into Go but idle in-window → still show the caps at $0, using the anchor for the month. - let db = "[" + row("2026-07-12T10:00:00.000Z", "1.0", 500, "gpt-5.5", "opencode") + "]" - let scanner = OpenCodeUsageScanner( - sqlite: FakeSQLite(data: ["/oc/opencode.db": db], anchors: ["/oc/opencode.db": "1700000000000"]), - databasePaths: { ["/oc/opencode.db"] } - ) - guard let scan = try await scanner.scan(now: now, hasGoKey: true) else { return XCTFail("expected a scan") } - XCTAssertNotNil(scan.goWindows) - XCTAssertEqual(scan.goWindows?.sessionSpend ?? -1, 0, accuracy: 0.0001) - } } /// Stub that returns crafted payloads per database path and classifies the query by SQL shape. private final class FakeSQLite: SQLiteAccessing, @unchecked Sendable { var data: [String: String] - var anchors: [String: String] var failing: Set + var lastDataSQL: String? - init(data: [String: String] = [:], anchors: [String: String] = [:], failing: Set = []) { + init(data: [String: String] = [:], failing: Set = []) { self.data = data - self.anchors = anchors self.failing = failing } func queryValue(path: String, sql: String) throws -> String? { if failing.contains(path) { throw SQLiteError.queryFailed("boom") } - if sql.contains("json_group_array") { return data[path] } - if sql.contains("MIN(time_created)") { return anchors[path] } + if sql.contains("json_group_array") { + lastDataSQL = sql + return data[path] + } if sql.contains("SELECT 1") { let payload = data[path] return (payload != nil && payload != "[]" && !(payload ?? "").isEmpty) ? "1" : nil diff --git a/docs/local-http-api.md b/docs/local-http-api.md index 044fd6185..1f89c1dd0 100644 --- a/docs/local-http-api.md +++ b/docs/local-http-api.md @@ -97,7 +97,7 @@ the app and CLI; `stale` says whether that instant has passed. Refresh failures `{"providerId":"…","message":"…"}` while a last-good provider snapshot remains available. For bounded progress resources, `unit` follows the provider's live metric format. For example, Cursor `totalUsage` is `percent` on percentage-based plans, `requests` on request-based Enterprise plans, and -`usd` when Cursor reports a dollar pool. +`usd` when Cursor reports a dollar pool. OpenCode `session`, `weekly`, and `monthly` are `percent`. ### Public resources diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index a9c5aa8b2..278acec2a 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -1,65 +1,64 @@ # OpenCode -Tracks your OpenCode-hosted usage — the **Go** subscription and the **Zen** pay-as-you-go gateway — from -OpenCode's own logs already on your Mac. Nothing is sent anywhere. +Tracks your OpenCode-hosted usage — the **Go** subscription and the **Zen** pay-as-you-go gateway. Go +plan windows come from OpenCode's official usage API. Spend tiles and the usage trend still come from +OpenCode's logs already on your Mac. ## What it tracks | Metric | Meaning | |---|---| -| Session | Go spend in the rolling 5-hour window, against the $12 cap, with the reset countdown | -| Weekly | Go spend this week, against the $30 cap (resets Monday) | -| Monthly | Go spend this cycle, against the $60 cap | +| Session | Go usage in the rolling 5-hour window, as a percent, with the reset countdown | +| Weekly | Go usage this week, as a percent (resets Monday UTC) | +| Monthly | Go usage this billing cycle, as a percent | | Today / Yesterday / Last 30 Days | Local cost and tokens across all your OpenCode-hosted usage (Go + Zen) | | Usage Trend | A day-by-day sparkline of tokens over the last month | When you have the Go subscription, OpenUsage shows "Go" beside the provider name. -The Session / Weekly / Monthly meters show **observed local spend** — the usage recorded on *this* Mac. If -you also use OpenCode Go on another machine, or OpenCode hasn't finished writing a session locally, the -local figure can be lower than your true account usage, so treat the caps as a guide rather than the last -word. (When OpenCode ships an official usage API, OpenUsage can switch to authoritative numbers without any -change on your side.) If you only use the Zen pay-as-you-go gateway (no Go subscription), the cap meters are -hidden and you'll just see the spend tiles. +The Session / Weekly / Monthly meters are **account-wide** — the same percents the OpenCode dashboard +shows, including usage from other machines. If you only use the Zen pay-as-you-go gateway (no Go +subscription), the cap meters are hidden and you'll just see the spend tiles. ## Where credentials come from -Use OpenCode as usual. OpenUsage reads OpenCode's local data directory -(`~/.local/share/opencode`, or `$OPENCODE_DATA_DIR` / `$XDG_DATA_HOME` if you've set them): the -`auth.json` Go key to detect that you use it, and the local SQLite logs for the numbers. There's no login -prompt and no token to paste. +Use OpenCode as usual. OpenUsage reads the `opencode-go` API key from OpenCode's local data directory +(`~/.local/share/opencode/auth.json`, or `$OPENCODE_DATA_DIR` / `$XDG_DATA_HOME` if you've set them) and +sends it as a Bearer token to the usage API. There's no login prompt and no token to paste. Spend tiles +still read the local SQLite logs in that same directory. ## The meters and spend tiles -The dollar figures come straight from the per-message cost OpenCode records for its own hosted gateways, so -they're OpenCode's own accounting — not an estimate imputed from token counts. Each spend tile shows cost -and tokens together (`$4.08 · 1.2M tokens`), the same as Claude / Codex / Cursor. A period with no recorded +Go meters are percents from `GET https://opencode.ai/zen/go/v1/usage` — OpenCode's own accounting, not +an estimate. Each spend tile shows cost and tokens together (`$4.08 · 1.2M tokens`), the same as Claude / +Codex / Cursor. Those dollars come straight from the per-message cost OpenCode records for its hosted +gateways on this Mac, so they can be lower than account-wide Go usage. A period with no recorded local usage reads "No data" rather than a misleading `$0.00`. No log data leaves your Mac. -The Go caps OpenUsage draws against are the published plan limits: **$12 per rolling 5 hours**, **$30 per -week** (UTC Monday), and **$60 per month** (the monthly cycle is anchored to the day of the month you first -used Go). Zen usage is pay-as-you-go credits with no cap, so it appears only in the spend tiles. - ## Troubleshooting -- **Everything shows "No data"** — OpenUsage needs OpenCode's local database at - `~/.local/share/opencode/opencode*.db`. Run an OpenCode session, then refresh. (If you're logged into - Go, the cap meters show at $0 even before your first local message.) -- **No Session / Weekly / Monthly meters** — those are Go-plan caps; you'll see them when you're logged - into OpenCode Go or have used it recently on this Mac. Zen-only (or lapsed) users see the spend tiles - instead — old Go history alone won't bring the caps back. -- **"Couldn't read OpenCode's local database"** — the database (or data directory) exists but couldn't be - read this refresh. Quit OpenCode and refresh; if it persists, check the permissions on - `~/.local/share/opencode`. +- **No Session / Weekly / Monthly meters** — those are Go-plan windows. You'll see them when you're + logged into OpenCode Go (`opencode-go` in `auth.json`) and the key has an active subscription. + Zen-only users see the spend tiles instead. +- **"OpenCode Go key was rejected"** — the local key was not accepted. Log into OpenCode Go again so + `auth.json` is rewritten. +- **"No OpenCode Go subscription on this key"** — the key is valid but this account isn't on Go. The + spend tiles still work if you use Zen locally. - **"Couldn't read OpenCode's auth.json"** — the file exists but is unreadable or not valid JSON. Check its permissions, or log into OpenCode Go again to rewrite it. -- **Numbers look lower than your dashboard** — the meters are local-observed spend (this Mac only); see the - note above. +- **Spend tiles show "No data"** — OpenUsage needs OpenCode's local database at + `~/.local/share/opencode/opencode*.db`. Run an OpenCode session, then refresh. +- **"Couldn't read OpenCode's local database"** — the database (or data directory) exists but couldn't be + read this refresh. If you're on Go, the percent meters still refresh; quit OpenCode and refresh to + restore the tiles. If it persists, check the permissions on `~/.local/share/opencode`. ## Under the hood -OpenUsage reads the assistant-message `cost` and token fields from every `opencode*.db` in the data -directory (OpenCode partitions its database by release channel — stable is `opencode.db`, the preview line -is `opencode-next.db` — so all channels are unioned). The Go caps sum the `opencode-go` messages; the spend -tiles and trend sum both `opencode-go` (Go) and `opencode` (Zen). Read-only, no network. If OpenCode's -proposed `/zen/go/v1/usage` API ships, the same Go key becomes the bearer token for authoritative windows. +Go windows: `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` key as `Authorization: +Bearer …`. The response is `{ usage: { rolling, weekly, monthly } }`, each with `percent` and +`resetsAt`. A 401 is a rejected key; a 403 `EntitlementError` means no Go subscription. + +Spend tiles and trend: assistant-message `cost` and token fields from every `opencode*.db` in the data +directory (OpenCode partitions its database by release channel — stable is `opencode.db`, the preview +line is `opencode-next.db` — so all channels are unioned). Both `opencode-go` (Go) and `opencode` (Zen) +count. Read-only.