Files

342 lines
17 KiB
Swift

// 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<AICostReferenceSnapshot, Never>] = [:]
@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<Void, Never>?
@ObservationIgnored private var refreshTask: Task<Void, Never>?
@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<AIQuotaSnapshot, AIQuotaFailure>)] {
await withTaskGroup(of: (AIProviderID, Result<AIQuotaSnapshot, AIQuotaFailure>).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<AIQuotaSnapshot, AIQuotaFailure>)] = []
for await result in group { results.append(result) }
return results
}
}
}