From f2cefcdb587e93468fae31920e1fff486fed9300 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 28 Jan 2026 01:08:34 +0000 Subject: [PATCH 1/5] Add OpenCode message file parser for cost estimation --- .../CostUsage/CostUsageScanner+OpenCode.swift | 227 ++++++++++++++++++ .../Vendored/CostUsage/CostUsageScanner.swift | 3 + 2 files changed, 230 insertions(+) create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift new file mode 100644 index 00000000..742791d4 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift @@ -0,0 +1,227 @@ +import Foundation + +extension CostUsageScanner { + // MARK: - OpenCode + + struct OpenCodeParseResult: Sendable { + let days: [String: [String: [Int]]] + let parsedCount: Int + } + + /// Parses an OpenCode message JSON file and extracts token usage. + /// 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 + { + var days: [String: [String: [Int]]] = [:] + + guard let data = try? Data(contentsOf: fileURL), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return OpenCodeParseResult(days: [:], parsedCount: 0) + } + + // Only process assistant messages with token usage + guard (obj["role"] as? String) == "assistant" else { + return OpenCodeParseResult(days: [:], parsedCount: 0) + } + + guard let tokens = obj["tokens"] as? [String: Any] else { + return OpenCodeParseResult(days: [:], parsedCount: 0) + } + + // Extract timestamp and convert to day key + guard let time = obj["time"] as? [String: Any], + let createdMs = time["created"] as? Int64 + else { + return OpenCodeParseResult(days: [:], parsedCount: 0) + } + + 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(days: [:], parsedCount: 0) + } + + // Extract model - OpenCode uses "modelID" like "claude-opus-4-5" + let modelID = obj["modelID"] as? String ?? "unknown" + let normModel = self.normalizeOpenCodeModel(modelID) + + // 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(days: [:], parsedCount: 0) + } + + // Calculate cost using Claude pricing (OpenCode uses Anthropic models) + let costScale = 1_000_000_000.0 + let cost = CostUsagePricing.claudeCostUSD( + model: normModel, + inputTokens: input, + cacheReadInputTokens: cacheRead, + cacheCreationInputTokens: cacheWrite, + outputTokens: output + reasoning) + let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 + + // Pack tokens: [input, cacheRead, cacheWrite, output, costNanos] + let packed = [input, cacheRead, cacheWrite, output + reasoning, costNanos] + days[dayKey] = [normModel: packed] + + return OpenCodeParseResult(days: days, parsedCount: 1) + } + + /// Normalizes OpenCode model IDs to match Claude pricing keys. + /// OpenCode uses names like "claude-opus-4-5" while pricing uses "claude-opus-4-5-20251101". + static func normalizeOpenCodeModel(_ raw: String) -> String { + var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // Remove provider prefix if present + if trimmed.hasPrefix("anthropic/") { + trimmed = String(trimmed.dropFirst("anthropic/".count)) + } + + // OpenCode model IDs don't have date suffixes, but the pricing table does. + // Map known models to their dated versions for accurate pricing lookup. + let modelMappings: [String: String] = [ + "claude-opus-4-5": "claude-opus-4-5-20251101", + "claude-sonnet-4-5": "claude-sonnet-4-5-20250929", + "claude-haiku-4-5": "claude-haiku-4-5-20251001", + "claude-opus-4": "claude-opus-4-20250514", + "claude-sonnet-4": "claude-sonnet-4-20250514", + "claude-opus-4-1": "claude-opus-4-1", + ] + + if let mapped = modelMappings[trimmed] { + return mapped + } + + // Fallback: use as-is (normalizeClaudeModel will handle it) + return CostUsagePricing.normalizeClaudeModel(trimmed) + } + + /// 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 + let parsed = Self.parseOpenCodeMessageFile(fileURL: url, range: range) + + // Store in cache + let usage = Self.makeFileUsage( + mtimeUnixMs: mtimeMs, + size: size, + days: parsed.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 From 54c4d64be1ff0adb4c5f0df71cc1c7a8868d4cb3 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 28 Jan 2026 01:08:37 +0000 Subject: [PATCH 2/5] Merge OpenCode usage into Claude cost data --- .../Vendored/CostUsage/CostUsageScanner+Claude.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 From 922cb2e2b8029cbd041f5bb749e9f84f8f722c7c Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 28 Jan 2026 01:08:41 +0000 Subject: [PATCH 3/5] Update OpenCode provider to be provider-agnostic --- .../OpenCode/OpenCodeProviderDescriptor.swift | 2 +- .../CostUsage/CostUsageScanner+OpenCode.swift | 23 ++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) 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+OpenCode.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift index 742791d4..1cc954c9 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift @@ -6,6 +6,7 @@ extension CostUsageScanner { struct OpenCodeParseResult: Sendable { let days: [String: [String: [Int]]] let parsedCount: Int + let providerID: String? } /// Parses an OpenCode message JSON file and extracts token usage. @@ -26,30 +27,32 @@ extension CostUsageScanner { guard let data = try? Data(contentsOf: fileURL), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return OpenCodeParseResult(days: [:], parsedCount: 0) + return OpenCodeParseResult(days: [:], parsedCount: 0, providerID: nil) } // Only process assistant messages with token usage guard (obj["role"] as? String) == "assistant" else { - return OpenCodeParseResult(days: [:], parsedCount: 0) + return OpenCodeParseResult(days: [:], parsedCount: 0, providerID: nil) } + let providerID = obj["providerID"] as? String + guard let tokens = obj["tokens"] as? [String: Any] else { - return OpenCodeParseResult(days: [:], parsedCount: 0) + return OpenCodeParseResult(days: [:], parsedCount: 0, 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(days: [:], parsedCount: 0) + return OpenCodeParseResult(days: [:], parsedCount: 0, 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(days: [:], parsedCount: 0) + return OpenCodeParseResult(days: [:], parsedCount: 0, providerID: providerID) } // Extract model - OpenCode uses "modelID" like "claude-opus-4-5" @@ -73,7 +76,7 @@ extension CostUsageScanner { // Skip if no tokens if input == 0, output == 0, cacheRead == 0, cacheWrite == 0, reasoning == 0 { - return OpenCodeParseResult(days: [:], parsedCount: 0) + return OpenCodeParseResult(days: [:], parsedCount: 0, providerID: providerID) } // Calculate cost using Claude pricing (OpenCode uses Anthropic models) @@ -90,7 +93,7 @@ extension CostUsageScanner { let packed = [input, cacheRead, cacheWrite, output + reasoning, costNanos] days[dayKey] = [normModel: packed] - return OpenCodeParseResult(days: days, parsedCount: 1) + return OpenCodeParseResult(days: days, parsedCount: 1, providerID: providerID) } /// Normalizes OpenCode model IDs to match Claude pricing keys. @@ -150,11 +153,15 @@ extension CostUsageScanner { // Parse the message file 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 + let days = parsed.providerID == "anthropic" ? parsed.days : [:] + // Store in cache let usage = Self.makeFileUsage( mtimeUnixMs: mtimeMs, size: size, - days: parsed.days, + days: days, parsedBytes: size) cache.files[path] = usage Self.applyFileDays(cache: &cache, fileDays: usage.days, sign: 1) From 2e0f14f9b529a9c0a56e5e8ed81c3ccd1f6b2103 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 28 Jan 2026 01:56:15 +0000 Subject: [PATCH 4/5] Refactor OpenCode parser to be fully provider-agnostic --- .../CostUsage/CostUsageScanner+OpenCode.swift | 81 ++++++++++++------- 1 file changed, 52 insertions(+), 29 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift index 1cc954c9..661f8e14 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift @@ -3,13 +3,23 @@ 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 days: [String: [String: [Int]]] - let parsedCount: Int + let tokenData: OpenCodeTokenData? let providerID: String? } - /// Parses an OpenCode message JSON file and extracts token usage. + /// 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 }, @@ -22,42 +32,38 @@ extension CostUsageScanner { fileURL: URL, range: CostUsageDayRange) -> OpenCodeParseResult { - var days: [String: [String: [Int]]] = [:] - guard let data = try? Data(contentsOf: fileURL), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return OpenCodeParseResult(days: [:], parsedCount: 0, providerID: nil) + return OpenCodeParseResult(tokenData: nil, providerID: nil) } // Only process assistant messages with token usage guard (obj["role"] as? String) == "assistant" else { - return OpenCodeParseResult(days: [:], parsedCount: 0, providerID: nil) + return OpenCodeParseResult(tokenData: nil, providerID: nil) } let providerID = obj["providerID"] as? String guard let tokens = obj["tokens"] as? [String: Any] else { - return OpenCodeParseResult(days: [:], parsedCount: 0, providerID: providerID) + 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(days: [:], parsedCount: 0, providerID: providerID) + 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(days: [:], parsedCount: 0, providerID: providerID) + return OpenCodeParseResult(tokenData: nil, providerID: providerID) } - // Extract model - OpenCode uses "modelID" like "claude-opus-4-5" let modelID = obj["modelID"] as? String ?? "unknown" - let normModel = self.normalizeOpenCodeModel(modelID) // Extract token counts func toInt(_ v: Any?) -> Int { @@ -76,29 +82,24 @@ extension CostUsageScanner { // Skip if no tokens if input == 0, output == 0, cacheRead == 0, cacheWrite == 0, reasoning == 0 { - return OpenCodeParseResult(days: [:], parsedCount: 0, providerID: providerID) + return OpenCodeParseResult(tokenData: nil, providerID: providerID) } - // Calculate cost using Claude pricing (OpenCode uses Anthropic models) - let costScale = 1_000_000_000.0 - let cost = CostUsagePricing.claudeCostUSD( - model: normModel, + let tokenData = OpenCodeTokenData( + dayKey: dayKey, + modelID: modelID, + providerID: providerID, inputTokens: input, - cacheReadInputTokens: cacheRead, - cacheCreationInputTokens: cacheWrite, - outputTokens: output + reasoning) - let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 - - // Pack tokens: [input, cacheRead, cacheWrite, output, costNanos] - let packed = [input, cacheRead, cacheWrite, output + reasoning, costNanos] - days[dayKey] = [normModel: packed] + outputTokens: output + reasoning, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite) - return OpenCodeParseResult(days: days, parsedCount: 1, providerID: providerID) + return OpenCodeParseResult(tokenData: tokenData, providerID: providerID) } /// Normalizes OpenCode model IDs to match Claude pricing keys. /// OpenCode uses names like "claude-opus-4-5" while pricing uses "claude-opus-4-5-20251101". - static func normalizeOpenCodeModel(_ raw: String) -> String { + private static func normalizeClaudeModelID(_ raw: String) -> String { var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) // Remove provider prefix if present @@ -125,6 +126,24 @@ extension CostUsageScanner { 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, @@ -150,12 +169,16 @@ extension CostUsageScanner { Self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) } - // Parse the message file + // 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 - let days = parsed.providerID == "anthropic" ? parsed.days : [:] + 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( From e75c490604e4138ffc41d2c074d9601b1d4c0510 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 28 Jan 2026 02:04:06 +0000 Subject: [PATCH 5/5] Use standard Claude model normalization for consistent storage keys --- .../CostUsage/CostUsageScanner+OpenCode.swift | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift index 661f8e14..0e1aa0c1 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+OpenCode.swift @@ -97,32 +97,17 @@ extension CostUsageScanner { return OpenCodeParseResult(tokenData: tokenData, providerID: providerID) } - /// Normalizes OpenCode model IDs to match Claude pricing keys. - /// OpenCode uses names like "claude-opus-4-5" while pricing uses "claude-opus-4-5-20251101". + /// 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 + // Remove provider prefix if present (OpenCode uses "anthropic/" prefix sometimes) if trimmed.hasPrefix("anthropic/") { trimmed = String(trimmed.dropFirst("anthropic/".count)) } - // OpenCode model IDs don't have date suffixes, but the pricing table does. - // Map known models to their dated versions for accurate pricing lookup. - let modelMappings: [String: String] = [ - "claude-opus-4-5": "claude-opus-4-5-20251101", - "claude-sonnet-4-5": "claude-sonnet-4-5-20250929", - "claude-haiku-4-5": "claude-haiku-4-5-20251001", - "claude-opus-4": "claude-opus-4-20250514", - "claude-sonnet-4": "claude-sonnet-4-20250514", - "claude-opus-4-1": "claude-opus-4-1", - ] - - if let mapped = modelMappings[trimmed] { - return mapped - } - - // Fallback: use as-is (normalizeClaudeModel will handle it) + // Use standard Claude normalization for consistent storage keys return CostUsagePricing.normalizeClaudeModel(trimmed) }