diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift index 18803a46..4cf7b54f 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift @@ -30,7 +30,7 @@ public enum OpenCodeProviderDescriptor { color: ProviderColor(red: 59 / 255, green: 130 / 255, blue: 246 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "OpenCode cost summary is not supported." }), + noDataMessage: { "OpenCode cost is included in Claude provider." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OpenCodeUsageFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 5e32060b..8166401f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -456,6 +456,17 @@ extension CostUsageScanner { state: scanState) } + // Also scan OpenCode message files for Claude/Anthropic usage + // (OpenCode users consuming Claude Max subscription) + // Skip when claudeProjectsRoots is explicitly set (test environment) + if provider == .claude, providerFilter != .vertexAIOnly, options.claudeProjectsRoots == nil { + Self.scanOpenCodeMessagesIntoClaude( + cache: &scanState.cache, + touched: &scanState.touched, + range: scanState.range, + options: options) + } + cache = scanState.cache touched = scanState.touched cache.roots = scanState.rootCache.isEmpty ? nil : scanState.rootCache diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift new file mode 100644 index 00000000..0e1aa0c1 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift @@ -0,0 +1,242 @@ +import Foundation + +extension CostUsageScanner { + // MARK: - OpenCode + + /// Raw token data extracted from an OpenCode message file (provider-agnostic) + struct OpenCodeTokenData: Sendable { + let dayKey: String + let modelID: String + let providerID: String? + let inputTokens: Int + let outputTokens: Int + let cacheReadTokens: Int + let cacheWriteTokens: Int + } + + struct OpenCodeParseResult: Sendable { + let tokenData: OpenCodeTokenData? + let providerID: String? + } + + /// Parses an OpenCode message JSON file and extracts raw token usage (provider-agnostic). + /// OpenCode stores each assistant message as a separate JSON file with structure: + /// { + /// "time": { "created": 1769531159117 }, + /// "role": "assistant", + /// "modelID": "claude-opus-4-5", + /// "providerID": "anthropic", + /// "tokens": { "input": 2, "output": 231, "reasoning": 0, "cache": { "read": 0, "write": 17135 } } + /// } + static func parseOpenCodeMessageFile( + fileURL: URL, + range: CostUsageDayRange) -> OpenCodeParseResult + { + guard let data = try? Data(contentsOf: fileURL), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return OpenCodeParseResult(tokenData: nil, providerID: nil) + } + + // Only process assistant messages with token usage + guard (obj["role"] as? String) == "assistant" else { + return OpenCodeParseResult(tokenData: nil, providerID: nil) + } + + let providerID = obj["providerID"] as? String + + guard let tokens = obj["tokens"] as? [String: Any] else { + return OpenCodeParseResult(tokenData: nil, providerID: providerID) + } + + // Extract timestamp and convert to day key + guard let time = obj["time"] as? [String: Any], + let createdMs = time["created"] as? Int64 + else { + return OpenCodeParseResult(tokenData: nil, providerID: providerID) + } + + let createdDate = Date(timeIntervalSince1970: Double(createdMs) / 1000.0) + let dayKey = CostUsageDayRange.dayKey(from: createdDate) + + guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) else { + return OpenCodeParseResult(tokenData: nil, providerID: providerID) + } + + let modelID = obj["modelID"] as? String ?? "unknown" + + // Extract token counts + func toInt(_ v: Any?) -> Int { + if let n = v as? NSNumber { return n.intValue } + return 0 + } + + let input = max(0, toInt(tokens["input"])) + let output = max(0, toInt(tokens["output"])) + let reasoning = max(0, toInt(tokens["reasoning"])) + + // Cache structure in OpenCode: { "read": N, "write": N } + let cache = tokens["cache"] as? [String: Any] + let cacheRead = max(0, toInt(cache?["read"])) + let cacheWrite = max(0, toInt(cache?["write"])) + + // Skip if no tokens + if input == 0, output == 0, cacheRead == 0, cacheWrite == 0, reasoning == 0 { + return OpenCodeParseResult(tokenData: nil, providerID: providerID) + } + + let tokenData = OpenCodeTokenData( + dayKey: dayKey, + modelID: modelID, + providerID: providerID, + inputTokens: input, + outputTokens: output + reasoning, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite) + + return OpenCodeParseResult(tokenData: tokenData, providerID: providerID) + } + + /// Normalizes OpenCode model IDs to match Claude pricing/storage keys. + /// Uses the same normalization as Claude logs for consistent model grouping. + private static func normalizeClaudeModelID(_ raw: String) -> String { + var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // Remove provider prefix if present (OpenCode uses "anthropic/" prefix sometimes) + if trimmed.hasPrefix("anthropic/") { + trimmed = String(trimmed.dropFirst("anthropic/".count)) + } + + // Use standard Claude normalization for consistent storage keys + return CostUsagePricing.normalizeClaudeModel(trimmed) + } + + /// Converts raw token data to cache format with Claude pricing + private static func packTokenDataForClaude(_ data: OpenCodeTokenData) -> (dayKey: String, model: String, packed: [Int]) { + let normModel = self.normalizeClaudeModelID(data.modelID) + + let costScale = 1_000_000_000.0 + let cost = CostUsagePricing.claudeCostUSD( + model: normModel, + inputTokens: data.inputTokens, + cacheReadInputTokens: data.cacheReadTokens, + cacheCreationInputTokens: data.cacheWriteTokens, + outputTokens: data.outputTokens) + let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 + + // Pack tokens: [input, cacheRead, cacheWrite, output, costNanos] + let packed = [data.inputTokens, data.cacheReadTokens, data.cacheWriteTokens, data.outputTokens, costNanos] + return (data.dayKey, normModel, packed) + } + + /// Process a single OpenCode message file directly into a cache + private static func processOpenCodeMessageFileIntoCache( + url: URL, + size: Int64, + mtimeMs: Int64, + cache: inout CostUsageCache, + touched: inout Set, + range: CostUsageDayRange) + { + let path = url.path + touched.insert(path) + + // Check cache - if unchanged, skip + if let cached = cache.files[path], + cached.mtimeUnixMs == mtimeMs, + cached.size == size + { + return + } + + // Remove old cached data if present + if let cached = cache.files[path] { + Self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) + } + + // Parse the message file (provider-agnostic) + let parsed = Self.parseOpenCodeMessageFile(fileURL: url, range: range) + + // Only include Anthropic provider messages when merging into Claude + // For non-Anthropic files, store empty days to avoid re-parsing + var days: [String: [String: [Int]]] = [:] + if parsed.providerID == "anthropic", let tokenData = parsed.tokenData { + let (dayKey, model, packed) = Self.packTokenDataForClaude(tokenData) + days[dayKey] = [model: packed] + } + + // Store in cache + let usage = Self.makeFileUsage( + mtimeUnixMs: mtimeMs, + size: size, + days: days, + parsedBytes: size) + cache.files[path] = usage + Self.applyFileDays(cache: &cache, fileDays: usage.days, sign: 1) + } + + /// Scans OpenCode message files and merges them into the Claude cache. + /// This allows OpenCode usage (which consumes Claude Max subscription) to appear under Claude provider. + static func scanOpenCodeMessagesIntoClaude( + cache: inout CostUsageCache, + touched: inout Set, + range: CostUsageDayRange, + options: Options) + { + // If a custom openCodeStorageRoot is set, use it; otherwise use default + let storageRoot: URL + if let override = options.openCodeStorageRoot { + storageRoot = override + } else { + let home = FileManager.default.homeDirectoryForCurrentUser + storageRoot = home.appendingPathComponent(".local/share/opencode/storage", isDirectory: true) + } + let messageRoot = storageRoot.appendingPathComponent("message", isDirectory: true) + + guard FileManager.default.fileExists(atPath: messageRoot.path) else { return } + + // OpenCode stores messages in: message/{session_id}/msg_*.json + guard let sessionDirs = try? FileManager.default.contentsOfDirectory( + at: messageRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + else { return } + + for sessionDir in sessionDirs { + guard let isDir = try? sessionDir.resourceValues(forKeys: [.isDirectoryKey]).isDirectory, + isDir == true + else { continue } + + guard let messageFiles = try? FileManager.default.contentsOfDirectory( + at: sessionDir, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .fileSizeKey], + options: [.skipsHiddenFiles]) + else { continue } + + for messageFile in messageFiles { + guard messageFile.pathExtension.lowercased() == "json", + messageFile.lastPathComponent.hasPrefix("msg_") + else { continue } + + guard let values = try? messageFile.resourceValues( + forKeys: [.isRegularFileKey, .contentModificationDateKey, .fileSizeKey]), + values.isRegularFile == true + else { continue } + + let size = Int64(values.fileSize ?? 0) + if size <= 0 { continue } + + let mtime = values.contentModificationDate?.timeIntervalSince1970 ?? 0 + let mtimeMs = Int64(mtime * 1000) + + Self.processOpenCodeMessageFileIntoCache( + url: messageFile, + size: size, + mtimeMs: mtimeMs, + cache: &cache, + touched: &touched, + range: range) + } + } + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index c50b106c..ce73d3db 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -10,6 +10,7 @@ enum CostUsageScanner { struct Options: Sendable { var codexSessionsRoot: URL? var claudeProjectsRoots: [URL]? + var openCodeStorageRoot: URL? var cacheRoot: URL? var refreshMinIntervalSeconds: TimeInterval = 60 var claudeLogProviderFilter: ClaudeLogProviderFilter = .all @@ -19,12 +20,14 @@ enum CostUsageScanner { init( codexSessionsRoot: URL? = nil, claudeProjectsRoots: [URL]? = nil, + openCodeStorageRoot: URL? = nil, cacheRoot: URL? = nil, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, forceRescan: Bool = false) { self.codexSessionsRoot = codexSessionsRoot self.claudeProjectsRoots = claudeProjectsRoots + self.openCodeStorageRoot = openCodeStorageRoot self.cacheRoot = cacheRoot self.claudeLogProviderFilter = claudeLogProviderFilter self.forceRescan = forceRescan