feat(ai-usage): 添加显示偏好与费用估算

This commit is contained in:
ysicing committed 2026-10-03 20:24:09 +08:00
1 parent 7b789b1ec2
commit e33fa55154
32 files changed
+1320 -63

No files matched your search

@@ -0,0 +1,148 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import CoreFoundation
/// 公开 API 的基础美元单价,单位为每百万 Token;不是订阅账单。
public struct AIModelPrice: Codable, Equatable, Sendable {
public let input: Double
public let output: Double
public let cacheRead: Double?
public let cacheWrite: Double?
public init(input: Double, output: Double, cacheRead: Double? = nil, cacheWrite: Double? = nil) {
self.input = input; self.output = output
self.cacheRead = cacheRead; self.cacheWrite = cacheWrite
}
var isValid: Bool {
[input, output].allSatisfy { $0.isFinite && $0 >= 0 }
&& [cacheRead, cacheWrite].compactMap { $0 }.allSatisfy { $0.isFinite && $0 >= 0 }
}
}
public struct AICostEstimate: Equatable, Sendable {
public let usd: Double?
public let unpricedModels: [String]
public init(usd: Double?, unpricedModels: [String]) {
self.usd = usd; self.unpricedModels = unpricedModels
}
}
public struct AIPriceCatalog: Codable, Equatable, Sendable {
public let prices: [String: AIModelPrice]
public let fetchedAt: Date
public init(prices: [String: AIModelPrice], fetchedAt: Date) {
self.prices = prices; self.fetchedAt = fetchedAt
}
/// 本地输入包含缓存读取和创建;输出已包含推理 Token,不能再次叠加。
/// 仅采用目录中的基础费率,不推测逐请求上下文阶梯、缓存时长或订阅折扣。
public func estimate(_ rows: [ModelTokenUsage]) -> AICostEstimate {
var total = 0.0
var priced = rows.isEmpty
var unknown = Set<String>()
for row in rows {
guard row.input >= 0, row.cached >= 0, row.cacheCreated >= 0, row.output >= 0,
row.cached <= row.input, row.cacheCreated <= row.input - row.cached else {
unknown.insert(row.model); continue
}
if row.input == 0 && row.output == 0 { priced = true; continue }
var model = row.model
// 只去掉明确的厂商命名空间;不剥离日期、版本或任意后缀。
if model.hasPrefix("openai/"), !model.dropFirst(7).hasPrefix("claude-") {
model = String(model.dropFirst(7))
} else if model.hasPrefix("anthropic/"), model.dropFirst(10).hasPrefix("claude-") {
model = String(model.dropFirst(10))
}
guard let price = prices[row.model] ?? prices[model], price.isValid else {
unknown.insert(row.model); continue
}
let categories: [(Int, Double?)] = [
(row.input - row.cached - row.cacheCreated, price.input),
(row.cached, price.cacheRead), (row.cacheCreated, price.cacheWrite), (row.output, price.output)
]
var amount = 0.0
var complete = true
for (tokens, rate) in categories where tokens > 0 {
guard let rate else { complete = false; break }
// 先缩放,避免合法大 Token 数与单价的中间乘积溢出。
amount += (Double(tokens) / 1_000_000) * rate
}
guard complete, amount.isFinite, (total + amount).isFinite else {
unknown.insert(row.model); continue
}
total += amount; priced = true
}
return AICostEstimate(usd: priced ? total : nil, unpricedModels: unknown.sorted())
}
/// models.dev 的 cost 为美元/百万 Token。忽略第三方转售商和缺失必需单价的模型。
static func parse(_ data: Data, fetchedAt: Date) throws -> Self {
guard data.count <= AICostReferenceStore.catalogLimit,
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw AICostReferenceError.invalidResponse
}
var prices: [String: AIModelPrice] = [:]
for provider in ["openai", "anthropic"] {
guard let entry = root[provider] as? [String: Any],
let models = entry["models"] as? [String: Any] else { continue }
for (model, value) in models {
try Task.checkCancellation()
guard !model.isEmpty, model.utf8.count <= 256, !model.contains("/"),
let object = value as? [String: Any],
let cost = object["cost"] as? [String: Any],
let input = AICostReferenceNumber.nonnegative(cost["input"]),
let output = AICostReferenceNumber.nonnegative(cost["output"]) else { continue }
// 非法可选价格作为缺失处理,只有实际使用该类别时才无法估算。
prices[model] = AIModelPrice(input: input, output: output,
cacheRead: AICostReferenceNumber.nonnegative(cost["cache_read"]),
cacheWrite: AICostReferenceNumber.nonnegative(cost["cache_write"]))
}
}
guard !prices.isEmpty else { throw AICostReferenceError.invalidResponse }
return Self(prices: prices, fetchedAt: fetchedAt)
}
}
public struct AIExchangeRate: Codable, Equatable, Sendable {
public let cnyPerUSD: Double
public let fetchedAt: Date
public init(cnyPerUSD: Double, fetchedAt: Date) {
self.cnyPerUSD = cnyPerUSD; self.fetchedAt = fetchedAt
}
static func parse(_ data: Data, fetchedAt: Date) throws -> Self {
guard data.count <= AICostReferenceStore.exchangeLimit,
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
root["result"] as? String == "success", root["base_code"] as? String == "USD",
let rates = root["rates"] as? [String: Any],
let rate = AICostReferenceNumber.nonnegative(rates["CNY"]), rate > 0 else {
throw AICostReferenceError.invalidResponse
}
return Self(cnyPerUSD: rate, fetchedAt: fetchedAt)
}
}
public struct AICostReferenceSnapshot: Equatable, Sendable {
public let catalog: AIPriceCatalog?
public let exchangeRate: AIExchangeRate?
public init(catalog: AIPriceCatalog?, exchangeRate: AIExchangeRate?) {
self.catalog = catalog; self.exchangeRate = exchangeRate
}
}
enum AICostReferenceError: Error, Equatable { case invalidResponse, responseTooLarge }
private enum AICostReferenceNumber {
static func nonnegative(_ value: Any?) -> Double? {
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(),
number.doubleValue.isFinite, number.doubleValue >= 0 else { return nil }
return number.doubleValue
}
}
@@ -0,0 +1,196 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
/// 按需获取公开参考数据,无定时器;磁盘仅保存两个有界的参考数据文件。
public actor AICostReferenceStore {
public static let shared = AICostReferenceStore()
public static var defaultCacheDirectory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("XStats/AIReference", isDirectory: true)
}
static let catalogLimit = 16 * 1024 * 1024
static let exchangeLimit = 1024 * 1024
private static let cacheLimit = 1024 * 1024
private static let retryDelay: TimeInterval = 600
private let cacheDirectory: URL?
private let now: @Sendable () -> Date
private let fetch: @Sendable (URL, Int) async throws -> Data
private var loaded = false
private var catalog: AIPriceCatalog?
private var exchangeRate: AIExchangeRate?
private var catalogRetryAt: Date?
private var exchangeRetryAt: Date?
private var flight: Flight?
private struct Flight {
let id: UUID
let task: Task<RefreshResult, Never>
var waiters: Set<UUID>
}
private struct RefreshResult: Sendable {
var catalog: AIPriceCatalog?
var exchangeRate: AIExchangeRate?
var catalogFailed = false
var exchangeFailed = false
}
/// nil 缓存目录用于无持久化环境。fetch/now 只用于注入可重复测试依赖。
public init(cacheDirectory: URL? = AICostReferenceStore.defaultCacheDirectory,
now: @escaping @Sendable () -> Date = { Date() },
fetch: (@Sendable (URL, Int) async throws -> Data)? = nil) {
self.cacheDirectory = cacheDirectory; self.now = now
self.fetch = fetch ?? Self.download
}
/// 失败继续返回已有缓存;并发需求合并,最后一个等待者取消时终止请求。
/// 只有未取消的等待者可提交结果;美元需求不会主动请求汇率。
public func refresh(needsExchangeRate: Bool) async -> AICostReferenceSnapshot {
guard !Task.isCancelled else { return snapshot }
loadCache()
let waiter = UUID()
while !Task.isCancelled {
let date = now()
let needsCatalog = needsRefresh(catalog?.fetchedAt, ttl: 86_400, retryAt: catalogRetryAt, now: date)
let needsExchange = needsExchangeRate
&& needsRefresh(exchangeRate?.fetchedAt, ttl: 43_200, retryAt: exchangeRetryAt, now: date)
guard needsCatalog || needsExchange else { return snapshot }
let current: Flight
if var existing = flight {
existing.waiters.insert(waiter); flight = existing; current = existing
} else {
let task = Task { await self.fetchReferences(catalog: needsCatalog, exchange: needsExchange) }
current = Flight(id: UUID(), task: task, waiters: [waiter]); flight = current
}
let result = await withTaskCancellationHandler {
await current.task.value
} onCancel: {
Task { await self.cancelWaiter(waiter, flightID: current.id) }
}
guard !Task.isCancelled else {
cancelWaiter(waiter, flightID: current.id)
return snapshot
}
// 其他等待者可能已经提交结果或取消了整轮;旧轮次不能覆盖新状态。
if flight?.id == current.id {
flight = nil
guard !current.task.isCancelled else { continue }
let finished = now()
if let value = result.catalog {
catalog = value; catalogRetryAt = nil; save(value, name: "prices-v1.json")
} else if result.catalogFailed {
catalogRetryAt = finished.addingTimeInterval(Self.retryDelay)
}
if let value = result.exchangeRate {
exchangeRate = value; exchangeRetryAt = nil; save(value, name: "exchange-v1.json")
} else if result.exchangeFailed {
exchangeRetryAt = finished.addingTimeInterval(Self.retryDelay)
}
}
// 若人民币调用加入了只获取价格的轮次,价格提交后再按需补一次汇率。
}
return snapshot
}
private var snapshot: AICostReferenceSnapshot {
AICostReferenceSnapshot(catalog: catalog, exchangeRate: exchangeRate)
}
private func cancelWaiter(_ waiter: UUID, flightID: UUID) {
guard var current = flight, current.id == flightID else { return }
current.waiters.remove(waiter)
if current.waiters.isEmpty { current.task.cancel(); flight = nil } else { flight = current }
}
private func needsRefresh(_ fetchedAt: Date?, ttl: TimeInterval, retryAt: Date?, now: Date) -> Bool {
if let retryAt, now < retryAt { return false }
guard let fetchedAt else { return true }
let age = now.timeIntervalSince(fetchedAt)
return age < 0 || age >= ttl
}
private func fetchReferences(catalog needsCatalog: Bool, exchange needsExchange: Bool) async -> RefreshResult {
var result = RefreshResult()
if needsCatalog {
do {
try Task.checkCancellation()
let data = try await fetch(URL(string: "https://models.dev/api.json")!, Self.catalogLimit)
try Task.checkCancellation()
result.catalog = try AIPriceCatalog.parse(data, fetchedAt: now())
} catch {
result.catalogFailed = !Task.isCancelled
}
}
if needsExchange && !Task.isCancelled {
do {
let data = try await fetch(URL(string: "https://open.er-api.com/v6/latest/USD")!, Self.exchangeLimit)
try Task.checkCancellation()
result.exchangeRate = try AIExchangeRate.parse(data, fetchedAt: now())
} catch {
result.exchangeFailed = !Task.isCancelled
}
}
return result
}
private func loadCache() {
guard !loaded else { return }
loaded = true
if let value: AIPriceCatalog = read(name: "prices-v1.json"),
value.fetchedAt.timeIntervalSince1970.isFinite, !value.prices.isEmpty,
value.prices.allSatisfy({ !$0.key.isEmpty && $0.key.utf8.count <= 256 && $0.value.isValid }) {
catalog = value
}
if let value: AIExchangeRate = read(name: "exchange-v1.json"),
value.fetchedAt.timeIntervalSince1970.isFinite, value.cnyPerUSD.isFinite, value.cnyPerUSD > 0 {
exchangeRate = value
}
}
private func read<Value: Decodable>(name: String) -> Value? {
guard let url = cacheDirectory?.appendingPathComponent(name),
let file = try? FileHandle(forReadingFrom: url) else { return nil }
defer { try? file.close() }
// 有界读取也防止磁盘缓存被意外替换为大型文件。
guard let data = try? file.read(upToCount: Self.cacheLimit + 1), data.count <= Self.cacheLimit else { return nil }
return try? JSONDecoder().decode(Value.self, from: data)
}
private func save(_ value: some Encodable, name: String) {
guard !Task.isCancelled, let cacheDirectory,
let data = try? JSONEncoder().encode(value), data.count <= Self.cacheLimit else { return }
do {
try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
try data.write(to: cacheDirectory.appendingPathComponent(name), options: .atomic)
} catch {
// 缓存写入失败不使已验证的公开参考数据失效,下一次启动重新获取。
}
}
private static func download(_ url: URL, limit: Int) async throws -> Data {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 15
configuration.timeoutIntervalForResource = 15
configuration.urlCache = nil
configuration.httpCookieStorage = nil
configuration.httpShouldSetCookies = false
let session = URLSession(configuration: configuration)
defer { session.invalidateAndCancel() }
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (bytes, response) = try await session.bytes(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw AICostReferenceError.invalidResponse
}
guard response.expectedContentLength <= Int64(limit) else { throw AICostReferenceError.responseTooLarge }
var data = Data()
for try await byte in bytes {
guard data.count < limit else { throw AICostReferenceError.responseTooLarge }
data.append(byte)
if data.count % 16_384 == 0 { try Task.checkCancellation() }
}
try Task.checkCancellation()
return data
}
}
@@ -45,10 +45,12 @@ public struct LocalUsageReport: Equatable, Sendable {
rows.reduce(into: ModelTokenUsage(day: .distantPast, model: "")) { $0.add($1) }
}
/// 活动图模式同时控制摘要:每日取本日,每周取本周,累计取本月;热力图仍单独取全年。
/// 活动图模式同时控制摘要:每日取本日,每周取本周,累计与活动图共用最近 365 天。
public func summary(mode: UsageActivityMode, model: String? = nil, now: Date = Date(),
calendar: Calendar = .current) -> [ModelTokenUsage] {
let component: Calendar.Component = mode == .daily ? .day : mode == .weekly ? .weekOfYear : .month
// 累计摘要、费用和排行必须覆盖图表同一窗口,不能仅从本月月初开始。
if mode == .cumulative { return selected(days: 365, model: model, now: now, calendar: calendar) }
let component: Calendar.Component = mode == .daily ? .day : .weekOfYear
let start = calendar.dateInterval(of: component, for: now)?.start ?? calendar.startOfDay(for: now)
return rows.filter { $0.day >= start && $0.day <= now && (model == nil || $0.model == model) }
}
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
/// AI 费用的显示币种;底层统计始终保存美元金额。
public enum AIUsageCurrency: String, CaseIterable, Codable, Sendable {
case usd
case cny
public var currencyCode: String {
switch self {
case .usd: "USD"
case .cny: "CNY"
}
}
}
/// 主应用与 Widget 共用的纯显示格式化,不读取或修改全局语言偏好。
public enum AIUsageFormat {
/// 按指定语言缩写 token 数;中文默认使用万、亿,西式单位为 K、M、B。
public static func tokens(_ value: Int, westernUnits: Bool = false, locale: Locale) -> String {
let number = Double(value)
let magnitude = abs(number)
let divisor: Double
let suffix: String
let fractionDigits: Int
if !westernUnits, locale.language.languageCode?.identifier == "zh" {
let traditional = locale.language.script?.identifier == "Hant"
// 在低档位舍入会抵达下一单位时提前升档,避免显示 10000 万。
if magnitude >= 99_999_500 {
divisor = 100_000_000
suffix = traditional ? "億" : "亿"
fractionDigits = 2
} else if magnitude >= 10_000 {
divisor = 10_000
suffix = traditional ? "萬" : "万"
fractionDigits = 1
} else {
return value.formatted(.number.locale(locale).grouping(.never))
}
} else {
// K/M 的大数档取整;跨界前提前升档,避免 1000K 和 1000M。
if magnitude >= 999_500_000 {
divisor = 1_000_000_000
suffix = "B"
fractionDigits = 2
} else if magnitude >= 999_500 {
divisor = 1_000_000
suffix = "M"
fractionDigits = magnitude >= 10_000_000 ? 0 : 1
} else if magnitude >= 1_000 {
divisor = 1_000
suffix = "K"
fractionDigits = magnitude >= 100_000 ? 0 : 1
} else {
return value.formatted(.number.locale(locale).grouping(.never))
}
}
return (number / divisor).formatted(
.number.locale(locale).grouping(.never).precision(.fractionLength(0...fractionDigits))
) + suffix
}
/// 将有效剩余额度限制到 0...100,再按显示方向返回剩余或已用百分数。
public static func quotaValue(remainingPercent: Double, showsRemaining: Bool) -> Double? {
guard remainingPercent.isFinite else { return nil }
let remaining = min(100, max(0, remainingPercent))
return showsRemaining ? remaining : 100 - remaining
}
/// 格式化额度百分数;未知值显示破折号,紧凑显示保留接近空额、满额的区别。
public static func quotaPercent(
remainingPercent: Double, showsRemaining: Bool, locale: Locale, compact: Bool = false
) -> String {
guard let value = quotaValue(remainingPercent: remainingPercent, showsRemaining: showsRemaining) else {
return "—"
}
let style = FloatingPointFormatStyle<Double>.Percent(locale: locale)
.precision(.fractionLength(0...(compact ? 0 : 1)))
if compact, value > 0, value < 1 {
return "<" + 0.01.formatted(style)
}
if compact, value > 99, value < 100 {
return ">" + 0.99.formatted(style)
}
return (value / 100).formatted(style)
}
/// 仅在显示时兑换美元金额;无效金额、缺失汇率或乘法上溢返回 nil。
/// 此接口仅接受美元费用,不用于 credits 等非货币计量。
public static func money(
usd: Double, currency: AIUsageCurrency, usdToCNY: Double?, locale: Locale
) -> String? {
guard usd.isFinite, usd >= 0 else { return nil }
let amount: Double
switch currency {
case .usd:
amount = usd
case .cny:
guard let rate = usdToCNY, rate.isFinite, rate > 0 else { return nil }
amount = usd * rate
}
guard amount.isFinite else { return nil }
let fractionDigits = amount >= 100 ? 0 : amount >= 1 ? 2 : 3
return amount.formatted(
.currency(code: currency.currencyCode).locale(locale)
.precision(.fractionLength(0...fractionDigits))
)
}
}
@@ -1620,3 +1620,19 @@ GitHub 项目 مشروع GitHub
不能在 XStats 内卸载或退出 XStats 自身 لا يمكن لهذه الأداة إلغاء تثبيت XStats نفسه أو إنهاؤه
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 تعذر نقل التطبيق. نُقلت {} من العناصر المرتبطة إلى سلة المهملات (نحو {}). عالج الخطأ ثم أعد المحاولة.
黄历 التقويم الصيني التقليدي
额度显示 عرض الحصة
数字单位 وحدات الأرقام
万 / 亿 ١٠ آلاف / ١٠٠ مليون
货币 العملة
美元 الدولار الأمريكي
人民币 اليوان الصيني
估算费用 التكلفة التقديرية
暂无汇率 سعر الصرف غير متاح
暂无价格 الأسعار غير متاحة
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 تقدير وفق أسعار API الأساسية لكل نموذج، دون الزيادات حسب الشرائح. ليس فاتورة اشتراك.
部分模型暂无价格 الأسعار غير متاحة لبعض النماذج
价格更新:{} تحديث الأسعار: {}
参考汇率:1 USD ≈ {} CNY سعر الصرف المرجعي: 1 USD ≈ {} CNY
汇率更新:{} تحديث سعر الصرف: {}
价格来源:models.dev;人民币按缓存汇率换算。 مصدر الأسعار: models.dev؛ تُحوَّل المبالغ إلى اليوان الصيني بسعر الصرف المخزّن مؤقتًا.
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 استخدام الذكاء الاصطناعي والحصة يشتركان في هذا الفاصل؛ يُحدَّث شريط القوائم عند وصول بيانات جديدة.
1 # Copyright (C) 2026 ysicing
1620 不能在 XStats 内卸载或退出 XStats 自身
1621 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
1622 黄历
1623 额度显示
1624 数字单位
1625 万 / 亿
1626 货币
1627 美元
1628 人民币
1629 估算费用
1630 暂无汇率
1631 暂无价格
1632 按模型基础 API 单价估算,不含阶梯加价,非订阅账单。
1633 部分模型暂无价格
1634 价格更新:{}
1635 参考汇率:1 USD ≈ {} CNY
1636 汇率更新:{}
1637 价格来源:models.dev;人民币按缓存汇率换算。
1638 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
@@ -1620,3 +1620,19 @@ GitHub 项目 GitHub-Projekt
不能在 XStats 内卸载或退出 XStats 自身 XStats kann sich mit diesem Werkzeug nicht selbst deinstallieren oder beenden
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 Die App konnte nicht verschoben werden. Zugehörige Objekte im Papierkorb: {} (ca. {}). Beheben Sie den Fehler und versuchen Sie es erneut.
黄历 Chinesischer Almanach
额度显示 Kontingentanzeige
数字单位 Zahleneinheiten
万 / 亿 10 Tsd. / 100 Mio.
货币 Währung
美元 US-Dollar
人民币 Chinesischer Yuan
估算费用 Geschätzte Kosten
暂无汇率 Wechselkurs nicht verfügbar
暂无价格 Preise nicht verfügbar
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 Schätzung anhand der API-Basispreise je Modell, ohne gestaffelte Aufschläge. Keine Abonnementrechnung.
部分模型暂无价格 Für einige Modelle sind keine Preise verfügbar
价格更新:{} Preisaktualisierung: {}
参考汇率:1 USD ≈ {} CNY Referenzkurs: 1 USD ≈ {} CNY
汇率更新:{} Wechselkursaktualisierung: {}
价格来源:models.dev;人民币按缓存汇率换算。 Preisquelle: models.dev; Yuan-Beträge werden mit dem gespeicherten Wechselkurs umgerechnet.
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 KI-Nutzung und Kontingent teilen dieses Intervall; Die Menüleiste wird aktualisiert, sobald neue Daten vorliegen.
1 # Copyright (C) 2026 ysicing
1620 不能在 XStats 内卸载或退出 XStats 自身
1621 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
1622 黄历
1623 额度显示
1624 数字单位
1625 万 / 亿
1626 货币
1627 美元
1628 人民币
1629 估算费用
1630 暂无汇率
1631 暂无价格
1632 按模型基础 API 单价估算,不含阶梯加价,非订阅账单。
1633 部分模型暂无价格
1634 价格更新:{}
1635 参考汇率:1 USD ≈ {} CNY
1636 汇率更新:{}
1637 价格来源:models.dev;人民币按缓存汇率换算。
1638 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
@@ -1620,3 +1620,19 @@ GitHub 项目 Proyecto en GitHub
不能在 XStats 内卸载或退出 XStats 自身 XStats no puede desinstalarse ni cerrarse desde esta herramienta
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 No se pudo mover la app. Se movieron {} elementos relacionados a la Papelera (aprox. {}). Resuelve el error e inténtalo de nuevo.
黄历 Almanaque chino
额度显示 Visualización de cuota
数字单位 Unidades numéricas
万 / 亿 10 mil / 100 millones
货币 Moneda
美元 Dólar estadounidense
人民币 Yuan chino
估算费用 Coste estimado
暂无汇率 Tipo de cambio no disponible
暂无价格 Precios no disponibles
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 Estimación basada en los precios base de la API de cada modelo, sin recargos por tramos. No es una factura de suscripción.
部分模型暂无价格 Precios no disponibles para algunos modelos
价格更新:{} Precios actualizados: {}
参考汇率:1 USD ≈ {} CNY Tipo de referencia: 1 USD ≈ {} CNY
汇率更新:{} Tipo de cambio actualizado: {}
价格来源:models.dev;人民币按缓存汇率换算。 Fuente de precios: models.dev; Los importes en yuanes se convierten con el tipo de cambio almacenado.
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 El uso de IA y la cuota comparten este intervalo; La barra de menús se actualiza cuando llegan nuevos datos.
1 # Copyright (C) 2026 ysicing
1620 不能在 XStats 内卸载或退出 XStats 自身
1621 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
1622 黄历
1623 额度显示
1624 数字单位
1625 万 / 亿
1626 货币
1627 美元
1628 人民币
1629 估算费用
1630 暂无汇率
1631 暂无价格
1632 按模型基础 API 单价估算,不含阶梯加价,非订阅账单。
1633 部分模型暂无价格
1634 价格更新:{}
1635 参考汇率:1 USD ≈ {} CNY
1636 汇率更新:{}
1637 价格来源:models.dev;人民币按缓存汇率换算。
1638 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
@@ -1620,3 +1620,19 @@ GitHub 项目 Projet GitHub
不能在 XStats 内卸载或退出 XStats 自身 XStats ne peut pas se désinstaller ou se quitter depuis cet outil
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 L’app n’a pas pu être déplacée. Éléments associés placés dans la Corbeille : {} (environ {}). Corrigez l’erreur et réessayez.
黄历 Almanach chinois
额度显示 Affichage du quota
数字单位 Unités numériques
万 / 亿 10 mille / 100 millions
货币 Devise
美元 Dollar américain
人民币 Yuan chinois
估算费用 Coût estimé
暂无汇率 Taux de change indisponible
暂无价格 Tarifs indisponibles
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 Estimation selon les tarifs API de base de chaque modèle, hors majorations par palier. Il ne s’agit pas d’une facture d’abonnement.
部分模型暂无价格 Tarifs indisponibles pour certains modèles
价格更新:{} Tarifs mis à jour : {}
参考汇率:1 USD ≈ {} CNY Taux de référence : 1 USD ≈ {} CNY
汇率更新:{} Taux de change mis à jour : {}
价格来源:models.dev;人民币按缓存汇率换算。 Source des tarifs : models.dev; Les montants en yuans sont convertis au taux de change en cache.
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 L’utilisation de l’IA et le quota partagent cet intervalle; La barre des menus est mise à jour à l’arrivée de nouvelles données.
1 # Copyright (C) 2026 ysicing
1620 不能在 XStats 内卸载或退出 XStats 自身
1621 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
1622 黄历
1623 额度显示
1624 数字单位
1625 万 / 亿
1626 货币
1627 美元
1628 人民币
1629 估算费用
1630 暂无汇率
1631 暂无价格
1632 按模型基础 API 单价估算,不含阶梯加价,非订阅账单。
1633 部分模型暂无价格
1634 价格更新:{}
1635 参考汇率:1 USD ≈ {} CNY
1636 汇率更新:{}
1637 价格来源:models.dev;人民币按缓存汇率换算。
1638 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
@@ -1620,3 +1620,19 @@ GitHub 项目 GitHub プロジェクト
不能在 XStats 内卸载或退出 XStats 自身 このツールからXStats自身をアンインストールまたは終了することはできません
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 アプリ本体を移動できませんでした。関連項目を{}件(約{})ゴミ箱に移動しました。エラーを解決して再試行してください。
黄历 中国の伝統暦
额度显示 割り当ての表示
数字单位 数値の単位
万 / 亿 万 / 億
货币 通貨
美元 米ドル
人民币 人民元
估算费用 推定費用
暂无汇率 為替レートなし
暂无价格 料金情報なし
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 モデルごとの基本 API 単価で推定し、段階的な追加料金は含みません。サブスクリプションの請求額ではありません。
部分模型暂无价格 一部のモデルの料金情報がありません
价格更新:{} 料金更新:{}
参考汇率:1 USD ≈ {} CNY 参考レート:1 USD ≈ {} CNY
汇率更新:{} 為替レート更新:{}
价格来源:models.dev;人民币按缓存汇率换算。 料金情報元:models.dev;人民元はキャッシュされた為替レートで換算します。
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 使用量と割り当てでこの間隔を共用;新しいデータの取得後にメニューバーを更新します。
1 # Copyright (C) 2026 ysicing
1620 不能在 XStats 内卸载或退出 XStats 自身
1621 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
1622 黄历
1623 额度显示
1624 数字单位
1625 万 / 亿
1626 货币
1627 美元
1628 人民币
1629 估算费用
1630 暂无汇率
1631 暂无价格
1632 按模型基础 API 单价估算,不含阶梯加价,非订阅账单。
1633 部分模型暂无价格
1634 价格更新:{}
1635 参考汇率:1 USD ≈ {} CNY
1636 汇率更新:{}
1637 价格来源:models.dev;人民币按缓存汇率换算。
1638 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
@@ -1620,3 +1620,19 @@ GitHub 项目 GitHub 프로젝트
不能在 XStats 内卸载或退出 XStats 自身 이 도구에서 XStats 자체를 제거하거나 종료할 수 없습니다
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 앱 본체를 이동하지 못했습니다. 관련 항목 {}개(약 {})를 휴지통으로 이동했습니다. 오류를 해결한 후 다시 시도하세요.
黄历 중국 전통 역서
额度显示 할당량 표시
数字单位 숫자 단위
万 / 亿 만 / 억
货币 통화
美元 미국 달러
人民币 중국 위안
估算费用 예상 비용
暂无汇率 환율 정보 없음
暂无价格 가격 정보 없음
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 모델별 기본 API 단가로 추정하며 구간별 추가 요금은 포함하지 않습니다. 구독 청구액이 아닙니다.
部分模型暂无价格 일부 모델의 가격 정보 없음
价格更新:{} 가격 업데이트: {}
参考汇率:1 USD ≈ {} CNY 참고 환율: 1 USD ≈ {} CNY
汇率更新:{} 환율 업데이트: {}
价格来源:models.dev;人民币按缓存汇率换算。 가격 출처: models.dev; 중국 위안 금액은 캐시된 환율로 환산합니다.
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 사용량과 할당량에 동일한 간격 적용; 새 데이터가 도착하면 메뉴 막대가 업데이트됩니다.
1 # Copyright (C) 2026 ysicing
1620 不能在 XStats 内卸载或退出 XStats 自身
1621 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
1622 黄历
1623 额度显示
1624 数字单位
1625 万 / 亿
1626 货币
1627 美元
1628 人民币
1629 估算费用
1630 暂无汇率
1631 暂无价格
1632 按模型基础 API 单价估算,不含阶梯加价,非订阅账单。
1633 部分模型暂无价格
1634 价格更新:{}
1635 参考汇率:1 USD ≈ {} CNY
1636 汇率更新:{}
1637 价格来源:models.dev;人民币按缓存汇率换算。
1638 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
@@ -1621,3 +1621,19 @@ GitHub 项目 GitHub 專案
不能在 XStats 内卸载或退出 XStats 自身 不能在 XStats 內卸載或退出 XStats 自身
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 應用本體未能移動;已將 {} 項殘留移到廢紙簍,約 {}。請解決錯誤後重試。
黄历 黃曆
额度显示 額度顯示
数字单位 數字單位
万 / 亿 萬 / 億
货币 貨幣
美元 美元
人民币 人民幣
估算费用 估算費用
暂无汇率 暫無匯率
暂无价格 暫無價格
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 按模型基礎 API 單價估算,不含階梯加價,非訂閱帳單。
部分模型暂无价格 部分模型暫無價格
价格更新:{} 價格更新:{}
参考汇率:1 USD ≈ {} CNY 參考匯率:1 USD ≈ {} CNY
汇率更新:{} 匯率更新:{}
价格来源:models.dev;人民币按缓存汇率换算。 價格來源:models.dev;人民幣按快取匯率換算。
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 用量與額度共用此間隔;新資料到達後更新選單列。
1 # Copyright (c) 2026 GiantAccel, LLC
1621 不能在 XStats 内卸载或退出 XStats 自身
1622 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
1623 黄历
1624 额度显示
1625 数字单位
1626 万 / 亿
1627 货币
1628 美元
1629 人民币
1630 估算费用
1631 暂无汇率
1632 暂无价格
1633 按模型基础 API 单价估算,不含阶梯加价,非订阅账单。
1634 部分模型暂无价格
1635 价格更新:{}
1636 参考汇率:1 USD ≈ {} CNY
1637 汇率更新:{}
1638 价格来源:models.dev;人民币按缓存汇率换算。
1639 AI 用量与额度共用此间隔;新数据到达后更新菜单栏。
@@ -81,6 +81,22 @@ Token 活动 Token activity
截至 {} 当周累计:{} Tokens Through the week of {}: {} Tokens cumulative
缓存创建 Token Cache creation tokens
AI 用量 AI Usage
额度显示 Quota display
数字单位 Number units
万 / 亿 10K / 100M
货币 Currency
美元 US dollar
人民币 Chinese yuan
估算费用 Estimated cost
暂无汇率 Exchange rate unavailable
暂无价格 Pricing unavailable
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 Estimated from base API prices per model, excluding tiered surcharges; this is not a subscription bill.
部分模型暂无价格 Pricing unavailable for some models
价格更新:{} Prices updated: {}
参考汇率:1 USD ≈ {} CNY Reference rate: 1 USD ≈ {} CNY
汇率更新:{} Exchange rate updated: {}
价格来源:models.dev;人民币按缓存汇率换算。 Pricing source: models.dev; Chinese yuan amounts use the cached exchange rate.
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI usage and quota share this interval; The menu bar updates when new data arrives.
显示本地用量 Show local usage
读取本机会话日志;关闭后停止扫描并隐藏 Token 统计。 Reads local session logs; turning this off stops scanning and hides token statistics.
查看 Codex / Claude 的 Token 用量与订阅额度。 View Codex / Claude token usage and subscription quotas.
@@ -161,6 +161,9 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
public var dailyTokens: [DailyTokenUsage]?
/// 可选以兼容旧版摘要;缺失时沿用 AI 总开关的旧行为。
public var localUsageEnabled: Bool?
/// 可选显示偏好;旧摘要沿用剩余额度和按语言缩写。
public var quotaShowsRemaining: Bool?
public var westernUnits: Bool?
public var publicIPEnabled: Bool
public var addresses: [Address]
public var language: String
@@ -177,7 +180,7 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
/// 写入日历摘要的主应用构建号;新版可能内置新的放假数据,构建号不同时不复用缓存。
public var calendarDataVersion: String?
public init(aiEnabled: Bool = false, quotas: [Quota] = [], dailyTokens: [DailyTokenUsage]? = nil,
localUsageEnabled: Bool? = nil,
localUsageEnabled: Bool? = nil, quotaShowsRemaining: Bool? = nil, westernUnits: Bool? = nil,
publicIPEnabled: Bool = false,
addresses: [Address] = [], language: String = "system",
calendarFirstWeekday: Int = Calendar.current.firstWeekday, showsLunar: Bool = Locale.current.region?.identifier == "CN",
@@ -187,6 +190,8 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
self.quotas = quotas
self.dailyTokens = dailyTokens
self.localUsageEnabled = localUsageEnabled
self.quotaShowsRemaining = quotaShowsRemaining
self.westernUnits = westernUnits
self.publicIPEnabled = publicIPEnabled
self.addresses = addresses
self.language = language
@@ -225,7 +230,9 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
}
let relocalized = language != previous.language
let quotaChanged = relocalized || aiEnabled != previous.aiEnabled
|| (quotaShowsRemaining != false) != (previous.quotaShowsRemaining != false)
let localUsageChanged = relocalized || showsLocalUsage != previous.showsLocalUsage
|| (westernUnits == true) != (previous.westernUnits == true)
var kinds: [String] = []
if quotaChanged || displayed(quotas) != displayed(previous.quotas) { kinds.append(WidgetKind.aiQuota) }
if localUsageChanged || dailyTokens != previous.dailyTokens { kinds.append(WidgetKind.todayTokens) }
@@ -382,6 +382,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
withObservationTracking {
_ = model.aiUsage.states
_ = model.aiUsage.lastAttemptAt
_ = model.settings.aiQuotaShowsRemaining
_ = model.settings.aiUsageWesternUnits
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
@@ -397,6 +399,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
withObservationTracking {
_ = model.settings.aiUsageEnabled
_ = model.settings.aiUsageSources
_ = model.settings.aiQuotaShowsRemaining
_ = model.settings.aiUsageWesternUnits
_ = model.settings.aiUsageShowsLocalUsage
_ = model.settings.publicIPLookup
_ = model.settings.language
@@ -484,6 +488,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
let snapshot = WidgetSnapshot(aiEnabled: settings.aiUsageEnabled, quotas: quotas,
dailyTokens: dailyTokens,
localUsageEnabled: settings.aiUsageShowsLocalUsage,
quotaShowsRemaining: settings.aiQuotaShowsRemaining,
westernUnits: settings.aiUsageWesternUnits,
publicIPEnabled: settings.publicIPLookup, addresses: addresses,
language: settings.language.rawValue,
calendarFirstWeekday: settings.calendarFirstWeekday,
@@ -48,6 +48,8 @@ struct MenuBarReading {
/// 本机日志今日 Token 总量;扫描未开启或还没有数据时为 nil
var aiTokens: Int?
var aiUsageShowsLocalUsage = true
var aiQuotaShowsRemaining = true
var aiUsageWesternUnits = false
/// 只有可显示额度的来源才占用菜单栏读数;本机 Token 统计独立于订阅额度。
var aiQuotas: [MenuBarQuota] = []
var batteryCharging = false
@@ -97,6 +99,8 @@ struct MenuBarReading {
case .aiUsage:
aiTokens = model.aiUsage.todayTokens
aiUsageShowsLocalUsage = model.settings.aiUsageShowsLocalUsage
aiQuotaShowsRemaining = model.settings.aiQuotaShowsRemaining
aiUsageWesternUnits = model.settings.aiUsageWesternUnits
aiQuotas = model.aiUsage.visibleQuotaProviders(for: nil).compactMap { provider in
let state = model.aiUsage.visibleQuotaState(for: provider)
guard let snapshot = state.snapshot else { return nil }
@@ -178,10 +182,11 @@ struct MenuBarReading {
&& (style != .history && style != .line || batteryHistory == previous.batteryHistory)
case .aiUsage:
guard aiQuotas.isEmpty == previous.aiQuotas.isEmpty else { return false }
if aiQuotas.isEmpty { return aiTokens == previous.aiTokens }
if aiQuotas.isEmpty { return aiTokens == previous.aiTokens && aiUsageWesternUnits == previous.aiUsageWesternUnits }
guard aiQuotaShowsRemaining == previous.aiQuotaShowsRemaining else { return false }
return aiQuotas.elementsEqual(previous.aiQuotas) {
$0.provider == $1.provider && $0.shortWindowName == $1.shortWindowName
&& $0.remainingPercent == $1.remainingPercent
&& $0.window.remainingPercent == $1.window.remainingPercent
}
}
}
@@ -212,7 +217,7 @@ struct MenuBarReading {
let checked = quota.isStale
? " · \(tr("上次成功:\(quota.fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))"
: ""
return "\(previous)\(quota.sourceName)\(quota.source == .sub2api ? " (Sub2API)" : "") · \(quota.shortWindowName) · \(tr("剩余")) \(quota.remainingPercent)% · \(tr("重置:")) \(quota.resetText)\(checked)"
return "\(previous)\(quota.sourceName)\(quota.source == .sub2api ? " (Sub2API)" : "") · \(quota.shortWindowName) · \(tr(aiQuotaShowsRemaining ? "剩余" : "已用")) \(AIUsageFormat.quotaPercent(remainingPercent: quota.window.remainingPercent, showsRemaining: aiQuotaShowsRemaining, locale: L10n.locale)) · \(tr("重置:")) \(quota.resetText)\(checked)"
}.joined(separator: "\n")
} else {
aiTokens.map { "AI · \(UsageNumber.exact($0)) Tokens" }
@@ -227,8 +232,8 @@ struct MenuBarReading {
/// 菜单栏图标自绘:每次刷新只生成一张小图,避免在菜单栏里重建 SwiftUI 视图
@MainActor
enum MenuBarRenderer {
private static func tokenText(_ value: Int) -> String {
UsageNumber.short(value)
private static func tokenText(_ value: Int, westernUnits: Bool) -> String {
AIUsageFormat.tokens(value, westernUnits: westernUnits, locale: L10n.locale)
}
/// 菜单栏只有 22pt 高。额度读数单独使用更清晰的字号,其余指标保持紧凑排版。
private enum Metrics {
@@ -373,17 +378,17 @@ enum MenuBarRenderer {
private static func aiUsageSegment(reading: MenuBarReading, style: MenuBarStyle) -> Segment {
guard !reading.aiQuotas.isEmpty else {
return textSegment(item: .aiUsage, value: reading.aiTokens.map { tokenText($0) } ?? "—",
return textSegment(item: .aiUsage, value: reading.aiTokens.map { tokenText($0, westernUnits: reading.aiUsageWesternUnits) } ?? "—",
sample: "999.9M", style: style)
}
let status = combine(reading.aiQuotas.map { quota in
let provider = quota.provider
let name = provider == .codex ? "Codex" : "Claude"
let label = "\(name) · \(quota.shortWindowName)"
let value = "\(quota.remainingPercent)%"
let value = AIUsageFormat.quotaPercent(remainingPercent: quota.window.remainingPercent, showsRemaining: reading.aiQuotaShowsRemaining, locale: L10n.locale, compact: true)
let stacked = aiQuotaText(label: name, value: value, centered: style == .stackedCenter)
let inline = aiQuotaInlineText(label: name, value: value)
let fraction = min(1, max(0, Double(quota.remainingPercent) / 100))
let fraction = (AIUsageFormat.quotaValue(remainingPercent: quota.window.remainingPercent, showsRemaining: reading.aiQuotaShowsRemaining) ?? 0) / 100
let color = aiQuotaColor(quota.remainingPercent)
switch style {
case .stacked, .stackedCenter: return stacked
@@ -5,6 +5,18 @@ import AIUsage
import Localization
import SwiftUI
extension EnvironmentValues {
@Entry var aiUsageWesternUnits = false
}
private struct CostRefreshKey: Hashable {
let attempt: Date?
let currency: AIUsageCurrency
let enabled: Bool
let localUsage: Bool
let visible: Bool
}
struct AIUsagePage: View {
var body: some View { PageScroll { LocalUsageContent(compact: false) } }
}
@@ -13,6 +25,7 @@ struct AIUsagePopover: View {
}
private struct LocalUsageContent: View {
@Environment(\.isSnapshot) private var isSnapshot
let compact: Bool
@Environment(AppModel.self) private var model
@State private var selectedModel = ""
@@ -37,6 +50,11 @@ private struct LocalUsageContent: View {
.map { $0 == .codex ? "Codex" : "Claude Code" }.joined(separator: " + ")
}
private var isVisible: Bool {
compact ? model.openPopover == .aiUsage
: model.isMainWindowVisible && model.settings.panelTab == .aiUsage
}
var body: some View {
VStack(alignment: .leading, spacing: compact ? DS.Space.s3 : DS.Space.s4) {
toolbar
@@ -69,7 +87,8 @@ private struct LocalUsageContent: View {
let activityRows = report.selected(days: 365, model: selectedModel.isEmpty ? nil : selectedModel)
let total = LocalUsageReport.total(rows)
UsageSummary(total: total, compact: compact,
title: selectedModel.isEmpty ? tr("本地用量") : selectedModel)
title: selectedModel.isEmpty ? tr("本地用量") : selectedModel,
costRows: rows)
UsageHeatmap(rows: activityRows, compact: compact, mode: $activityMode)
modelRanking(report: report, rows: rows, total: total)
status(report)
@@ -87,6 +106,13 @@ private struct LocalUsageContent: View {
Card { Text(tr("暂无额度数据")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) }
}
}
.environment(\.aiUsageWesternUnits, model.settings.aiUsageWesternUnits)
.task(id: CostRefreshKey(attempt: model.aiUsage.lastAttemptAt, currency: model.settings.aiUsageCurrency,
enabled: model.settings.aiUsageEnabled, localUsage: model.settings.aiUsageShowsLocalUsage,
visible: isVisible)) {
guard !isSnapshot, isVisible else { return }
await model.aiUsage.refreshCostReferences()
}
.onChange(of: model.settings.aiUsageSources) { _, sources in
if let id = AIProviderID(rawValue: source), !sources.contains(id) { source = "all" }
selectedModel = ""
@@ -147,16 +173,22 @@ private struct LocalUsageContent: View {
Text(quotaTitle(window.kind))
.foregroundStyle(DS.Palette.textSecondary)
Spacer()
Text(tr("剩余"))
Text(tr(model.settings.aiQuotaShowsRemaining ? "剩余" : "已用"))
.foregroundStyle(DS.Palette.textSecondary)
Text("\(Int(window.remainingPercent.rounded()))%")
Text(AIUsageFormat.quotaPercent(remainingPercent: window.remainingPercent,
showsRemaining: model.settings.aiQuotaShowsRemaining,
locale: L10n.locale))
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
}
.dsFont(.xs)
ProgressTrack(fraction: window.remainingPercent / 100,
ProgressTrack(fraction: (AIUsageFormat.quotaValue(remainingPercent: window.remainingPercent,
showsRemaining: model.settings.aiQuotaShowsRemaining) ?? 0) / 100,
color: progressTint(for: window.remainingPercent))
.accessibilityLabel("\(quotaTitle(window.kind)) \(tr("剩余")) \(Int(window.remainingPercent.rounded()))%")
.accessibilityLabel(quotaTitle(window.kind) + " " + tr(model.settings.aiQuotaShowsRemaining ? "剩余" : "已用")
+ " " + AIUsageFormat.quotaPercent(remainingPercent: window.remainingPercent,
showsRemaining: model.settings.aiQuotaShowsRemaining,
locale: L10n.locale))
if let reset = window.resetsAt {
let resetText = tr("重置:") + " " + reset.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale))
if state.isStale {
@@ -384,25 +416,17 @@ enum UsageNumber {
date.formatted(.dateTime.year().month().day().locale(locale))
}
static func short(_ value: Int, locale: Locale = L10n.locale) -> String {
let chinese = locale.language.languageCode?.identifier == "zh"
let units: [(Int, String)] = chinese
? [(100_000_000, "亿"), (1_000_000, "百万"), (10_000, "万"), (1_000, "千")]
: [(1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")]
guard let (divisor, rawUnit) = units.first(where: { value >= $0.0 }) else {
return value.formatted(.number.locale(locale))
}
let unit = locale.language.script?.identifier == "Hant"
? rawUnit.replacingOccurrences(of: "亿", with: "億").replacingOccurrences(of: "万", with: "萬") : rawUnit
let number = (Double(value) / Double(divisor)).formatted(.number.precision(.fractionLength(0...1)).locale(locale))
return number + (chinese ? " " : "") + unit
static func short(_ value: Int, locale: Locale = L10n.locale, westernUnits: Bool = false) -> String {
AIUsageFormat.tokens(value, westernUnits: westernUnits, locale: locale)
}
}
private struct UsageSummary: View {
@Environment(\.aiUsageWesternUnits) private var westernUnits
let total: ModelTokenUsage
let compact: Bool
let title: String
let costRows: [ModelTokenUsage]
@State private var expanded = false
var body: some View {
@@ -412,7 +436,7 @@ private struct UsageSummary: View {
Spacer()
}
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) {
Text(UsageNumber.short(total.total))
Text(UsageNumber.short(total.total, westernUnits: westernUnits))
.dsFont(.xxl, weight: .semibold)
.tracking(-0.6).monospacedDigit()
.help(UsageNumber.exact(total.total))
@@ -422,12 +446,13 @@ private struct UsageSummary: View {
}
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), alignment: .leading), count: compact ? 2 : 4),
alignment: .leading, spacing: DS.Space.s3) {
UsageMetric(title: tr("输入 Token"), value: UsageNumber.short(total.input), exact: UsageNumber.exact(total.input))
UsageMetric(title: tr("输出 Token"), value: UsageNumber.short(total.output), exact: UsageNumber.exact(total.output))
UsageMetric(title: tr("输入 Token"), value: UsageNumber.short(total.input, westernUnits: westernUnits), exact: UsageNumber.exact(total.input))
UsageMetric(title: tr("输出 Token"), value: UsageNumber.short(total.output, westernUnits: westernUnits), exact: UsageNumber.exact(total.output))
UsageMetric(title: tr("缓存命中率"), value: total.input > 0
? (Double(total.cached) / Double(total.input)).formatted(.percent.precision(.fractionLength(1)).locale(L10n.locale)) : "—")
UsageMetric(title: tr("用量记录"), value: UsageNumber.exact(total.records))
}
UsageCostSummary(rows: costRows)
DisclosureGroup(isExpanded: $expanded) {
VStack(spacing: DS.Space.s2) {
InfoRow(label: tr("缓存 Token"), text: UsageNumber.exact(total.cached))
@@ -443,6 +468,54 @@ private struct UsageSummary: View {
}
}
private struct UsageCostSummary: View {
let rows: [ModelTokenUsage]
@Environment(AppModel.self) private var model
var body: some View {
let estimate = model.aiUsage.estimatedCost(rows)
let references = model.aiUsage.costReferences
let currency = model.settings.aiUsageCurrency
VStack(alignment: .leading, spacing: DS.Space.s1) {
HStack {
Text(tr("估算费用")).foregroundStyle(DS.Palette.textSecondary)
Spacer()
if let usd = estimate.usd {
if let amount = AIUsageFormat.money(usd: usd, currency: currency,
usdToCNY: references.exchangeRate?.cnyPerUSD, locale: L10n.locale) {
Text(amount + (estimate.unpricedModels.isEmpty ? "" : "+"))
.monospacedDigit().foregroundStyle(DS.Palette.textPrimary)
} else {
Text(tr("暂无汇率")).foregroundStyle(DS.Palette.textTertiary)
}
} else if model.aiUsage.isRefreshingCostReferences {
ProgressView().controlSize(.mini)
} else {
Text(tr("暂无价格")).foregroundStyle(DS.Palette.textTertiary)
}
}.dsFont(.sm, weight: .medium)
Text(tr("按模型基础 API 单价估算,不含阶梯加价,非订阅账单。"))
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
if !estimate.unpricedModels.isEmpty {
Text(tr("部分模型暂无价格"))
.dsFont(.xs).foregroundStyle(DS.Palette.warning)
.help(estimate.unpricedModels.joined(separator: "\n"))
}
if let catalog = references.catalog {
Text(tr("价格更新:\(catalog.fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
if currency == .cny, let rate = references.exchangeRate {
let value = rate.cnyPerUSD.formatted(.number.precision(.fractionLength(0...4)).locale(L10n.locale))
Text(tr("参考汇率:1 USD ≈ \(value) CNY"))
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
.help(tr("汇率更新:\(rate.fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))
}
}
}
}
private struct UsageMetric: View {
let title: String
let value: String
@@ -458,6 +531,7 @@ private struct UsageMetric: View {
}
private struct UsageModelRow: View {
@Environment(\.aiUsageWesternUnits) private var westernUnits
let item: ModelTokenUsage
let total: Int
let compact: Bool
@@ -470,7 +544,7 @@ private struct UsageModelRow: View {
Text(verbatim: item.model).dsFont(.xs, weight: .medium).lineLimit(1).help(item.model)
}
Spacer(minLength: DS.Space.s2)
Text(UsageNumber.short(item.total)).dsFont(.sm, weight: .semibold)
Text(UsageNumber.short(item.total, westernUnits: westernUnits)).dsFont(.sm, weight: .semibold)
.monospacedDigit().fixedSize().help(UsageNumber.exact(item.total))
.accessibilityLabel(UsageNumber.exact(item.total) + " Tokens")
Text((total > 0 ? Double(item.total) / Double(total) : 0).formatted(.percent.precision(.fractionLength(0)).locale(L10n.locale)))
@@ -482,8 +556,8 @@ private struct UsageModelRow: View {
.accessibilityHidden(true)
if !compact {
HStack(spacing: DS.Space.s6) {
Text(tr("输入 Token") + " " + UsageNumber.short(item.input))
Text(tr("输出 Token") + " " + UsageNumber.short(item.output))
Text(tr("输入 Token") + " " + UsageNumber.short(item.input, westernUnits: westernUnits))
Text(tr("输出 Token") + " " + UsageNumber.short(item.output, westernUnits: westernUnits))
Spacer()
Text(tr("用量记录") + " " + UsageNumber.exact(item.records))
}.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).monospacedDigit()
@@ -492,8 +566,9 @@ private struct UsageModelRow: View {
}
}
/// 活动图始终展示全年;模式绑定到父视图,同时切换摘要和排行的本日/本周/本月口径。
/// 活动图始终展示最近 365 天;模式同时切换摘要和排行的本日/本周/同窗口累计口径。
private struct UsageHeatmap: View {
@Environment(\.aiUsageWesternUnits) private var westernUnits
let rows: [ModelTokenUsage]
let compact: Bool
@Binding var mode: UsageActivityMode
@@ -547,7 +622,7 @@ private struct UsageHeatmap: View {
.frame(width: DS.Space.s3, height: DS.Space.s3)
}
}.accessibilityHidden(true)
Text(UsageNumber.short(peak) + " Tokens").help(UsageNumber.exact(peak) + " Tokens")
Text(UsageNumber.short(peak, westernUnits: westernUnits) + " Tokens").help(UsageNumber.exact(peak) + " Tokens")
}.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
}
.onChange(of: mode) { _, _ in hovered = nil }
@@ -588,6 +663,7 @@ private struct ActivityGrid: View {
}
private struct ActivityWeek: View {
@Environment(\.aiUsageWesternUnits) private var westernUnits
let week: UsageActivity.Week
let activity: UsageActivity
let mode: UsageActivityMode
@@ -597,8 +673,8 @@ private struct ActivityWeek: View {
private var weekLabel: String {
let date = UsageNumber.day(week.start)
return mode == .weekly
? tr("\(date) 当周:\(UsageNumber.short(week.total)) Tokens")
: tr("截至 \(date) 当周累计:\(UsageNumber.short(week.cumulative)) Tokens")
? tr("\(date) 当周:\(UsageNumber.short(week.total, westernUnits: westernUnits)) Tokens")
: tr("截至 \(date) 当周累计:\(UsageNumber.short(week.cumulative, westernUnits: westernUnits)) Tokens")
}
var body: some View {
@@ -614,7 +690,7 @@ private struct ActivityWeek: View {
let level = min(4, max(1, Int(ceil(Double(count) / Double(max(1, peak)) * 4))))
ActivityCell(side: side,
color: count == 0 ? DS.Palette.track : DS.Palette.primary.opacity(Double(level) / 4),
label: UsageNumber.day(date) + " · " + UsageNumber.short(count) + " Tokens",
label: UsageNumber.day(date) + " · " + UsageNumber.short(count, westernUnits: westernUnits) + " Tokens",
hovered: $hovered)
} else {
Color.clear.frame(width: side, height: side).accessibilityHidden(true)
@@ -720,13 +796,36 @@ private struct UsageSettings: View {
}
}
HairlineDivider()
SettingRow(title: tr("刷新间隔"), subtitle: nil) {
SettingRow(title: tr("刷新间隔"), subtitle: tr("AI 用量与额度共用此间隔;新数据到达后更新菜单栏。")) {
Picker(tr("刷新间隔"), selection: $settings.aiUsageRefreshMinutes) {
ForEach(AppSettings.aiUsageRefreshOptions, id: \.self) { value in
Text(tr("\(value) 分钟")).tag(value)
}
}.labelsHidden()
}
SettingRow(title: tr("额度显示"), subtitle: nil) {
Picker(tr("额度显示"), selection: $settings.aiQuotaShowsRemaining) {
Text(tr("已用")).tag(false)
Text(tr("剩余")).tag(true)
}.labelsHidden()
}
if settings.language.resolved.isChinese {
SettingRow(title: tr("数字单位"), subtitle: nil) {
Picker(tr("数字单位"), selection: $settings.aiUsageWesternUnits) {
Text(tr("万 / 亿")).tag(false)
Text("K / M / B").tag(true)
}.labelsHidden()
}
}
SettingRow(title: tr("货币"), subtitle: nil) {
Picker(tr("货币"), selection: $settings.aiUsageCurrency) {
Text(tr("美元")).tag(AIUsageCurrency.usd)
Text(tr("人民币")).tag(AIUsageCurrency.cny)
}.labelsHidden()
}
Text(tr("价格来源:models.dev;人民币按缓存汇率换算。"))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Text(tr("本机 Token 统计不代表订阅账单或其他设备的用量"))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
@@ -313,15 +313,17 @@ private struct OverviewReading {
case .aiUsage:
if !model.settings.aiUsageShowsLocalUsage {
if let quota = MenuBarReading(model: model, items: [.aiUsage]).aiQuotas.min(by: { $0.remainingPercent < $1.remainingPercent }) {
value = "\(quota.remainingPercent)"
unit = "%"
detail = quota.sourceName + " · " + tr("订阅额度")
value = AIUsageFormat.quotaPercent(remainingPercent: quota.window.remainingPercent,
showsRemaining: model.settings.aiQuotaShowsRemaining,
locale: L10n.locale, compact: true)
unit = ""
detail = quota.sourceName + " · " + tr(model.settings.aiQuotaShowsRemaining ? "剩余" : "已用")
} else {
detail = tr("暂无额度数据")
}
} else if let tokens = model.aiUsage.todayTokens {
// 与菜单栏、面板共用同一套缩写,同一个数字不能在三处显示成三种样子。
value = UsageNumber.short(tokens)
value = UsageNumber.short(tokens, westernUnits: model.settings.aiUsageWesternUnits)
unit = "Tokens"
detail = "AI · " + tr("今天")
} else {
@@ -1,6 +1,6 @@
# XStats Privacy Policy
Effective date: October 2, 2026
Effective date: October 3, 2026
This policy explains what XStats processes on your Mac and which features contact external services. XStats-operated services retain only the update-statistics aggregates described in section 2; the app's other persisted data stays on your Mac and is not uploaded to XStats-operated servers. Network tests send requests needed for the test to independent providers, as described in section 3. Contact: i@xiai.me.
@@ -26,6 +26,7 @@ You can set automatic checks to Never in Settings → About. You decide whether
## 3. Other network features
- When AI Usage is enabled, Codex quotas are read through the installed local Codex CLI App Server first; if unavailable, XStats uses cached Codex credentials to contact `chatgpt.com` directly. Claude Code uses its local credentials to contact `api.anthropic.com` directly. Those providers can observe the login token and connection metadata. The last successful quota snapshot for each source is stored in this Mac's `ai-usage.sqlite` database with percentages, reset times, source, and fetch time, but no login token. Failed queries may show that snapshot marked as old data. Authentication failure or removal of a manual configuration deletes that source's snapshot. Disabling the feature stops queries and hides quotas but retains the local snapshot for re-enabling. Quota results are not uploaded to XStats servers.
- With local AI usage enabled and its view visible, cost estimates fetch public base model prices from `models.dev/api.json` on demand. Selecting CNY also fetches a reference exchange rate from `open.er-api.com/v6/latest/USD`. These requests send no session logs, token counts, model usage records, or login credentials; the providers can observe connection metadata. Prices and exchange rates are cached locally and refreshed on demand after 24 and 12 hours respectively. Failed refreshes may keep older cached values with their fetch times. Closing the view, disabling the feature, or system suspension cancels requests that are no longer needed. Estimates use local token statistics, exclude tiered surcharges, and are not subscription bills.
- With a Codex or Claude Code Sub2API backup configured, a failed automatic lookup for that source causes a direct request to its chosen Sub2API server. That server receives the admin email, password, login token, and account ID being queried. XStats provides no preset Sub2API server and does not upload these values or quota results to XStats servers.
- When you open network details with public-IP lookup enabled, XStats contacts Cloudflare `1.1.1.1`, falling back to ipify. It sends your public IP address to `cleanip.io` for location, ASN, network type, and cleanliness information. Results are cached locally.
- Connection probing sends ICMP packets to your selected target when enabled. Speed tests, egress checks, and DNS lookups contact selected test nodes, websites, or DNS services only when you use those features. Those services can observe connection metadata and the data needed for the test.
@@ -1,6 +1,6 @@
# XStats 隐私政策
生效日期:2026 年 10 月 2 日
生效日期:2026 年 10 月 3 日
本政策说明 XStats 在 macOS 上处理哪些信息,以及哪些功能会连接外部服务。XStats 自有服务仅保存第 2 节所述的更新统计汇总;应用自身的其他持久化数据保存在本机,不上传到 XStats 自有服务器。联网测试会向相应第三方发送完成测试所需的请求,详见第 3 节。联系邮箱:i@xiai.me。
@@ -26,6 +26,7 @@ CPU、GPU、内存、网络、温度、风扇、进程等监控数据来自本
## 3. 其他联网功能
- 启用“AI 用量”后,Codex 优先通过本机已安装的 Codex CLI App Server 查询额度;不可用时使用本机 Codex 登录凭据直连 `chatgpt.com`。Claude Code 使用本机登录凭据直连 `api.anthropic.com`。相应服务可以看到登录令牌及连接元数据。每个来源最近一次成功的额度快照保存在本机 `ai-usage.sqlite`,包含百分比、重置时间、数据来源及获取时间,不含登录令牌;查询失败时可显示并标记为旧数据。认证失效或移除手动配置会删除对应快照。关闭功能后停止查询并隐藏额度,本地快照仍保留以便重新启用;额度结果不上传到 XStats 服务器。
- 启用本地 AI 用量且用量界面可见时,费用估算按需读取 `models.dev/api.json` 的公开模型基础单价;选择人民币时另读取 `open.er-api.com/v6/latest/USD` 的参考汇率。请求不发送会话日志、Token 数量、模型使用记录或登录凭据,第三方可看到连接元数据。单价与汇率分别在本机缓存 24 小时和 12 小时后按需更新,失败时可继续显示带更新时间的旧缓存。关闭界面、关闭功能或系统暂停时取消不再需要的请求。费用根据本机 Token 统计计算,不含阶梯加价,不代表订阅账单。
- 配置 Codex 或 Claude Code 的 Sub2API 备选来源后,对应的自动额度查询失败时,应用直接请求你为该来源指定的 Sub2API 服务器;该服务器会收到管理员邮箱、密码、登录令牌和查询账号 ID。XStats 不预设 Sub2API 服务器,也不向 XStats 服务器上传这些信息或额度结果。
- 打开网络详情且启用公网 IP 查询时,应用直接访问 Cloudflare `1.1.1.1`,失败时回退到 ipify;归属地、ASN、网络类型和纯净度查询会把公网 IP 发送给 `cleanip.io`。结果在本机缓存。
- 启用连接探测时,应用向所选目标发送 ICMP 探测。测速、出口检测和 DNS 查询仅在你使用相应功能时,直接连接所选测试节点、网站或 DNS 服务;对方可看到连接元数据及测试所需内容。
@@ -35,6 +35,11 @@ public final class AIUsageController {
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]
@@ -48,9 +53,10 @@ public final class AIUsageController {
public init(settings: AppSettings, providers: [any AIUsageProvider] = [CodexLocalUsageProvider(), ClaudeLocalUsageProvider()],
quotaProviders: [any AIQuotaProvider] = [],
quotaCacheURL: URL? = nil,
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) }
@@ -60,6 +66,38 @@ public final class AIUsageController {
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) {
@@ -140,6 +178,7 @@ public final class AIUsageController {
cadenceTask?.cancel()
cadenceTask = nil
if !settings.aiUsageShowsLocalUsage {
for task in costTasks.values { task.cancel() }
// 关闭日志读取时丢弃在途扫描;额度轮询会在下方重新启动。
generation += 1
refreshTask?.cancel()
@@ -152,6 +191,7 @@ public final class AIUsageController {
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
@@ -175,6 +215,7 @@ public final class AIUsageController {
}
public func stop() {
for task in costTasks.values { task.cancel() }
generation += 1
cadenceTask?.cancel()
cadenceTask = nil
@@ -86,7 +86,7 @@ public final class AppModel {
self.settings = settings
rest = RestController(settings: settings)
aiUsage = AIUsageController(settings: settings, providers: aiUsageProviders, quotaProviders: aiQuotaProviders,
quotaCacheURL: quotaCacheURL)
quotaCacheURL: quotaCacheURL, costReferenceStore: .shared)
self.store = store
self.helper = helper
fans = FanController(helper: helper, store: store, settings: settings)
@@ -572,6 +572,15 @@ public final class AppSettings {
public var aiUsageShowsLocalUsage: Bool {
didSet { defaults.set(aiUsageShowsLocalUsage, forKey: Keys.aiUsageShowsLocalUsage) }
}
public var aiQuotaShowsRemaining: Bool {
didSet { defaults.set(aiQuotaShowsRemaining, forKey: Keys.aiQuotaShowsRemaining) }
}
public var aiUsageWesternUnits: Bool {
didSet { defaults.set(aiUsageWesternUnits, forKey: Keys.aiUsageWesternUnits) }
}
public var aiUsageCurrency: AIUsageCurrency {
didSet { defaults.set(aiUsageCurrency.rawValue, forKey: Keys.aiUsageCurrency) }
}
public var aiUsageRefreshMinutes: Int {
didSet { defaults.set(aiUsageRefreshMinutes, forKey: Keys.aiUsageRefreshMinutes) }
}
@@ -726,7 +735,7 @@ public final class AppSettings {
public static let restBreakOptions = [3, 5, 10]
public static let restLongBreakOptions = [10, 15, 20, 30]
public static let restDailyGoalOptions = [4, 6, 8, 12]
public static let aiUsageRefreshOptions = [5, 15, 30, 60]
public static let aiUsageRefreshOptions = [1, 3, 5, 10, 15, 30, 60]
public static let batteryFloorOptions = [10, 20, 30, 40]
public static let fanSafetyOptions = [85, 90, 95, 100]
public static let alertTemperatureOptions = [85, 90, 95, 100]
@@ -786,6 +795,9 @@ public final class AppSettings {
aiUsageSources = defaults.stringArray(forKey: Keys.aiUsageSources)
.map { Set($0.compactMap(AIProviderID.init(rawValue:))) } ?? Set(AIProviderID.allCases)
aiUsageShowsLocalUsage = defaults.object(forKey: Keys.aiUsageShowsLocalUsage) as? Bool ?? true
aiQuotaShowsRemaining = defaults.object(forKey: Keys.aiQuotaShowsRemaining) as? Bool ?? true
aiUsageWesternUnits = defaults.bool(forKey: Keys.aiUsageWesternUnits)
aiUsageCurrency = defaults.string(forKey: Keys.aiUsageCurrency).flatMap(AIUsageCurrency.init(rawValue:)) ?? .usd
aiUsageRefreshMinutes = Self.aiUsageRefreshOptions.contains(defaults.integer(forKey: Keys.aiUsageRefreshMinutes))
? defaults.integer(forKey: Keys.aiUsageRefreshMinutes) : 30
colorizeHighLoad = defaults.bool(forKey: Keys.colorizeHighLoad)
@@ -912,6 +924,9 @@ public final class AppSettings {
static let processesEnabled = "processesEnabled"
static let aiUsageSources = "aiUsageSources"
static let aiUsageShowsLocalUsage = "aiUsageShowsLocalUsage"
static let aiQuotaShowsRemaining = "aiQuotaShowsRemaining"
static let aiUsageWesternUnits = "aiUsageWesternUnits"
static let aiUsageCurrency = "aiUsageCurrency"
static let aiUsageRefreshMinutes = "aiUsageRefreshMinutes"
static let colorizeHighLoad = "colorizeHighLoad"
static let bluetoothLowBatteryInMenuBar = "bluetoothLowBatteryInMenuBar"
@@ -0,0 +1,235 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import os
import Testing
@testable import AIUsage
@Suite struct AICostReferenceTests {
private static let date = Date(timeIntervalSince1970: 1_780_000_000)
private static let prices = Data(#"{"openai":{"models":{"gpt-test":{"cost":{"input":2,"output":8,"cache_read":0.5}}}},"anthropic":{"models":{"claude-test":{"cost":{"input":3,"output":15,"cache_read":0.3,"cache_write":3.75}}}}}"#.utf8)
private static let exchange = Data(#"{"result":"success","base_code":"USD","rates":{"CNY":7.25}}"#.utf8)
private func row(_ model: String = "claude-test", input: Int = 0, cached: Int = 0,
output: Int = 0, created: Int = 0) -> ModelTokenUsage {
ModelTokenUsage(day: Self.date, model: model, input: input, cached: cached, output: output, cacheCreated: created)
}
@Test func cacheTokensAreChargedExactlyOnce() throws {
let catalog = try AIPriceCatalog.parse(Self.prices, fetchedAt: Self.date)
let result = catalog.estimate([row(input: 1_000_000, cached: 200_000, output: 100_000, created: 100_000)])
let usd = try #require(result.usd)
// 700k 普通输入 + 200k 读取 + 100k 创建 + 100k 输出。
#expect(abs(usd - 4.035) < 0.0000001)
#expect(result.unpricedModels.isEmpty)
}
@Test func missingUsedCategoryMakesRowUnknownButExplicitZeroIsFree() throws {
let catalog = AIPriceCatalog(prices: [
"missing": AIModelPrice(input: 1, output: 2),
"free": AIModelPrice(input: 0, output: 0, cacheRead: 0, cacheWrite: 0)
], fetchedAt: Self.date)
#expect(catalog.estimate([row("missing", input: 10, cached: 1)]).usd == nil)
#expect(catalog.estimate([row("missing", input: 10, created: 1)]).usd == nil)
#expect(catalog.estimate([row("missing", input: 10)]).usd == 0.00001)
#expect(catalog.estimate([row("free", input: 10, cached: 3, output: 4, created: 2)]).usd == 0)
}
@Test func unknownModelsRemainVisibleAndOnlyExplicitPrefixesMatch() throws {
let catalog = try AIPriceCatalog.parse(Self.prices, fetchedAt: Self.date)
let result = catalog.estimate([
row("openai/gpt-test", input: 1_000_000), row("anthropic/claude-test", output: 1_000_000),
row("gpt-test-new", input: 2), row("other/gpt-test", input: 2),
row("openai/claude-test", input: 2), row("gpt-test-new", input: 2),
row("claude-test-20991231", input: 2)
])
#expect(result.usd == 17)
#expect(result.unpricedModels == ["claude-test-20991231", "gpt-test-new", "openai/claude-test", "other/gpt-test"])
#expect(catalog.estimate([row("unknown", input: 1)]).usd == nil)
#expect(catalog.estimate([]).usd == 0)
#expect(catalog.estimate([row("unknown")]).usd == 0)
}
@Test func invalidCountsAndOverflowNeverProduceNonfiniteCost() {
let catalog = AIPriceCatalog(prices: ["a": AIModelPrice(input: .greatestFiniteMagnitude, output: 1)], fetchedAt: Self.date)
for value in [row("a", input: -1), row("a", input: 1, cached: 2), row("a", input: 2, cached: 1, created: 2),
row("a", input: .max), row("a", input: 1, cached: .max, created: .max)] {
#expect(catalog.estimate([value]).usd == nil)
}
let large = catalog.estimate([row("a", input: 1_000_000), row("a", input: 1_000_000)])
#expect(large.usd?.isFinite == true)
#expect(large.unpricedModels == ["a"])
for value in [Double.infinity, .nan, -1] {
let invalid = AIPriceCatalog(prices: ["a": AIModelPrice(input: value, output: 1)], fetchedAt: Self.date)
#expect(invalid.estimate([row("a", input: 1)]).usd == nil)
}
}
@Test func parsingRejectsMalformedPricesAndIgnoresOtherProviders() throws {
let data = Data(#"{"openai":{"models":{"missing":{"cost":{"input":1}},"negative":{"cost":{"input":-1,"output":1}},"boolean":{"cost":{"input":true,"output":2}},"string":{"cost":{"input":"2","output":2}},"free":{"cost":{"input":0,"output":0}},"bad-cache":{"cost":{"input":1,"output":1,"cache_read":false,"cache_write":-2}}}},"reseller":{"models":{"private":{"cost":{"input":1,"output":1}}}}}"#.utf8)
let result = try AIPriceCatalog.parse(data, fetchedAt: Self.date)
#expect(Set(result.prices.keys) == ["free", "bad-cache"])
#expect(result.prices["bad-cache"]?.cacheRead == nil)
#expect(result.prices["bad-cache"]?.cacheWrite == nil)
#expect(result.estimate([row("bad-cache", input: 1, cached: 1)]).usd == nil)
#expect(throws: AICostReferenceError.invalidResponse) {
try AIPriceCatalog.parse(Data(#"{"openai":{"models":{}}}"#.utf8), fetchedAt: Self.date)
}
}
@Test func exchangeRateRequiresUSDAndPositiveNumericCNY() throws {
#expect(try AIExchangeRate.parse(Self.exchange, fetchedAt: Self.date).cnyPerUSD == 7.25)
for json in [#"{"result":"error","base_code":"USD","rates":{"CNY":7}}"#,
#"{"result":"success","base_code":"EUR","rates":{"CNY":7}}"#,
#"{"result":"success","base_code":"USD","rates":{"CNY":0}}"#,
#"{"result":"success","base_code":"USD","rates":{"CNY":-1}}"#,
#"{"result":"success","base_code":"USD","rates":{"CNY":true}}"#,
#"{"result":"success","base_code":"USD","rates":{"CNY":"7"}}"#,
#"{"result":"success","base_code":"USD","rates":{}}"#] {
#expect(throws: AICostReferenceError.invalidResponse) {
try AIExchangeRate.parse(Data(json.utf8), fetchedAt: Self.date)
}
}
}
@Test func limitsRejectOversizedInjectedResponses() async {
let store = AICostReferenceStore(cacheDirectory: nil, fetch: { _, limit in Data(repeating: 32, count: limit + 1) })
let result = await store.refresh(needsExchangeRate: true)
#expect(result.catalog == nil)
#expect(result.exchangeRate == nil)
}
@Test func refreshHonorsIndependentTTLsAndOnDemandExchange() async {
let clock = OSAllocatedUnfairLock(initialState: Self.date)
let server = ReferenceServer()
let store = AICostReferenceStore(cacheDirectory: nil, now: { clock.withLock { $0 } }, fetch: server.fetch)
#expect(await store.refresh(needsExchangeRate: false).catalog != nil)
#expect(await server.counts == [1, 0])
#expect(await store.refresh(needsExchangeRate: true).exchangeRate != nil)
#expect(await server.counts == [1, 1])
clock.withLock { $0 = $0.addingTimeInterval(43_200) }
_ = await store.refresh(needsExchangeRate: false)
#expect(await server.counts == [1, 1])
_ = await store.refresh(needsExchangeRate: true)
#expect(await server.counts == [1, 2])
clock.withLock { $0 = $0.addingTimeInterval(43_200) }
_ = await store.refresh(needsExchangeRate: true)
#expect(await server.counts == [2, 3])
}
@Test func failureKeepsOldCacheAndBacksOffTenMinutes() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let clock = OSAllocatedUnfairLock(initialState: Self.date)
let server = ReferenceServer()
let store = AICostReferenceStore(cacheDirectory: root, now: { clock.withLock { $0 } }, fetch: server.fetch)
let original = await store.refresh(needsExchangeRate: true)
clock.withLock { $0 = $0.addingTimeInterval(86_400) }
await server.setFailure(true)
#expect(await store.refresh(needsExchangeRate: true) == original)
#expect(await server.counts == [2, 2])
clock.withLock { $0 = $0.addingTimeInterval(599) }
#expect(await store.refresh(needsExchangeRate: true) == original)
#expect(await server.counts == [2, 2])
clock.withLock { $0 = $0.addingTimeInterval(1) }
#expect(await store.refresh(needsExchangeRate: true) == original)
#expect(await server.counts == [3, 3])
let restarted = AICostReferenceStore(cacheDirectory: root, now: { clock.withLock { $0 } }, fetch: server.fetch)
#expect(await restarted.refresh(needsExchangeRate: true) == original)
let files = try FileManager.default.contentsOfDirectory(atPath: root.path).sorted()
#expect(files == ["exchange-v1.json", "prices-v1.json"])
#expect(try Data(contentsOf: root.appendingPathComponent("prices-v1.json")).count < 10_000)
}
@Test func freshDiskCacheAvoidsNetworkAndInvalidCacheIsIgnored() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let server = ReferenceServer()
let first = AICostReferenceStore(cacheDirectory: root, now: { Self.date }, fetch: server.fetch)
let original = await first.refresh(needsExchangeRate: true)
let second = AICostReferenceStore(cacheDirectory: root, now: { Self.date }, fetch: server.fetch)
#expect(await second.refresh(needsExchangeRate: true) == original)
#expect(await server.counts == [1, 1])
try Data(repeating: 32, count: 1024 * 1024 + 1).write(to: root.appendingPathComponent("prices-v1.json"))
let invalid = AIExchangeRate(cnyPerUSD: -1, fetchedAt: Self.date)
try JSONEncoder().encode(invalid).write(to: root.appendingPathComponent("exchange-v1.json"))
await server.setFailure(true)
let third = AICostReferenceStore(cacheDirectory: root, now: { Self.date }, fetch: server.fetch)
let result = await third.refresh(needsExchangeRate: true)
#expect(result.catalog == nil)
#expect(result.exchangeRate == nil)
}
@Test func coldFailureReturnsNilAndRetriesOnlyAfterBackoff() async {
let server = ReferenceServer()
await server.setFailure(true)
let store = AICostReferenceStore(cacheDirectory: nil, now: { Self.date }, fetch: server.fetch)
let first = await store.refresh(needsExchangeRate: true)
#expect(first.catalog == nil && first.exchangeRate == nil)
_ = await store.refresh(needsExchangeRate: true)
#expect(await server.counts == [1, 1])
}
@Test func concurrentRequestsMergeAndLateExchangeDemandIsFulfilled() async {
let server = ReferenceServer(suspended: true)
let store = AICostReferenceStore(cacheDirectory: nil, now: { Self.date }, fetch: server.fetch)
let dollars = Task { await store.refresh(needsExchangeRate: false) }
await server.waitForRequest()
let yuan = Task { await store.refresh(needsExchangeRate: true) }
let moreDollars = Task { await store.refresh(needsExchangeRate: false) }
await server.release()
#expect(await dollars.value.catalog != nil)
#expect(await moreDollars.value.catalog != nil)
#expect(await yuan.value.exchangeRate != nil)
#expect(await server.counts == [1, 1])
}
@Test func cancellationDoesNotPublishOrPersistAndNextRefreshCanRetry() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let server = ReferenceServer(suspended: true)
let store = AICostReferenceStore(cacheDirectory: root, now: { Self.date }, fetch: server.fetch)
let task = Task { await store.refresh(needsExchangeRate: true) }
await server.waitForRequest()
task.cancel()
await server.release()
let canceled = await task.value
#expect(canceled.catalog == nil && canceled.exchangeRate == nil)
#expect(!FileManager.default.fileExists(atPath: root.path))
#expect(await server.counts == [1, 0])
let next = await store.refresh(needsExchangeRate: true)
#expect(next.catalog != nil && next.exchangeRate != nil)
#expect(await server.counts == [2, 1])
}
private actor ReferenceServer {
private var fail = false
private var suspended: Bool
private var pending: [CheckedContinuation<Void, Never>] = []
private var observers: [CheckedContinuation<Void, Never>] = []
private(set) var counts = [0, 0]
init(suspended: Bool = false) { self.suspended = suspended }
func setFailure(_ value: Bool) { fail = value }
func waitForRequest() async {
if counts.reduce(0, +) > 0 { return }
await withCheckedContinuation { observers.append($0) }
}
func release() {
suspended = false
let waiting = pending; pending.removeAll()
waiting.forEach { $0.resume() }
}
func fetch(_ url: URL, limit: Int) async throws -> Data {
let isCatalog = url.host == "models.dev"
#expect(url.absoluteString == (isCatalog ? "https://models.dev/api.json" : "https://open.er-api.com/v6/latest/USD"))
#expect(limit == (isCatalog ? 16 * 1024 * 1024 : 1024 * 1024))
counts[isCatalog ? 0 : 1] += 1
let waiting = observers; observers.removeAll()
waiting.forEach { $0.resume() }
if suspended { await withCheckedContinuation { pending.append($0) } }
if fail { throw URLError(.notConnectedToInternet) }
return isCatalog ? AICostReferenceTests.prices : AICostReferenceTests.exchange
}
}
}
@@ -6,7 +6,7 @@ import Testing
@testable import AIUsage
@Suite struct UsageActivityTests {
@Test func summaryFollowsTodayThisWeekAndThisMonthWhileActivityStaysAnnual() {
@Test func summaryFollowsTodayThisWeekAndAnnualCumulative() {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
calendar.firstWeekday = 2
@@ -20,10 +20,34 @@ import Testing
row("2026-09-23", 99999), row("2026-09-22", 2, model: "b")], fileCount: 1)
#expect(LocalUsageReport.total(report.summary(mode: .daily, now: now, calendar: calendar)).total == 3)
#expect(LocalUsageReport.total(report.summary(mode: .weekly, now: now, calendar: calendar)).total == 13)
#expect(LocalUsageReport.total(report.summary(mode: .cumulative, now: now, calendar: calendar)).total == 1113)
#expect(LocalUsageReport.total(report.summary(mode: .cumulative, model: "a", now: now, calendar: calendar)).total == 1111)
#expect(LocalUsageReport.total(report.summary(mode: .cumulative, now: now, calendar: calendar)).total == 11113)
#expect(LocalUsageReport.total(report.summary(mode: .cumulative, model: "a", now: now, calendar: calendar)).total == 11111)
#expect(LocalUsageReport.total(report.selected(days: 365, now: now, calendar: calendar)).total == 11113)
}
@Test(arguments: ["2026-01-01T12:00:00Z", "2026-03-01T12:00:00Z"])
func cumulativeSummaryMatchesChartAcrossYearAndMonthBoundaries(_ timestamp: String) throws {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai"))
let now = try #require(ISO8601DateFormatter().date(from: timestamp))
let today = calendar.startOfDay(for: now)
let rows = [-1, 0, 31, 364, 365].flatMap { ago in
["a", "b"].map { model in
ModelTokenUsage(day: calendar.date(byAdding: .day, value: -ago, to: today)!,
model: model, input: model == "a" ? 100 : 200, output: 10)
}
}
let report = LocalUsageReport(rows: rows, fileCount: 1)
for model: String? in [nil, "a", "b"] {
let summary = report.summary(mode: .cumulative, model: model, now: now, calendar: calendar)
let activity = UsageActivity(rows: report.selected(days: 365, model: model, now: now, calendar: calendar),
now: now, calendar: calendar)
let total = LocalUsageReport.total(summary).total
#expect(total == activity.peak(for: .cumulative))
#expect(total == (model == nil ? 960 : model == "a" ? 330 : 630))
#expect(LocalUsageReport.models(summary).reduce(0) { $0 + $1.total } == total)
}
}
@Test func weeklyAndCumulativeColorsDistinguishUsageWithoutLightingZero() {
#expect(UsageActivity.colorIntensity(value: 0, peak: 100, mode: .weekly) == 0)
#expect(UsageActivity.colorIntensity(value: 100, peak: 100, mode: .cumulative) == 1)
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Testing
import Localization
struct AIUsageFormatTests {
@Test(arguments: [
(0, "0"), (999, "999"), (1_000, "1K"), (1_234, "1.2K"),
(100_000, "100K"), (123_456, "123K"), (999_499, "999K"),
(999_500, "1M"), (1_000_000, "1M"), (1_234_567, "1.2M"),
(10_000_000, "10M"), (12_345_678, "12M"), (999_499_999, "999M"),
(999_500_000, "1B"), (1_000_000_000, "1B"), (1_234_567_890, "1.23B"),
(-1_234, "-1.2K")
])
func westernTokens(_ value: Int, _ expected: String) {
#expect(AIUsageFormat.tokens(value, locale: Locale(identifier: "en_US")) == expected)
}
@Test(arguments: [
(9_999, "9999"), (10_000, "1万"), (12_345, "1.2万"),
(99_999_499, "9999.9万"), (99_999_500, "1亿"),
(100_000_000, "1亿"), (123_456_789, "1.23亿")
])
func simplifiedChineseTokens(_ value: Int, _ expected: String) {
#expect(AIUsageFormat.tokens(value, locale: Locale(identifier: "zh_CN")) == expected)
}
@Test func tokenLocalesAndWesternOverride() {
#expect(AIUsageFormat.tokens(12_345, locale: Locale(identifier: "zh_Hant_TW")) == "1.2萬")
#expect(AIUsageFormat.tokens(123_456_789, locale: Locale(identifier: "zh_TW")) == "1.23億")
#expect(AIUsageFormat.tokens(12_345, westernUnits: true, locale: Locale(identifier: "zh_CN")) == "12.3K")
#expect(AIUsageFormat.tokens(1_234, locale: Locale(identifier: "de_DE")) == "1,2K")
#expect(AIUsageFormat.tokens(1_234, locale: Locale(identifier: "ar_SA")) == "١٫٢K")
#expect(!AIUsageFormat.tokens(Int.min, locale: Locale(identifier: "en_US")).isEmpty)
#expect(!AIUsageFormat.tokens(Int.max, locale: Locale(identifier: "zh_CN")).isEmpty)
}
@Test func quotaClampsAndInverts() {
#expect(AIUsageFormat.quotaValue(remainingPercent: -10, showsRemaining: true) == 0)
#expect(AIUsageFormat.quotaValue(remainingPercent: 110, showsRemaining: true) == 100)
#expect(AIUsageFormat.quotaValue(remainingPercent: -10, showsRemaining: false) == 100)
#expect(AIUsageFormat.quotaValue(remainingPercent: 110, showsRemaining: false) == 0)
#expect(AIUsageFormat.quotaValue(remainingPercent: 75, showsRemaining: false) == 25)
}
@Test(arguments: [Double.nan, Double.infinity, -Double.infinity])
func unknownQuotaIsNotZero(_ value: Double) {
#expect(AIUsageFormat.quotaValue(remainingPercent: value, showsRemaining: true) == nil)
#expect(AIUsageFormat.quotaValue(remainingPercent: value, showsRemaining: false) == nil)
#expect(AIUsageFormat.quotaPercent(remainingPercent: value, showsRemaining: true,
locale: Locale(identifier: "en_US")) == "—")
}
@Test func compactQuotaPreservesEndpoints() {
let locale = Locale(identifier: "en_US")
#expect(AIUsageFormat.quotaPercent(remainingPercent: 0, showsRemaining: true, locale: locale, compact: true) == "0%")
#expect(AIUsageFormat.quotaPercent(remainingPercent: 100, showsRemaining: true, locale: locale, compact: true) == "100%")
#expect(AIUsageFormat.quotaPercent(remainingPercent: 0.4, showsRemaining: true, locale: locale, compact: true) == "<1%")
#expect(AIUsageFormat.quotaPercent(remainingPercent: 99.6, showsRemaining: true, locale: locale, compact: true) == ">99%")
#expect(AIUsageFormat.quotaPercent(remainingPercent: 99.6, showsRemaining: false, locale: locale, compact: true) == "<1%")
#expect(AIUsageFormat.quotaPercent(remainingPercent: 0, showsRemaining: false, locale: locale, compact: true) == "100%")
#expect(AIUsageFormat.quotaPercent(remainingPercent: 99.6, showsRemaining: true, locale: locale) == "99.6%")
#expect(AIUsageFormat.quotaPercent(remainingPercent: 99.6, showsRemaining: false, locale: locale) == "0.4%")
}
@Test func percentUsesExplicitLocale() {
let arabic = AIUsageFormat.quotaPercent(remainingPercent: 12.3, showsRemaining: true, locale: Locale(identifier: "ar_SA"))
#expect(arabic.contains("١٢٫٣"))
#expect(arabic.contains("٪"))
let german = AIUsageFormat.quotaPercent(remainingPercent: 12.3, showsRemaining: true, locale: Locale(identifier: "de_DE"))
#expect(german.contains("12,3"))
}
@Test(arguments: [(0.0, "$0"), (0.0123, "$0.012"), (1.234, "$1.23"), (100.49, "$100")])
func dollarsUseMagnitudePrecision(_ value: Double, _ expected: String) {
#expect(AIUsageFormat.money(usd: value, currency: .usd, usdToCNY: nil,
locale: Locale(identifier: "en_US")) == expected)
}
@Test func yuanRequiresExchangeRateAndConvertsForDisplay() {
let usd = 2.0
let locale = Locale(identifier: "zh_CN")
#expect(AIUsageFormat.money(usd: usd, currency: .cny, usdToCNY: nil, locale: locale) == nil)
#expect(AIUsageFormat.money(usd: usd, currency: .cny, usdToCNY: 7.25, locale: locale) == "¥14.5")
#expect(usd == 2)
#expect(AIUsageCurrency.usd.currencyCode == "USD")
#expect(AIUsageCurrency.cny.currencyCode == "CNY")
}
@Test(arguments: [Double.nan, Double.infinity, -Double.infinity, -1])
func rejectsInvalidMoney(_ value: Double) {
#expect(AIUsageFormat.money(usd: value, currency: .usd, usdToCNY: nil,
locale: Locale(identifier: "en_US")) == nil)
}
@Test(arguments: [Double.nan, Double.infinity, -Double.infinity, -1, 0])
func rejectsInvalidRates(_ rate: Double) {
#expect(AIUsageFormat.money(usd: 1, currency: .cny, usdToCNY: rate,
locale: Locale(identifier: "zh_CN")) == nil)
}
@Test func rejectsConversionOverflow() {
#expect(AIUsageFormat.money(usd: Double.greatestFiniteMagnitude, currency: .cny,
usdToCNY: 2, locale: Locale(identifier: "zh_CN")) == nil)
}
}
@@ -86,6 +86,10 @@ struct LanguageSupportTests {
let catalog = try #require(Translations.catalog(for: language))
#expect(catalog.sourceKeys == Translations.shared.sourceKeys)
for key in ["语言", "应用 UI 语言", "自动检测", "搜索语言", "没有匹配的语言",
"额度显示", "数字单位", "货币", "估算费用",
"AI 用量与额度共用此间隔;新数据到达后更新菜单栏。",
"价格来源:models.dev;人民币按缓存汇率换算。",
"按模型基础 API 单价估算,不含阶梯加价,非订阅账单。",
"设置", "内存", "下载并应用", "中国大陆节假日与调休", "黄历", "每日", "每周", "累计",
"XStats 基于 OpenStats 开发,感谢原项目的开源贡献 · MIT License"] {
#expect(catalog.translate(key)?.isEmpty == false)
@@ -166,6 +166,8 @@ func snapshotPreservesEveryWeekStartAndReloadsCalendars(firstWeekday: Int) throw
#expect(snapshot.todayTokens(for: "codex") == nil)
#expect(snapshot.currentQuotas().count == 1)
#expect(snapshot.localUsageEnabled == nil)
#expect(snapshot.quotaShowsRemaining == nil)
#expect(snapshot.westernUnits == nil)
}
@Test func onlyWidgetsWhoseDisplayedDataChangedAreReloaded() {
@@ -225,3 +227,19 @@ func snapshotPreservesEveryWeekStartAndReloadsCalendars(firstWeekday: Int) throw
WidgetKind.calendar, WidgetKind.tomorrowWork,
WidgetKind.calendarMonth])
}
@Test func displayPreferencesPreserveLegacyDefaultsAndReloadOnlyAffectedWidgets() throws {
let legacy = WidgetSnapshot()
var current = legacy
current.quotaShowsRemaining = true
current.westernUnits = false
#expect(current.changedWidgetKinds(from: legacy).isEmpty)
current.quotaShowsRemaining = false
#expect(current.changedWidgetKinds(from: legacy) == [WidgetKind.aiQuota])
current.quotaShowsRemaining = true
current.westernUnits = true
#expect(current.changedWidgetKinds(from: legacy) == [WidgetKind.todayTokens])
let restored = try JSONDecoder().decode(WidgetSnapshot.self, from: JSONEncoder().encode(current))
#expect(restored.westernUnits == true)
#expect(restored.quotaShowsRemaining == true)
}
@@ -724,3 +724,72 @@ private actor GatedUsageProvider: AIUsageProvider {
#expect(await provider.count() == 0)
}
}
private actor CancellableCostFetch {
private var started = false
private var waiter: CheckedContinuation<Void, Never>?
private(set) var cancelled = false
func fetch() async throws -> Data {
started = true
waiter?.resume(); waiter = nil
do {
try await Task.sleep(for: .seconds(60))
return Data()
} catch {
cancelled = Task.isCancelled
throw error
}
}
func waitUntilStarted() async {
if started { return }
await withCheckedContinuation { waiter = $0 }
}
}
extension AIUsageControllerTests {
@Test(arguments: [true, false])
func costRequestCancelsWithSystemPauseOrViewTask(systemPause: Bool) async {
let settings = AppSettings(defaults: defaultsForAIUsage())
settings.aiUsageEnabled = true
let fetch = CancellableCostFetch()
let store = AICostReferenceStore(cacheDirectory: nil, fetch: { _, _ in try await fetch.fetch() })
let controller = AIUsageController(settings: settings,
providers: [StubUsageProvider([.success(usageSnapshot(tokens: 100))])], costReferenceStore: store)
await controller.refresh()
let task = Task { await controller.refreshCostReferences() }
await fetch.waitUntilStarted()
#expect(controller.isRefreshingCostReferences)
if systemPause { controller.setPaused(true) } else { task.cancel() }
await task.value
#expect(await fetch.cancelled)
#expect(!controller.isRefreshingCostReferences)
#expect(controller.costReferences.catalog == nil)
controller.stop()
}
@Test func displayPreferencesInvalidateOnlyRelevantMenuBarImages() async {
let settings = AppSettings(defaults: defaultsForAIUsage())
settings.aiUsageEnabled = true
let snapshot = AIQuotaSnapshot(provider: .codex, windows: [
AIQuotaWindow(kind: .weekly, usedPercent: 73, resetsAt: nil),
], fetchedAt: Date())
let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: [],
aiQuotaProviders: [StubQuotaProvider(results: [.success(snapshot)])])
await model.aiUsage.refresh()
let remaining = MenuBarReading(model: model)
settings.aiQuotaShowsRemaining = false
let used = MenuBarReading(model: model)
#expect(!used.aiQuotaShowsRemaining)
#expect(!used.hasSameImage(as: remaining, item: .aiUsage, style: .stacked))
#expect(used.hasSameImage(as: remaining, item: .cpu, style: .stacked))
#expect(used.aiQuotas.first?.remainingPercent == 27)
var tokens = remaining
tokens.aiQuotas = []
tokens.aiTokens = 123_456
var western = tokens
western.aiUsageWesternUnits = true
#expect(!western.hasSameImage(as: tokens, item: .aiUsage, style: .stacked))
}
}
@@ -0,0 +1,36 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Testing
@testable import XStatsUI
@MainActor
struct AIUsageSettingsTests {
@Test func oldPreferencesKeepDefaultsAndNewChoicesPersist() throws {
let name = "AIUsageSettingsTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let settings = AppSettings(defaults: defaults)
#expect(settings.aiQuotaShowsRemaining)
#expect(!settings.aiUsageWesternUnits)
#expect(settings.aiUsageCurrency == .usd)
#expect(settings.aiUsageRefreshMinutes == 30)
settings.aiQuotaShowsRemaining = false
settings.aiUsageWesternUnits = true
settings.aiUsageCurrency = .cny
for interval in [1, 3, 5, 10, 15, 30, 60] {
settings.aiUsageRefreshMinutes = interval
let restored = AppSettings(defaults: defaults)
#expect(restored.aiUsageRefreshMinutes == interval)
#expect(!restored.aiQuotaShowsRemaining)
#expect(restored.aiUsageWesternUnits)
#expect(restored.aiUsageCurrency == .cny)
}
defaults.set("invalid", forKey: "aiUsageCurrency")
defaults.set(0, forKey: "aiUsageRefreshMinutes")
let repaired = AppSettings(defaults: defaults)
#expect(repaired.aiUsageCurrency == .usd)
#expect(repaired.aiUsageRefreshMinutes == 30)
}
}
@@ -8,14 +8,14 @@ import Testing
@Suite struct UsageNumberTests {
@Test func chineseUsesReadableTokenUnits() {
let locale = Locale(identifier: "zh_Hans_CN")
#expect(UsageNumber.short(1_374_863_940, locale: locale) == "13.7 亿")
#expect(UsageNumber.short(1_234_567, locale: locale) == "1.2 百万")
#expect(UsageNumber.short(12_345, locale: locale) == "1.2 万")
#expect(UsageNumber.short(1_374_863_940, locale: locale) == "13.75亿")
#expect(UsageNumber.short(1_234_567, locale: locale) == "123.5万")
#expect(UsageNumber.short(12_345, locale: locale) == "1.2万")
#expect(UsageNumber.short(0, locale: locale) == "0")
#expect(UsageNumber.short(999, locale: locale) == "999")
}
@Test func englishAndTraditionalChineseKeepTheirUnits() {
#expect(UsageNumber.short(1_374_863_940, locale: Locale(identifier: "en_US")) == "1.4B")
#expect(UsageNumber.short(1_374_863_940, locale: Locale(identifier: "zh_Hant_TW")) == "13.7 億")
#expect(UsageNumber.short(1_374_863_940, locale: Locale(identifier: "en_US")) == "1.37B")
#expect(UsageNumber.short(1_374_863_940, locale: Locale(identifier: "zh_Hant_TW")) == "13.75億")
}
}
+5 -5
View File
@@ -114,14 +114,14 @@ private struct AIQuotaWidgetView: View {
Text(quota.provider == "codex" ? "Codex" : "Claude")
.font(.subheadline.weight(.semibold))
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(Int(quota.remainingPercent.rounded()))%")
Text(AIUsageFormat.quotaPercent(remainingPercent: quota.remainingPercent, showsRemaining: entry.snapshot.quotaShowsRemaining != false, locale: L10n.locale))
.font(.system(size: 27, weight: .semibold, design: .rounded))
.monospacedDigit()
Text(tr("剩余"))
Text(tr(entry.snapshot.quotaShowsRemaining != false ? "剩余" : "已用"))
.font(.caption2)
.foregroundStyle(.secondary)
}
ProgressView(value: quota.remainingPercent, total: 100)
ProgressView(value: AIUsageFormat.quotaValue(remainingPercent: quota.remainingPercent, showsRemaining: entry.snapshot.quotaShowsRemaining != false) ?? 0, total: 100)
.tint(quota.remainingPercent < 20 ? .red : .blue)
if let reset = quota.resetsAt {
Text(tr("重置:") + " " + reset.formatted(date: .abbreviated, time: .shortened))
@@ -168,7 +168,7 @@ private struct TodayTokensWidgetView: View {
WidgetEmptyState(enabled: entry.snapshot.showsLocalUsage)
} else {
let total = sources.reduce(0) { $0 + $1.tokens }
Text(total.formatted(.number.notation(.compactName).locale(L10n.locale)))
Text(AIUsageFormat.tokens(total, westernUnits: entry.snapshot.westernUnits == true, locale: L10n.locale))
.font(.system(size: 36, weight: .semibold, design: .rounded))
.lineLimit(1)
.minimumScaleFactor(0.7)
@@ -178,7 +178,7 @@ private struct TodayTokensWidgetView: View {
HStack {
Text(source.name)
Spacer(minLength: 4)
Text(source.tokens.formatted(.number.notation(.compactName).locale(L10n.locale)))
Text(AIUsageFormat.tokens(source.tokens, westernUnits: entry.snapshot.westernUnits == true, locale: L10n.locale))
.monospacedDigit()
}
.font(.caption)
+2 -1
View File
@@ -24,7 +24,7 @@
<div class="section-head">
<p><a href="./">← 返回 XStats</a></p>
<h2>隐私政策</h2>
<p>最后更新:2026-10-02。本页说明 XStats 与区域更新服务会处理哪些数据。</p>
<p>最后更新:2026-10-03。本页说明 XStats 与区域更新服务会处理哪些数据。</p>
</div>
<div class="section-head">
@@ -33,6 +33,7 @@
</div>
<ul class="fact-list">
<li><b>日程与提醒事项</b><span>默认关闭;主动授权后仅在日历面板打开时读取所选列表的月历范围,在本机只读展示,不上传、不修改记录,也不写入设置备份或小组件摘要。可在日历设置中关闭或在系统设置中撤销权限。</span></li>
<li><b>AI 费用估算</b><span>启用本地 AI 用量且界面可见时,按需获取 models.dev 公开基础单价;选择人民币时另获取 open.er-api.com 参考汇率,分别缓存 24 小时和 12 小时。不发送日志、Token 数量、模型使用记录或登录凭据;第三方可看到连接元数据。失败时可显示带更新时间的旧缓存,关闭界面或功能后取消不再需要的请求。估算不含阶梯加价,非订阅账单。</span></li>
<li><b>公网 IP</b><span>打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭</span></li>
<li><b>归属地</b><span>归属地、ASN、网络类型与纯净度评分由 cleanip.io 提供:这台 Mac 直接向它查询自己的公网 IP,只发送这个地址、不带其他信息,不经过 XStats 的服务器</span></li>
<li><b>连接探测</b><span>打开网络详情时每秒 ping 一次你选择的目标(后台每 10 秒),只在显示网络项时运行,可关闭</span></li>