844 lines · 33.3 KB · Swift
RawDownload
import Foundation extension CostUsageScanner {    // MARK: - Claude     private struct ClaudeTokens {        let input: Int        let cacheRead: Int        let cacheCreate: Int        let cacheCreate1h: Int        let output: Int        let costNanos: Int        let costPriced: Bool    }     private struct ClaudeDayModelKey: Hashable {        let day: String        let model: String    }     private struct ClaudeRepricedCost {        var total: Double = 0        var sampleCount: Int = 0        var unresolved = false    }     private static func defaultClaudeProjectsRoots(options: Options) -> [URL] {        if let override = options.claudeProjectsRoots { return override }         var roots: [URL] = []         if let env = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"]?            .trimmingCharacters(in: .whitespacesAndNewlines),            !env.isEmpty        {            for part in env.split(separator: ",") {                let raw = String(part).trimmingCharacters(in: .whitespacesAndNewlines)                guard !raw.isEmpty else { continue }                let url = URL(fileURLWithPath: raw)                if url.lastPathComponent == "projects" {                    roots.append(url)                } else {                    roots.append(url.appendingPathComponent("projects", isDirectory: true))                }            }        } else {            let home = FileManager.default.homeDirectoryForCurrentUser            roots.append(home.appendingPathComponent(".config/claude/projects", isDirectory: true))            roots.append(home.appendingPathComponent(".claude/projects", isDirectory: true))        }         return roots    }     static func parseClaudeFile(        fileURL: URL,        range: CostUsageDayRange,        providerFilter: ClaudeLogProviderFilter,        startOffset: Int64 = 0,        modelsDevCatalog: ModelsDevCatalog? = nil,        modelsDevCacheRoot: URL? = nil) -> ClaudeParseResult    {        (            try? self.parseClaudeFileCancellable(                fileURL: fileURL,                range: range,                providerFilter: providerFilter,                startOffset: startOffset,                modelsDevCatalog: modelsDevCatalog,                modelsDevCacheRoot: modelsDevCacheRoot,                checkCancellation: nil)) ?? ClaudeParseResult(days: [:], rows: [], parsedBytes: startOffset)    }     static func parseClaudeFileCancellable(        fileURL: URL,        range: CostUsageDayRange,        providerFilter: ClaudeLogProviderFilter,        startOffset: Int64 = 0,        modelsDevCatalog: ModelsDevCatalog? = nil,        modelsDevCacheRoot: URL? = nil,        checkCancellation: CancellationCheck? = nil) throws -> ClaudeParseResult    {        func add(dayKey: String, model: String, tokens: ClaudeTokens, days: inout [String: [String: [Int]]]) {            guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey)            else { return }            let normModel = CostUsagePricing.normalizeClaudeModel(model)            var dayModels = days[dayKey] ?? [:]            var packed = dayModels[normModel] ?? [0, 0, 0, 0, 0, 0, 0, 0]            packed[0] = (packed[safe: 0] ?? 0) + tokens.input            packed[1] = (packed[safe: 1] ?? 0) + tokens.cacheRead            packed[2] = (packed[safe: 2] ?? 0) + tokens.cacheCreate            packed[3] = (packed[safe: 3] ?? 0) + tokens.output            packed[4] = (packed[safe: 4] ?? 0) + tokens.costNanos            packed[5] = (packed[safe: 5] ?? 0) + 1            packed[6] = (packed[safe: 6] ?? 0) + (tokens.costPriced ? 1 : 0)            packed[7] = (packed[safe: 7] ?? 0) + tokens.cacheCreate1h            dayModels[normModel] = packed            days[dayKey] = dayModels        }         func toInt(_ v: Any?) -> Int {            if let n = v as? NSNumber { return n.intValue }            return 0        }         func toBool(_ value: Any?) -> Bool {            if let bool = value as? Bool { return bool }            if let number = value as? NSNumber { return number.boolValue }            return false        }         let pathRole = Self.claudePathRole(fileURL: fileURL)        var keyedRows: [String: ClaudeUsageRow] = [:]        var unkeyedRows: [ClaudeUsageRow] = []         let maxLineBytes = 512 * 1024        // Keep the full line so usage at the tail isn't dropped on large tool outputs.        let prefixBytes = maxLineBytes        let costScale = 1_000_000_000.0         let parsedBytes: Int64        do {            parsedBytes = try CostUsageJsonl.scan(                fileURL: fileURL,                offset: startOffset,                maxLineBytes: maxLineBytes,                prefixBytes: prefixBytes,                checkCancellation: checkCancellation,                onLine: { line in                    guard !line.bytes.isEmpty else { return }                    guard !line.wasTruncated else { return }                    guard line.bytes.containsAscii(#""type":"assistant""#) else { return }                    guard line.bytes.containsAscii(#""usage""#) else { return }                     autoreleasepool {                        guard                            let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any],                            let type = obj["type"] as? String,                            type == "assistant"                        else { return }                        guard Self.matchesClaudeProviderFilter(obj: obj, filter: providerFilter) else { return }                         guard let tsText = obj["timestamp"] as? String, let timestamp = Self.dateFromTimestamp(tsText)                        else { return }                        guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText)                        else { return }                         guard let message = obj["message"] as? [String: Any] else { return }                        guard let model = message["model"] as? String else { return }                        guard let usage = message["usage"] as? [String: Any] else { return }                         let input = max(0, toInt(usage["input_tokens"]))                        let cacheCreate = max(0, toInt(usage["cache_creation_input_tokens"]))                        let cacheCreate1h = Self.claudeOneHourCacheCreationTokens(                            usage: usage,                            total: cacheCreate)                        let cacheRead = max(0, toInt(usage["cache_read_input_tokens"]))                        let output = max(0, toInt(usage["output_tokens"]))                        if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { return }                         let cost = CostUsagePricing.claudeCostUSD(                            model: model,                            inputTokens: input,                            cacheReadInputTokens: cacheRead,                            cacheCreationInputTokens: cacheCreate,                            cacheCreationInputTokens1h: cacheCreate1h,                            outputTokens: output,                            pricingDate: timestamp,                            modelsDevCatalog: modelsDevCatalog,                            modelsDevCacheRoot: modelsDevCacheRoot)                        let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0                        let tokens = ClaudeTokens(                            input: input,                            cacheRead: cacheRead,                            cacheCreate: cacheCreate,                            cacheCreate1h: cacheCreate1h,                            output: output,                            costNanos: costNanos,                            costPriced: cost != nil)                         guard CostUsageDayRange.isInRange(                            dayKey: dayKey,                            since: range.scanSinceKey,                            until: range.scanUntilKey)                        else { return }                         let messageId = message["id"] as? String                        let requestId = obj["requestId"] as? String                        let sessionId = obj["sessionId"] as? String                            ?? obj["session_id"] as? String                            ?? (obj["metadata"] as? [String: Any])?["sessionId"] as? String                            ?? (message["metadata"] as? [String: Any])?["sessionId"] as? String                        let normalizedModel = CostUsagePricing.normalizeClaudeModel(model)                        let row = ClaudeUsageRow(                            dayKey: dayKey,                            model: normalizedModel,                            sessionId: sessionId,                            messageId: messageId,                            requestId: requestId,                            timestampUnixMs: Int64((timestamp.timeIntervalSince1970 * 1000).rounded()),                            isSidechain: toBool(obj["isSidechain"]),                            pathRole: pathRole,                            input: tokens.input,                            cacheRead: tokens.cacheRead,                            cacheCreate: tokens.cacheCreate,                            cacheCreate1h: tokens.cacheCreate1h,                            output: tokens.output,                            costNanos: tokens.costNanos,                            costPriced: tokens.costPriced)                         // Streaming chunks share message.id + requestId inside a file.                        // Keep overwriting so the final cumulative chunk wins.                        if let messageId, let requestId {                            let key = "\(messageId):\(requestId)"                            keyedRows[key] = row                        } else {                            // Older logs omit IDs; treat each line as distinct to avoid dropping usage.                            unkeyedRows.append(row)                        }                    }                })        } catch is CancellationError {            throw CancellationError()        } catch {            parsedBytes = startOffset        }         let rows = keyedRows.keys.sorted().compactMap { keyedRows[$0] } + unkeyedRows        var days: [String: [String: [Int]]] = [:]        for row in rows {            let tokens = ClaudeTokens(                input: row.input,                cacheRead: row.cacheRead,                cacheCreate: row.cacheCreate,                cacheCreate1h: row.cacheCreate1h ?? 0,                output: row.output,                costNanos: row.costNanos,                costPriced: row.costPriced ?? (row.costNanos > 0))            add(dayKey: row.dayKey, model: row.model, tokens: tokens, days: &days)        }         return ClaudeParseResult(days: days, rows: rows, parsedBytes: parsedBytes)    }     private static func claudeOneHourCacheCreationTokens(usage: [String: Any], total: Int) -> Int {        guard let cacheCreation = usage["cache_creation"] as? [String: Any] else { return 0 }        let tokens = (cacheCreation["ephemeral_1h_input_tokens"] as? NSNumber)?.intValue ?? 0        return min(total, max(0, tokens))    }     private static func claudePathRole(fileURL: URL) -> ClaudePathRole {        fileURL.path.contains("/subagents/") ? .subagent : .parent    }     private static func claudeCanonicalRowKey(_ row: ClaudeUsageRow) -> String? {        guard let messageId = row.messageId, let requestId = row.requestId else {            return nil        }        return "\(messageId):\(requestId)"    }     private static func mergeClaudeRows(existing: [ClaudeUsageRow], delta: [ClaudeUsageRow]) -> [ClaudeUsageRow] {        var keyedRows: [String: ClaudeUsageRow] = [:]        var unkeyedRows: [ClaudeUsageRow] = []         for row in existing {            if let key = Self.claudeInFileKey(row) {                keyedRows[key] = row            } else {                unkeyedRows.append(row)            }        }        for row in delta {            if let key = Self.claudeInFileKey(row) {                keyedRows[key] = row            } else {                unkeyedRows.append(row)            }        }         return keyedRows.keys.sorted().compactMap { keyedRows[$0] } + unkeyedRows    }     private static func claudeInFileKey(_ row: ClaudeUsageRow) -> String? {        guard let messageId = row.messageId, let requestId = row.requestId else { return nil }        return "\(messageId):\(requestId)"    }     private static func claudeRowWins(        lhs: (path: String, row: ClaudeUsageRow),        rhs: (path: String, row: ClaudeUsageRow)) -> Bool    {        if lhs.row.isSidechain != rhs.row.isSidechain {            return rhs.row.isSidechain        }        if lhs.row.pathRole != rhs.row.pathRole {            return rhs.row.pathRole == .subagent        }        return lhs.path < rhs.path    }     private static func reconciledClaudeRows(cache: CostUsageCache) -> [ClaudeUsageRow] {        var rows: [ClaudeUsageRow] = []        var winners: [String: (path: String, row: ClaudeUsageRow)] = [:]         for path in cache.files.keys.sorted() {            guard let fileRows = cache.files[path]?.claudeRows else { continue }            for row in fileRows {                guard let canonicalKey = Self.claudeCanonicalRowKey(row) else {                    rows.append(row)                    continue                }                let candidate = (path: path, row: row)                if let existing = winners[canonicalKey] {                    if Self.claudeRowWins(lhs: candidate, rhs: existing) {                        winners[canonicalKey] = candidate                    }                } else {                    winners[canonicalKey] = candidate                }            }        }         rows.append(contentsOf: winners.keys.sorted().compactMap { winners[$0]?.row })        return rows    }     private static func rebuildClaudeDays(cache: inout CostUsageCache) {        var days: [String: [String: [Int]]] = [:]         for row in Self.reconciledClaudeRows(cache: cache) {            var dayModels = days[row.dayKey] ?? [:]            var packed = dayModels[row.model] ?? [0, 0, 0, 0, 0, 0, 0, 0]            packed[0] = (packed[safe: 0] ?? 0) + row.input            packed[1] = (packed[safe: 1] ?? 0) + row.cacheRead            packed[2] = (packed[safe: 2] ?? 0) + row.cacheCreate            packed[3] = (packed[safe: 3] ?? 0) + row.output            packed[4] = (packed[safe: 4] ?? 0) + row.costNanos            packed[5] = (packed[safe: 5] ?? 0) + 1            packed[6] = (packed[safe: 6] ?? 0) + ((row.costPriced ?? (row.costNanos > 0)) ? 1 : 0)            packed[7] = (packed[safe: 7] ?? 0) + (row.cacheCreate1h ?? 0)            dayModels[row.model] = packed            days[row.dayKey] = dayModels        }         cache.days = days    }     private static func makeClaudeFileUsage(        mtimeMs: Int64,        size: Int64,        rows: [ClaudeUsageRow],        parsedBytes: Int64?) -> CostUsageFileUsage    {        makeFileUsage(            mtimeUnixMs: mtimeMs,            size: size,            days: [:],            parsedBytes: parsedBytes,            claudeRows: rows)    }     private static let vertexProviderKeys: Set<String> = [        "provider",        "platform",        "backend",        "api_provider",        "apiprovider",        "api_type",        "apitype",        "source",        "vendor",        "client",    ]     private static func matchesClaudeProviderFilter(        obj: [String: Any],        filter: ClaudeLogProviderFilter) -> Bool    {        switch filter {        case .all:            true        case .vertexAIOnly:            self.isVertexAIUsageEntry(obj: obj)        case .excludeVertexAI:            !self.isVertexAIUsageEntry(obj: obj)        }    }     private static func isVertexAIUsageEntry(obj: [String: Any]) -> Bool {        // Primary detection: Vertex AI message IDs and request IDs have "vrtx" prefix        // e.g., "msg_vrtx_0154LUXjFVzQGUca3yK2RUeo", "req_vrtx_011CWjK86SWeFuXqZKUtgB1H"        if let message = obj["message"] as? [String: Any],           let messageId = message["id"] as? String,           messageId.contains("_vrtx_")        {            return true        }        if let requestId = obj["requestId"] as? String,           requestId.contains("_vrtx_")        {            return true        }         // Secondary detection: model name with @ version separator (Vertex AI format)        // e.g., "claude-opus-4-5@20251101" vs "claude-opus-4-5-20251101"        if let message = obj["message"] as? [String: Any],           let model = message["model"] as? String,           Self.modelNameLooksVertex(model)        {            return true        }         // Fallback: check for explicit Vertex AI metadata fields        var candidates: [[String: Any]] = [obj]        if let metadata = obj["metadata"] as? [String: Any] { candidates.append(metadata) }        if let request = obj["request"] as? [String: Any] { candidates.append(request) }        if let context = obj["context"] as? [String: Any] { candidates.append(context) }        if let client = obj["client"] as? [String: Any] { candidates.append(client) }        if let message = obj["message"] as? [String: Any] {            if let metadata = message["metadata"] as? [String: Any] { candidates.append(metadata) }            if let request = message["request"] as? [String: Any] { candidates.append(request) }        }         return candidates.contains { Self.containsVertexAIMetadata(in: $0) }    }     /// Detects Vertex AI model names by format.    /// Vertex AI uses @ for version separator: claude-opus-4-5@20251101    /// Anthropic API uses -: claude-opus-4-5-20251101    private static func modelNameLooksVertex(_ model: String) -> Bool {        // Vertex AI model format: claude-{variant}@{version}        // Examples: claude-opus-4-5@20251101, claude-sonnet-4-5@20250514        guard model.hasPrefix("claude-") else { return false }        return model.contains("@")    }     private static func containsVertexAIMetadata(in dict: [String: Any]) -> Bool {        for (key, value) in dict {            let lowerKey = key.lowercased()            if lowerKey.contains("vertex") || lowerKey.contains("gcp") {                return true            }            if Self.vertexProviderKeys.contains(lowerKey),               let text = value as? String,               Self.stringLooksVertex(text)            {                return true            }            if let nested = value as? [String: Any] {                if Self.containsVertexAIMetadata(in: nested) { return true }            } else if let array = value as? [Any] {                if Self.containsVertexAIMetadata(in: array) { return true }            }        }         return false    }     private static func containsVertexAIMetadata(in array: [Any]) -> Bool {        for entry in array {            if let dict = entry as? [String: Any] {                if self.containsVertexAIMetadata(in: dict) { return true }            }        }         return false    }     private static func stringLooksVertex(_ value: String) -> Bool {        value.lowercased().contains("vertex")    }     private static func claudeRootCandidates(for rootPath: String) -> [String] {        if rootPath.hasPrefix("/var/") {            return ["/private" + rootPath, rootPath]        }        if rootPath.hasPrefix("/private/var/") {            let trimmed = String(rootPath.dropFirst("/private".count))            return [rootPath, trimmed]        }        return [rootPath]    }     private final class ClaudeScanState {        var cache: CostUsageCache        var touched: Set<String>        let range: CostUsageDayRange        let providerFilter: ClaudeLogProviderFilter        let forceFullScan: Bool        let modelsDevCatalog: ModelsDevCatalog?        let modelsDevCacheRoot: URL?        let checkCancellation: CancellationCheck?         init(            cache: CostUsageCache,            range: CostUsageDayRange,            providerFilter: ClaudeLogProviderFilter,            forceFullScan: Bool,            modelsDevCatalog: ModelsDevCatalog?,

Lines 1–500 of 844.