// Copyright (C) 2026 ysicing // SPDX-License-Identifier: AGPL-3.0-or-later import AIUsage import Foundation import Observation public struct AIUsageProviderState: Equatable, Sendable { public let provider: AIProviderID public var snapshot: AIUsageSnapshot? public var failure: AIUsageFailure? public var isRefreshing = false public init(provider: AIProviderID) { self.provider = provider } } public struct AIQuotaProviderState: Equatable, Sendable { public let provider: AIProviderID public var snapshot: AIQuotaSnapshot? public var failure: AIQuotaFailure? /// 最近一次完成额度查询的发起时间;与快照的上次成功时间分别显示。 public var lastAttemptAt: Date? /// 快照来自磁盘,或本次查询失败后保留的上次成功值。 public var isStale = false public var isRefreshing = false public init(provider: AIProviderID) { self.provider = provider } } /// 本机用量的低频刷新入口。Provider 只负责一次只读扫描;调度与“保留旧值”策略集中在这里。 @MainActor @Observable public final class AIUsageController { public private(set) var states: [AIProviderID: AIUsageProviderState] public private(set) var quotaStates: [AIProviderID: AIQuotaProviderState] /// 供 AppController 观察的刷新脉冲;各额度来源另有自己的 lastAttemptAt。 public private(set) var lastAttemptAt: Date? public private(set) var costReferences = AICostReferenceSnapshot(catalog: nil, exchangeRate: nil) private var costRefreshCount = 0 public var isRefreshingCostReferences: Bool { costRefreshCount > 0 } @ObservationIgnored private var costTasks: [UUID: Task] = [:] @ObservationIgnored private let costReferenceStore: AICostReferenceStore? @ObservationIgnored private let settings: AppSettings @ObservationIgnored private let providers: [any AIUsageProvider] @ObservationIgnored private let quotaProviders: [any AIQuotaProvider] @ObservationIgnored private let quotaCache: AIQuotaCacheStore? @ObservationIgnored private let now: @Sendable () -> Date @ObservationIgnored private var cadenceTask: Task? @ObservationIgnored private var refreshTask: Task? @ObservationIgnored private var generation = 0 @ObservationIgnored private var paused = false public init(settings: AppSettings, providers: [any AIUsageProvider] = [CodexLocalUsageProvider(), ClaudeLocalUsageProvider()], quotaProviders: [any AIQuotaProvider] = [], quotaCacheURL: URL? = nil, costReferenceStore: AICostReferenceStore? = nil, now: @escaping @Sendable () -> Date = Date.init) { self.settings = settings self.costReferenceStore = costReferenceStore self.providers = providers self.quotaProviders = quotaProviders quotaCache = quotaCacheURL.flatMap { try? AIQuotaCacheStore(url: $0) } self.now = now states = Dictionary(uniqueKeysWithValues: providers.map { ($0.id, AIUsageProviderState(provider: $0.id)) }) quotaStates = Dictionary(uniqueKeysWithValues: quotaProviders.map { ($0.id, AIQuotaProviderState(provider: $0.id)) }) restoreCachedQuotas() } /// 由可见的用量页请求;复用价格/汇率缓存,取消随视图任务传播,不增加轮询。 public func refreshCostReferences() async { guard let costReferenceStore, settings.aiUsageEnabled, settings.aiUsageShowsLocalUsage, !paused, localReport(for: nil)?.rows.contains(where: { $0.input > 0 || $0.output > 0 }) == true else { return } let epoch = generation let id = UUID() let needsExchangeRate = settings.aiUsageCurrency == .cny let task = Task { await costReferenceStore.refresh(needsExchangeRate: needsExchangeRate) } costTasks[id] = task costRefreshCount += 1 defer { costTasks[id] = nil; costRefreshCount -= 1 } // 视图消失与系统暂停都必须取消实际请求,不能只丢弃最终结果。 let incoming = await withTaskCancellationHandler { await task.value } onCancel: { task.cancel() } guard !Task.isCancelled, generation == epoch, settings.aiUsageEnabled, settings.aiUsageShowsLocalUsage, !paused else { return } // 多个可见窗口可能并发读取;较早返回的美元请求不能清掉新汇率。 let catalog = [costReferences.catalog, incoming.catalog].compactMap { $0 }.max { $0.fetchedAt < $1.fetchedAt } let rate = [costReferences.exchangeRate, incoming.exchangeRate].compactMap { $0 }.max { $0.fetchedAt < $1.fetchedAt } let merged = AICostReferenceSnapshot(catalog: catalog, exchangeRate: rate) if merged != costReferences { costReferences = merged } } public func estimatedCost(_ rows: [ModelTokenUsage]) -> AICostEstimate { if rows.allSatisfy({ $0.input == 0 && $0.output == 0 && $0.cached == 0 && $0.cacheCreated == 0 }) { return AICostEstimate(usd: 0, unpricedModels: []) } return costReferences.catalog?.estimate(rows) ?? AICostEstimate(usd: nil, unpricedModels: Array(Set(rows.filter { $0.input != 0 || $0.output != 0 || $0.cached != 0 || $0.cacheCreated != 0 }.map(\.model))).sorted()) } private func restoreCachedQuotas() { guard let cached = try? quotaCache?.load() else { return } for provider in quotaProviders where settings.aiUsageSources.contains(provider.id) { guard var state = quotaStates[provider.id], state.snapshot == nil, let snapshot = cached[provider.id] else { continue } state.snapshot = snapshot state.isStale = true quotaStates[provider.id] = state } } public func state(for provider: AIProviderID) -> AIUsageProviderState { states[provider] ?? AIUsageProviderState(provider: provider) } public func quotaState(for provider: AIProviderID) -> AIQuotaProviderState { quotaStates[provider] ?? AIQuotaProviderState(provider: provider) } /// 移除手动账号时丢弃来自 Sub2API 的旧额度,保留本机直连快照。 /// 内存与磁盘分别判断:关闭模块会清空内存但保留缓存,缓存写入失败时两者来源也可能不同。 func clearSub2APIQuota(for provider: AIProviderID) { // 作废在途刷新,否则它返回后会把已移除账号的额度重新写回内存与缓存。 generation += 1 if quotaStates[provider]?.snapshot?.source == .sub2api { quotaStates[provider] = AIQuotaProviderState(provider: provider) } // 读不出缓存时无法确认来源,宁可清除,避免已移除账号的数据在离线启动时重现。 let cached = try? quotaCache?.load() if cached == nil || cached?[provider]?.source == .sub2api { try? quotaCache?.clear(provider) } } public func visibleQuotaState(for provider: AIProviderID) -> AIQuotaProviderState { guard settings.aiUsageEnabled, settings.aiUsageSources.contains(provider) else { return AIQuotaProviderState(provider: provider) } return quotaState(for: provider) } public func visibleQuotaProviders(for selection: AIProviderID?) -> [AIProviderID] { guard settings.aiUsageEnabled else { return [] } return AIProviderID.allCases.filter { id in guard settings.aiUsageSources.contains(id), selection == nil || selection == id, let state = quotaStates[id] else { return false } return state.snapshot != nil || state.failure != .notConfigured } } public var isRefreshing: Bool { states.values.contains(where: \.isRefreshing) || quotaStates.values.contains(where: \.isRefreshing) } public var todayTokens: Int? { guard settings.aiUsageEnabled, settings.aiUsageShowsLocalUsage, let report = localReport(for: nil) else { return nil } return LocalUsageReport.total(report.selected(days: 1, now: now())).total } public func localReport(for provider: AIProviderID?) -> LocalUsageReport? { guard settings.aiUsageShowsLocalUsage else { return nil } let reports = states.values.filter { settings.aiUsageSources.contains($0.provider) && (provider == nil || $0.provider == provider) } .compactMap { $0.snapshot?.localUsage } .filter { !$0.rows.isEmpty || $0.fileCount > 0 } guard !reports.isEmpty else { return nil } return LocalUsageReport(rows: reports.flatMap(\.rows), fileCount: reports.reduce(0) { $0 + $1.fileCount }, unreadableFiles: reports.reduce(0) { $0 + $1.unreadableFiles }) } public func localState(for provider: AIProviderID?) -> (stale: Bool, updated: Date?) { guard settings.aiUsageShowsLocalUsage else { return (false, nil) } let selected = states.values.filter { settings.aiUsageSources.contains($0.provider) && (provider == nil || $0.provider == provider) } return (selected.contains { $0.failure != nil }, selected.compactMap { $0.snapshot?.fetchedAt }.min()) } public func start() { cadenceTask?.cancel() cadenceTask = nil if !settings.aiUsageShowsLocalUsage { for task in costTasks.values { task.cancel() } // 关闭日志读取时丢弃在途扫描;额度轮询会在下方重新启动。 generation += 1 refreshTask?.cancel() refreshTask = nil } for provider in providers where !settings.aiUsageShowsLocalUsage || !settings.aiUsageSources.contains(provider.id) { states[provider.id] = AIUsageProviderState(provider: provider.id) } for provider in quotaProviders where !settings.aiUsageSources.contains(provider.id) { quotaStates[provider.id] = AIQuotaProviderState(provider: provider.id) } guard settings.aiUsageEnabled, !settings.aiUsageSources.isEmpty, !paused else { for task in costTasks.values { task.cancel() } generation += 1 // 不再轮询时,在途的冷扫描也没有意义了 refreshTask?.cancel(); refreshTask = nil if !settings.aiUsageEnabled { states = Dictionary(uniqueKeysWithValues: providers.map { ($0.id, AIUsageProviderState(provider: $0.id)) }) quotaStates = Dictionary(uniqueKeysWithValues: quotaProviders.map { ($0.id, AIQuotaProviderState(provider: $0.id)) }) } return } restoreCachedQuotas() let seconds = settings.aiUsageRefreshMinutes * 60 cadenceTask = Task { [weak self] in guard !Task.isCancelled else { return } await self?.refresh() while !Task.isCancelled { do { try await Task.sleep(for: .seconds(seconds)) } catch { return } guard !Task.isCancelled else { return } await self?.refresh() } } } public func stop() { for task in costTasks.values { task.cancel() } generation += 1 cadenceTask?.cancel() cadenceTask = nil refreshTask?.cancel() refreshTask = nil } /// 系统唤醒会连发屏幕唤醒与系统唤醒两个通知,重复的恢复不能再排一次扫描 public func setPaused(_ value: Bool) { guard value != paused else { return } paused = value if value { stop() } else { start() } } /// 刷新串行排队。旧实现遇到在途刷新就直接返回,而轮询任务把这次返回当成“首次刷新已完成”, /// 接着睡满整个周期(最长 60 分钟);那次在途刷新的结果又会因 generation 变化被丢弃, /// 于是关掉再打开统计后页面会一直空着。链上的任务是非结构化的,stop() 必须显式取消它。 public func refresh() async { let previous = refreshTask let task = Task { @MainActor [weak self] in await previous?.value guard !Task.isCancelled else { return } await self?.performRefresh() } refreshTask = task await task.value } private func performRefresh() async { guard settings.aiUsageEnabled, !paused, !Task.isCancelled else { return } let currentGeneration = generation defer { for id in states.keys { states[id]?.isRefreshing = false } for id in quotaStates.keys { quotaStates[id]?.isRefreshing = false } } let attempt = now() let selectedQuotaProviders = quotaProviders.filter { settings.aiUsageSources.contains($0.id) } for provider in selectedQuotaProviders { quotaStates[provider.id]?.isRefreshing = true } // 在线额度查询与本机日志扫描并行;扫描慢时不额外推迟网络请求。 async let quotaResults = Self.fetchQuotas(selectedQuotaProviders) for provider in providers { guard settings.aiUsageShowsLocalUsage, settings.aiUsageSources.contains(provider.id) else { states[provider.id] = AIUsageProviderState(provider: provider.id) continue } var state = state(for: provider.id) state.isRefreshing = true states[provider.id] = state do { let snapshot = try await provider.fetch() guard currentGeneration == generation, settings.aiUsageEnabled, !Task.isCancelled else { return } guard settings.aiUsageShowsLocalUsage, settings.aiUsageSources.contains(provider.id) else { states[provider.id] = AIUsageProviderState(provider: provider.id) continue } state.snapshot = snapshot state.failure = nil } catch let failure as AIUsageFailure { guard currentGeneration == generation, settings.aiUsageEnabled, !Task.isCancelled else { return } guard settings.aiUsageShowsLocalUsage, settings.aiUsageSources.contains(provider.id) else { states[provider.id] = AIUsageProviderState(provider: provider.id) continue } // 扫描失败不代表日志消失了,保留上一次的统计结果并在页面上标注为旧数据。 state.failure = failure } catch { guard currentGeneration == generation, settings.aiUsageEnabled, !Task.isCancelled else { return } guard settings.aiUsageShowsLocalUsage, settings.aiUsageSources.contains(provider.id) else { states[provider.id] = AIUsageProviderState(provider: provider.id) continue } state.failure = .network } state.isRefreshing = false states[provider.id] = state } for (id, result) in await quotaResults { guard currentGeneration == generation, settings.aiUsageEnabled, !Task.isCancelled else { return } guard settings.aiUsageSources.contains(id) else { quotaStates[id] = AIQuotaProviderState(provider: id) continue } var state = quotaState(for: id) switch result { case .success(let snapshot): state.snapshot = snapshot state.failure = nil state.isStale = false try? quotaCache?.save(snapshot) case .failure(let failure): if failure.preservesLastGood { state.isStale = state.snapshot != nil } else { state.snapshot = nil state.isStale = false try? quotaCache?.clear(id) } state.failure = failure } state.lastAttemptAt = attempt state.isRefreshing = false quotaStates[id] = state } lastAttemptAt = attempt } private static func fetchQuotas(_ providers: [any AIQuotaProvider]) async -> [(AIProviderID, Result)] { await withTaskGroup(of: (AIProviderID, Result).self) { group in for provider in providers { group.addTask { do { return (provider.id, .success(try await provider.fetch())) } catch let failure as AIQuotaFailure { return (provider.id, .failure(failure)) } catch { return (provider.id, .failure(.network)) } } } var results: [(AIProviderID, Result)] = [] for await result in group { results.append(result) } return results } } }