Files
XStats/Packages/XStatsKit/Sources/AIUsage/ClaudeLocalUsageProvider.swift

168 lines
8.5 KiB
Swift

// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import CoreFoundation
/// Claude Code 的输入不包含缓存字段;归一化后与 Codex 共用“含缓存输入”的统计口径。
public actor ClaudeLocalUsageProvider: AIUsageProvider {
/// 解析口径变化时提升版本号;旧命名空间的缓存行会在下一次扫描时被清掉。
private static let source = "claude-v3"
public nonisolated let id = AIProviderID.claude
private let roots: [URL]
private let databaseURL: URL
private let now: @Sendable () -> Date
private var store: UsageScanStore?
private var scanCache: LocalUsageScanCache?
public nonisolated var unownedExecutor: UnownedSerialExecutor {
ScanExecutor.shared.asUnownedSerialExecutor()
}
public init(roots: [URL]? = nil, databaseURL: URL? = nil, now: @escaping @Sendable () -> Date = Date.init) {
self.databaseURL = databaseURL ?? roots?.first?.appendingPathComponent("usage-cache.sqlite") ?? UsageScanStore.defaultURL
self.roots = roots ?? Self.defaultRoots()
self.now = now
}
public static func defaultRoots(environment: [String: String] = ProcessInfo.processInfo.environment,
home: URL = FileManager.default.homeDirectoryForCurrentUser) -> [URL] {
if let override = environment["CLAUDE_CONFIG_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines), !override.isEmpty {
return [URL(fileURLWithPath: (override as NSString).expandingTildeInPath)]
}
let xdg = environment["XDG_CONFIG_HOME"].flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0) }
?? home.appendingPathComponent(".config")
return [home.appendingPathComponent(".claude"), xdg.appendingPathComponent("claude")]
}
public func fetch() async throws -> AIUsageSnapshot {
let now = self.now()
let calendar = Calendar.current
let since = calendar.date(byAdding: .day, value: -364, to: calendar.startOfDay(for: now))!
var files: Set<URL> = []
var unreadable = 0
for root in roots {
let directory = (root.lastPathComponent == "projects" ? root : root.appendingPathComponent("projects"))
.resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: directory.path) else { continue }
var enumerationFailed = false
let enumerator = FileManager.default.enumerator(at: directory,
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey],
options: [.skipsHiddenFiles], errorHandler: { _, _ in enumerationFailed = true; return true })
while let url = enumerator?.nextObject() as? URL {
try Task.checkCancellation()
if url.pathExtension == "jsonl" { files.insert(url) }
}
if enumerationFailed { unreadable += 1 }
}
let revision = unreadable == 0
? try? LocalUsageScanCache.Revision(files: files.sorted { $0.path < $1.path }, now: now, calendar: calendar) : nil
try Task.checkCancellation()
if let revision, let scanCache, scanCache.revision == revision {
var snapshot = AIUsageSnapshot(provider: .claude, fetchedAt: now)
snapshot.localUsage = scanCache.report
return snapshot
}
scanCache = nil
if store == nil { store = try UsageScanStore(url: databaseURL) }
guard let store else { throw AIUsageFailure.invalidResponse }
var events: [String: ClaudeLocalLogParser.Event] = [:]
var scanned: Set<String> = []
for url in files.sorted(by: { $0.path < $1.path }) {
try Task.checkCancellation()
do {
let state = ClaudeLogScanState(calendar: calendar)
let parsed = try store.scan(url: url, source: Self.source, initial: state) { $0.consume($1) }
scanned.insert(url.resolvingSymlinksInPath().path)
for event in parsed.events.values { ClaudeLocalLogParser.merge(event, into: &events) }
} catch is CancellationError { throw CancellationError() }
catch { unreadable += 1 }
}
// 项目目录被删掉后对应的缓存行不会再出现在扫描结果里,留着只会一直变大。
if unreadable == 0 { try? store.prune(family: "claude-", source: Self.source, keeping: scanned) }
var snapshot = AIUsageSnapshot(provider: .claude, fetchedAt: now)
let rows = events.values.lazy.map(\.row).filter { $0.day >= since }
snapshot.localUsage = LocalUsageReport(rows: LocalUsageReport.aggregated(rows),
fileCount: files.count, unreadableFiles: unreadable)
if unreadable == 0, let revision, let report = snapshot.localUsage {
scanCache = LocalUsageScanCache(revision: revision, report: report)
}
return snapshot
}
}
enum ClaudeLocalLogParser {
struct Event: Sendable, Codable {
let id: String
let row: ModelTokenUsage
let final: Bool
let timestamp: Date
}
private static let marker = Data("\"usage\"".utf8)
static func parse(_ data: Data, calendar: Calendar = .current, iso: ISO8601Parser = ISO8601Parser()) -> Event? {
guard data.range(of: marker) != nil,
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
object["type"] as? String == "assistant",
object["isApiErrorMessage"] as? Bool != true,
let message = object["message"] as? [String: Any],
let id = message["id"] as? String, !id.isEmpty,
let usage = message["usage"] as? [String: Any],
let time = iso.date(object["timestamp"]) else { return nil }
func count(_ value: Any?) -> Int? {
guard let n = value as? NSNumber, CFGetTypeID(n) != CFBooleanGetTypeID(),
n.doubleValue >= 0, n.doubleValue < 1e15 else { return nil }
return n.intValue
}
let input = count(usage["input_tokens"]) ?? 0
let output = count(usage["output_tokens"]) ?? 0
let read = count(usage["cache_read_input_tokens"]) ?? 0
let breakdown = usage["cache_creation"] as? [String: Any]
let created = count(usage["cache_creation_input_tokens"])
?? ((count(breakdown?["ephemeral_5m_input_tokens"]) ?? 0) + (count(breakdown?["ephemeral_1h_input_tokens"]) ?? 0))
guard input + output + read + created > 0 else { return nil }
let rawModel = (message["model"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
guard rawModel != "<synthetic>" else { return nil }
let model = rawModel.flatMap { $0.isEmpty ? nil : $0 } ?? "unknown"
let day = calendar.startOfDay(for: time)
let row = ModelTokenUsage(day: day, model: model,
input: input + read + created, cached: read, output: output, records: 1, cacheCreated: created,
hourlyTokens: [UsageTimeSeries.hourStart(for: time, dayStart: day): input + read + created + output])
return Event(id: id, row: row, final: message["stop_reason"] is String, timestamp: time)
}
static func merge(_ event: Event, into events: inout [String: Event]) {
if let prior = events[event.id] {
if prior.final && !event.final { return }
if prior.final == event.final {
if prior.row.output > event.row.output { return }
if prior.row.output == event.row.output && prior.timestamp > event.timestamp { return }
}
}
events[event.id] = event
}
}
/// 消息 ID 要跨文件去重,所以整张表都得落到检查点里。
/// 这里不按保留窗口裁剪:检查点会被原样恢复,存进去的截止日期会永远停在首次扫描那天,
/// 于是过期事件反而再也出不去。窗口一律在取数时按当天重新计算。
private struct ClaudeLogScanState: Codable {
let calendar: Calendar
var events: [String: ClaudeLocalLogParser.Event] = [:]
private let iso = ISO8601Parser()
private enum CodingKeys: String, CodingKey {
case calendar, events
}
init(calendar: Calendar) {
self.calendar = calendar
}
mutating func consume(_ line: Data) {
guard let event = ClaudeLocalLogParser.parse(line, calendar: calendar, iso: iso) else { return }
ClaudeLocalLogParser.merge(event, into: &events)
}
}