diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 267dc2d..3a96d2e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -15,8 +15,8 @@ hosting SwiftUI, no third-party dependencies. The Xcode project is generated fro - `Packages/XStatsKit/Sources/Metrics` — one sampler per metric and `MetricsHub`; power and CPU frequency (`PowerSampler`), disk activity and NVMe SMART (`DiskSamplers`), Bluetooth battery, the SQLite history store. -- `Packages/XStatsKit/Sources/AIUsage` — local usage models and the two read-only log scanners. - It reads only session logs; it never reads, refreshes or writes third-party credentials. +- `Packages/XStatsKit/Sources/AIUsage` — local usage models, two read-only log scanners and opt-in quota readers. + Quota readers inspect CLI credentials read-only and never refresh or write them. - `Packages/XStatsKit/Sources/Cleaner` — cleanup rules, `SafetyGuard`, `CleanEngine`, the app uninstaller's leftover search and the launchd startup-item list. - `Packages/XStatsKit/Sources/Updates` — the update manifest and the download → verify → @@ -202,8 +202,9 @@ light/dark calendar screenshots without starting unrelated samplers or scans. ## AI usage statistics -The active Codex provider reads local JSONL rollouts from CODEX_HOME (default ~/.codex), -including sessions and archived_sessions. It does not read credentials or call quota APIs. +The local Codex provider reads JSONL rollouts from CODEX_HOME (default ~/.codex), +including sessions and archived_sessions. It never reads credentials or calls quota APIs; +independent quota providers do that only while the master AI Usage switch is enabled. Parsing runs in a background actor pinned to a dedicated serial executor, so a cold scan (tens of seconds over a multi-gigabyte log directory) never occupies a Swift concurrency cooperative thread and cannot stall per-second metric sampling. It uses bounded line buffers and a SQLite checkpoint store at @@ -228,7 +229,22 @@ history; `started_at` is not compared against the session creation time, because `started_at` is its parent turn's start and recent Codex builds omit the field entirely. Unknown models remain unknown. Local logs cannot reliably identify the paying account or usage on other devices. No cost, -subscription limits or HTTP success rates are inferred. +subscription limits or HTTP success rates are inferred from them. The separate in-memory quota +snapshot instead queries Codex `wham/usage` and Claude `api/oauth/usage` using CLI OAuth tokens. +It maps five-hour and seven-day windows, plus Claude's model-specific weekly windows, without +combining them with local token totals. Credential discovery is repeated on each refresh; Codex +reads `auth.json` under CODEX_HOME or the standard locations, while Claude reads its CLI credential +file or a non-interactive Keychain item. Neither token nor quota result is persisted by XStats. +Requests use isolated URL sessions and reject redirects so bearer tokens cannot be forwarded. +Missing or expired credentials clear that provider's quota display; transient errors keep the last +in-memory result marked stale. Both quota readers stop when AI Usage is disabled. +When a subscription snapshot exists, the menu bar AI reading shows the weekly window (or the +five-hour window if weekly is unavailable), its remaining percentage, and a compact reset time. +With multiple providers it displays the one with less remaining quota; the tooltip lists both. +Without a subscription snapshot it keeps the local daily Token reading. +The quota detail card presents remaining allowance (not used allowance) and, when the server +provides a reset date, a separate neutral bar for the time remaining in that five-hour or +seven-day window; the time bar updates while the view is open. The UI follows CC Switch's filter/summary/trend/model-table organization, implemented in SwiftUI. The desktop toolbar groups source controls and settings. Token totals use compact notation with exact hover/VoiceOver values. Cache details are @@ -265,7 +281,8 @@ Refreshes are queued rather than dropped: a refresh arriving while another is in so rebuilding the polling cadence cannot mistake a skipped call for a completed initial refresh and then sleep out the whole interval. Stopping cancels the in-flight scan. These settings are local and not synced through WebDAV. Cache creation is shown separately but already included in the input total. Claude credentials, -account data, Cowork containers and subscription limits are outside this scanner's scope. +account data, Cowork containers and subscription limits remain outside the local scanner's scope; +the quota reader has no access to session-log content. ## Network details diff --git a/Packages/XStatsKit/Sources/AIUsage/QuotaProviders.swift b/Packages/XStatsKit/Sources/AIUsage/QuotaProviders.swift new file mode 100644 index 0000000..0360d5a --- /dev/null +++ b/Packages/XStatsKit/Sources/AIUsage/QuotaProviders.swift @@ -0,0 +1,277 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Security +import LocalAuthentication + +public enum AIQuotaKind: String, Sendable, Identifiable { + case session, weekly, opusWeekly, sonnetWeekly + public var id: Self { self } +} + +public struct AIQuotaWindow: Equatable, Sendable, Identifiable { + public let kind: AIQuotaKind + public let usedPercent: Double + public let resetsAt: Date? + public var id: AIQuotaKind { kind } + + public var remainingPercent: Double { 100 - usedPercent } + + /// 以服务端给出的重置时间为终点;没有重置时间时不能推断窗口进度。 + public func remainingTimeFraction(at now: Date) -> Double? { + guard let resetsAt else { return nil } + let duration: TimeInterval = kind == .session ? 5 * 3600 : 7 * 24 * 3600 + return min(1, max(0, resetsAt.timeIntervalSince(now) / duration)) + } +} + +public struct AIQuotaSnapshot: Equatable, Sendable { + public let provider: AIProviderID + public let windows: [AIQuotaWindow] + public let fetchedAt: Date + + public func window(_ kind: AIQuotaKind) -> AIQuotaWindow? { windows.first { $0.kind == kind } } +} + +public enum AIQuotaFailure: Error, Equatable, Sendable { + case notConfigured, unauthorized, rateLimited, network, invalidResponse + + public var preservesLastGood: Bool { + switch self { + case .notConfigured, .unauthorized: false + case .rateLimited, .network, .invalidResponse: true + } + } +} + +public protocol AIQuotaProvider: Sendable { + var id: AIProviderID { get } + func fetch() async throws -> AIQuotaSnapshot +} + +protocol QuotaHTTPClient: Sendable { + func send(_ request: URLRequest) async throws -> (Data, Int) +} + +private actor URLSessionQuotaHTTPClient: QuotaHTTPClient { + private let session: URLSession + + init() { + let configuration = URLSessionConfiguration.ephemeral + configuration.urlCache = nil + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData + configuration.timeoutIntervalForRequest = 20 + session = URLSession(configuration: configuration, delegate: RejectQuotaRedirects(), delegateQueue: nil) + } + + func send(_ request: URLRequest) async throws -> (Data, Int) { + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse else { throw AIQuotaFailure.network } + return (data, response.statusCode) + } +} + +/// Bearer 凭据只允许发送到固定域名;重定向不能携带原 Authorization 继续走。 +private final class RejectQuotaRedirects: NSObject, URLSessionTaskDelegate, Sendable { + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest) async -> URLRequest? { nil } +} + +struct CodexQuotaCredentials: Sendable { + let token: String + let accountID: String? + + static func parse(_ data: Data) throws -> Self { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AIQuotaFailure.notConfigured + } + // OPENAI_API_KEY 不是 ChatGPT 订阅用量凭据。 + guard let tokens = object["tokens"] as? [String: Any], + let token = tokens["access_token"] as? String, !token.isEmpty else { + throw AIQuotaFailure.notConfigured + } + return Self(token: token, accountID: tokens["account_id"] as? String) + } + + static func load(environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser) throws -> Self { + let candidates: [URL] + if let custom = environment["CODEX_HOME"], !custom.isEmpty { + candidates = [URL(fileURLWithPath: (custom as NSString).expandingTildeInPath).appendingPathComponent("auth.json")] + } else { + candidates = [home.appendingPathComponent(".codex/auth.json"), + home.appendingPathComponent(".config/codex/auth.json")] + } + for url in candidates where FileManager.default.fileExists(atPath: url.path) { + guard let data = try? Data(contentsOf: url) else { throw AIQuotaFailure.notConfigured } + return try parse(data) + } + throw AIQuotaFailure.notConfigured + } +} + +struct ClaudeQuotaCredentials: Sendable { + let token: String + + static func parse(_ data: Data, now: Date = Date()) throws -> Self { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let oauth = object["claudeAiOauth"] as? [String: Any], + let token = oauth["accessToken"] as? String, !token.isEmpty else { + throw AIQuotaFailure.notConfigured + } + if let expiry = oauth["expiresAt"] as? NSNumber, + Date(timeIntervalSince1970: expiry.doubleValue / 1000) <= now { throw AIQuotaFailure.unauthorized } + return Self(token: token) + } + + static func load(environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser) throws -> String { + let root = environment["CLAUDE_CONFIG_DIR"].flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: ($0 as NSString).expandingTildeInPath) } + ?? home.appendingPathComponent(".claude") + let url = root.appendingPathComponent(".credentials.json") + var fileFailure: AIQuotaFailure? + if let data = try? Data(contentsOf: url) { + do { return try parse(data).token } + catch let failure as AIQuotaFailure { fileFailure = failure } + } + + // Claude CLI 部分版本只把 OAuth 保存到钥匙串。后台读取不允许弹出授权框。 + let context = LAContext() + context.interactionNotAllowed = true + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: "Claude Code-credentials", + kSecAttrAccount: NSUserName(), + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + kSecUseAuthenticationContext: context, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { throw fileFailure ?? AIQuotaFailure.notConfigured } + return try parse(data).token + } +} + +public actor CodexQuotaProvider: AIQuotaProvider { + public nonisolated let id = AIProviderID.codex + private let credentials: @Sendable () throws -> CodexQuotaCredentials + private let http: any QuotaHTTPClient + + public init() { + credentials = { try CodexQuotaCredentials.load() } + http = URLSessionQuotaHTTPClient() + } + + init(credentials: @escaping @Sendable () throws -> CodexQuotaCredentials, http: any QuotaHTTPClient) { + self.credentials = credentials + self.http = http + } + + public func fetch() async throws -> AIQuotaSnapshot { + let credential = try credentials() + var request = URLRequest(url: URL(string: "https://chatgpt.com/backend-api/wham/usage")!) + request.setValue("Bearer \(credential.token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("XStats AIUsage", forHTTPHeaderField: "User-Agent") + if let accountID = credential.accountID { request.setValue(accountID, forHTTPHeaderField: "ChatGPT-Account-Id") } + let data = try await quotaResponse(for: request, using: http) + return try Self.parse(data, now: Date()) + } + + static func parse(_ data: Data, now: Date) throws -> AIQuotaSnapshot { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rate = object["rate_limit"] as? [String: Any] else { throw AIQuotaFailure.invalidResponse } + var selected: [AIQuotaKind: (window: AIQuotaWindow, exact: Bool)] = [:] + for name in ["primary_window", "secondary_window"] { + guard let item = rate[name] as? [String: Any], + let used = (item["used_percent"] as? NSNumber)?.doubleValue, + used.isFinite, (0...100).contains(used) else { continue } + let kind: AIQuotaKind + let seconds = (item["limit_window_seconds"] as? NSNumber)?.intValue + switch seconds { + case 18_000: kind = .session + case 604_800: kind = .weekly + case nil: kind = name == "primary_window" ? .session : .weekly + default: continue + } + let reset = (item["reset_at"] as? NSNumber).map { Date(timeIntervalSince1970: $0.doubleValue) } + ?? (item["reset_after_seconds"] as? NSNumber).map { now.addingTimeInterval($0.doubleValue) } + let exact = seconds != nil + if selected[kind]?.exact != true || exact { + selected[kind] = (AIQuotaWindow(kind: kind, usedPercent: used, resetsAt: reset), exact) + } + } + let windows = [AIQuotaKind.session, .weekly].compactMap { selected[$0]?.window } + guard !windows.isEmpty else { throw AIQuotaFailure.invalidResponse } + return AIQuotaSnapshot(provider: .codex, windows: windows, fetchedAt: now) + } +} + +public actor ClaudeQuotaProvider: AIQuotaProvider { + public nonisolated let id = AIProviderID.claude + private let credentials: @Sendable () throws -> String + private let http: any QuotaHTTPClient + + public init() { + credentials = { try ClaudeQuotaCredentials.load() } + http = URLSessionQuotaHTTPClient() + } + + init(credentials: @escaping @Sendable () throws -> String, http: any QuotaHTTPClient) { + self.credentials = credentials + self.http = http + } + + public func fetch() async throws -> AIQuotaSnapshot { + let token = try credentials() + var request = URLRequest(url: URL(string: "https://api.anthropic.com/api/oauth/usage")!) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("XStats AIUsage", forHTTPHeaderField: "User-Agent") + let data = try await quotaResponse(for: request, using: http) + return try Self.parse(data, now: Date()) + } + + static func parse(_ data: Data, now: Date) throws -> AIQuotaSnapshot { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AIQuotaFailure.invalidResponse + } + let fields: [(String, AIQuotaKind)] = [ + ("five_hour", .session), ("seven_day", .weekly), + ("seven_day_opus", .opusWeekly), ("seven_day_sonnet", .sonnetWeekly), + ] + let formatter = ISO8601DateFormatter() + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var windows: [AIQuotaWindow] = [] + for (field, kind) in fields { + guard let item = object[field] as? [String: Any], + let used = (item["utilization"] as? NSNumber)?.doubleValue, + used.isFinite, (0...100).contains(used) else { continue } + let reset = (item["resets_at"] as? String).flatMap { fractionalFormatter.date(from: $0) ?? formatter.date(from: $0) } + windows.append(AIQuotaWindow(kind: kind, usedPercent: used, resetsAt: reset)) + } + guard !windows.isEmpty else { throw AIQuotaFailure.invalidResponse } + return AIQuotaSnapshot(provider: .claude, windows: windows, fetchedAt: now) + } +} + +private func quotaResponse(for request: URLRequest, using http: any QuotaHTTPClient) async throws -> Data { + let data: Data + let status: Int + do { (data, status) = try await http.send(request) } + catch is CancellationError { throw CancellationError() } + catch { throw AIQuotaFailure.network } + switch status { + case 200..<300: return data + case 401, 403: throw AIQuotaFailure.unauthorized + case 429: throw AIQuotaFailure.rateLimited + default: throw AIQuotaFailure.network + } +} diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 11075fe..086404c 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -335,6 +335,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم P 刷新状态 تحديث الحالة 刷新间隔 فترة التحديث 刷新频率 معدل التحديث +剩余 المتبقي +距重置 حتى إعادة التعيين 剩余 {} المتبقي {} 剩余寿命 العمر المتبقي 剩余寿命 {}% · 已写入 {} العمر المتبقي {}% · تمت كتابة {} @@ -1245,14 +1247,26 @@ Codex / Claude Code 本机模型与 Token 使用统计 استخدام نماذج 用量记录 سجلات الاستخدام 模型排行 ترتيب النماذج 启用使用统计 تفعيل إحصاءات الاستخدام -只读本机 Codex / Claude Code 会话日志,按模型统计 Token;不读取登录凭据,不查询订阅额度。 يقرأ سجلات جلسات Codex / Claude Code المحلية لحساب الرموز حسب النموذج دون بيانات تسجيل الدخول أو طلبات الحصة. +启用后读取本机会话日志,并使用本机登录凭据向 Codex / Claude 查询订阅额度。 When enabled, reads local session logs and uses local login credentials to query Codex / Claude subscription usage. +统计本机会话 Token,并使用本机登录凭据自动查询 Codex / Claude 订阅额度。 Counts local session tokens and automatically queries Codex / Claude subscription usage with local login credentials. +订阅额度 Subscription usage +服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics +重置: Resets: +查询失败,显示上次额度 Query failed; showing previous quota +暂无额度数据 No quota data yet +未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI +登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI +服务暂时限流,稍后会自动重试 Rate limited; will retry later +暂时无法连接额度服务 Cannot connect to the quota service right now +额度接口返回了无法识别的数据 The quota service returned unrecognized data +启用后自动读取本机 CLI 登录凭据,直接向 Codex / Claude 查询额度;凭据不保存到 XStats。 When enabled, reads local CLI login credentials and queries Codex / Claude directly; XStats does not save the credentials. +本机 Token 统计不代表订阅账单或其他设备的用量 Local token statistics are not a subscription bill or usage from other devices 缓存 Token 已包含在输入中,推理 Token 已包含在输出中。 رموز الذاكرة المؤقتة ضمن الإدخال ورموز الاستدلال ضمن الإخراج. 所选时间范围没有用量记录 لا توجد سجلات في هذه الفترة 部分会话日志无法读取,统计可能不完整 تعذرت قراءة بعض السجلات وقد تكون الإحصاءات غير مكتملة 读取失败,显示上次统计结果 فشلت القراءة؛ تُعرض الإحصاءات السابقة 正在读取会话日志… جارٍ قراءة سجلات الجلسات… 暂无本机用量数据 لا توجد بيانات استخدام محلية -仅统计本机日志,不代表订阅账单或其他设备的用量 سجلات محلية فقط، وليست فاتورة اشتراك أو استخدام أجهزة أخرى 缓存创建 Token رموز إنشاء الذاكرة المؤقتة Token 活动 نشاط الرموز 每日 يومي diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index a64c6ea..efa45fc 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -335,6 +335,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Von uv geladener Py 刷新状态 Status aktualisieren 刷新间隔 Aktualisierungsintervall 刷新频率 Aktualisierungsrate +剩余 Verbleibend +距重置 Bis zum Zurücksetzen 剩余 {} {} verbleibend 剩余寿命 Restlebensdauer 剩余寿命 {}% · 已写入 {} {} % Restlebensdauer · {} geschrieben @@ -1245,14 +1247,26 @@ Codex / Claude Code 本机模型与 Token 使用统计 Lokale Codex / Claude Cod 用量记录 Nutzungsdatensätze 模型排行 Modellrangliste 启用使用统计 Nutzungsstatistik aktivieren -只读本机 Codex / Claude Code 会话日志,按模型统计 Token;不读取登录凭据,不查询订阅额度。 Liest lokale Codex / Claude Code-Sitzungsprotokolle für Tokens je Modell. Keine Anmeldedaten oder Kontingentabfragen. +启用后读取本机会话日志,并使用本机登录凭据向 Codex / Claude 查询订阅额度。 When enabled, reads local session logs and uses local login credentials to query Codex / Claude subscription usage. +统计本机会话 Token,并使用本机登录凭据自动查询 Codex / Claude 订阅额度。 Counts local session tokens and automatically queries Codex / Claude subscription usage with local login credentials. +订阅额度 Subscription usage +服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics +重置: Resets: +查询失败,显示上次额度 Query failed; showing previous quota +暂无额度数据 No quota data yet +未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI +登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI +服务暂时限流,稍后会自动重试 Rate limited; will retry later +暂时无法连接额度服务 Cannot connect to the quota service right now +额度接口返回了无法识别的数据 The quota service returned unrecognized data +启用后自动读取本机 CLI 登录凭据,直接向 Codex / Claude 查询额度;凭据不保存到 XStats。 When enabled, reads local CLI login credentials and queries Codex / Claude directly; XStats does not save the credentials. +本机 Token 统计不代表订阅账单或其他设备的用量 Local token statistics are not a subscription bill or usage from other devices 缓存 Token 已包含在输入中,推理 Token 已包含在输出中。 Cache-Tokens sind in der Eingabe, Reasoning-Tokens in der Ausgabe enthalten. 所选时间范围没有用量记录 Keine Nutzung in diesem Zeitraum 部分会话日志无法读取,统计可能不完整 Einige Protokolle sind nicht lesbar; die Statistik ist eventuell unvollständig 读取失败,显示上次统计结果 Lesen fehlgeschlagen; vorherige Statistik angezeigt 正在读取会话日志… Sitzungsprotokolle werden gelesen… 暂无本机用量数据 Keine lokalen Nutzungsdaten -仅统计本机日志,不代表订阅账单或其他设备的用量 Nur lokale Protokolle; keine Abrechnung oder Nutzung anderer Geräte 缓存创建 Token Cache-Erstellungs-Tokens Token 活动 Token-Aktivität 每日 Täglich diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index beb89ae..937fbbb 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -335,6 +335,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Cachés de paquetes 刷新状态 Actualizar estado 刷新间隔 Intervalo de actualización 刷新频率 Frecuencia de actualización +剩余 Restante +距重置 Hasta el reinicio 剩余 {} {} restantes 剩余寿命 Vida útil restante 剩余寿命 {}% · 已写入 {} {}% de vida útil · {} escritos @@ -1245,14 +1247,26 @@ Codex / Claude Code 本机模型与 Token 使用统计 Uso local de modelos y to 用量记录 Registros de uso 模型排行 Clasificación de modelos 启用使用统计 Activar estadísticas -只读本机 Codex / Claude Code 会话日志,按模型统计 Token;不读取登录凭据,不查询订阅额度。 Lee registros locales de Codex / Claude Code para contar tokens por modelo. No accede a credenciales ni consulta cuotas. +启用后读取本机会话日志,并使用本机登录凭据向 Codex / Claude 查询订阅额度。 When enabled, reads local session logs and uses local login credentials to query Codex / Claude subscription usage. +统计本机会话 Token,并使用本机登录凭据自动查询 Codex / Claude 订阅额度。 Counts local session tokens and automatically queries Codex / Claude subscription usage with local login credentials. +订阅额度 Subscription usage +服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics +重置: Resets: +查询失败,显示上次额度 Query failed; showing previous quota +暂无额度数据 No quota data yet +未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI +登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI +服务暂时限流,稍后会自动重试 Rate limited; will retry later +暂时无法连接额度服务 Cannot connect to the quota service right now +额度接口返回了无法识别的数据 The quota service returned unrecognized data +启用后自动读取本机 CLI 登录凭据,直接向 Codex / Claude 查询额度;凭据不保存到 XStats。 When enabled, reads local CLI login credentials and queries Codex / Claude directly; XStats does not save the credentials. +本机 Token 统计不代表订阅账单或其他设备的用量 Local token statistics are not a subscription bill or usage from other devices 缓存 Token 已包含在输入中,推理 Token 已包含在输出中。 Los tokens en caché están incluidos en la entrada y los de razonamiento en la salida. 所选时间范围没有用量记录 Sin uso en este período 部分会话日志无法读取,统计可能不完整 Algunos registros no se pudieron leer; las estadísticas pueden estar incompletas 读取失败,显示上次统计结果 Error de lectura; se muestran estadísticas anteriores 正在读取会话日志… Leyendo registros de sesiones… 暂无本机用量数据 Sin datos de uso local -仅统计本机日志,不代表订阅账单或其他设备的用量 Solo registros locales; no refleja facturas ni uso de otros dispositivos 缓存创建 Token Tokens de creación de caché Token 活动 Actividad de tokens 每日 Diario diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 251a8b0..33a6f90 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -335,6 +335,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Caches de paquets P 刷新状态 Actualiser l’état 刷新间隔 Intervalle d’actualisation 刷新频率 Fréquence d’actualisation +剩余 Restant +距重置 Avant réinitialisation 剩余 {} {} restants 剩余寿命 Durée de vie restante 剩余寿命 {}% · 已写入 {} {} % de durée de vie restante · {} écrits @@ -1245,14 +1247,26 @@ Codex / Claude Code 本机模型与 Token 使用统计 Utilisation locale des mo 用量记录 Relevés d’utilisation 模型排行 Classement des modèles 启用使用统计 Activer les statistiques -只读本机 Codex / Claude Code 会话日志,按模型统计 Token;不读取登录凭据,不查询订阅额度。 Lit les journaux locaux Codex / Claude Code pour compter les tokens par modèle, sans identifiants ni requêtes de quota. +启用后读取本机会话日志,并使用本机登录凭据向 Codex / Claude 查询订阅额度。 When enabled, reads local session logs and uses local login credentials to query Codex / Claude subscription usage. +统计本机会话 Token,并使用本机登录凭据自动查询 Codex / Claude 订阅额度。 Counts local session tokens and automatically queries Codex / Claude subscription usage with local login credentials. +订阅额度 Subscription usage +服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics +重置: Resets: +查询失败,显示上次额度 Query failed; showing previous quota +暂无额度数据 No quota data yet +未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI +登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI +服务暂时限流,稍后会自动重试 Rate limited; will retry later +暂时无法连接额度服务 Cannot connect to the quota service right now +额度接口返回了无法识别的数据 The quota service returned unrecognized data +启用后自动读取本机 CLI 登录凭据,直接向 Codex / Claude 查询额度;凭据不保存到 XStats。 When enabled, reads local CLI login credentials and queries Codex / Claude directly; XStats does not save the credentials. +本机 Token 统计不代表订阅账单或其他设备的用量 Local token statistics are not a subscription bill or usage from other devices 缓存 Token 已包含在输入中,推理 Token 已包含在输出中。 Les tokens en cache sont inclus en entrée et ceux de raisonnement en sortie. 所选时间范围没有用量记录 Aucune utilisation sur cette période 部分会话日志无法读取,统计可能不完整 Certains journaux sont illisibles ; les statistiques peuvent être incomplètes 读取失败,显示上次统计结果 Échec de lecture ; affichage des statistiques précédentes 正在读取会话日志… Lecture des journaux de session… 暂无本机用量数据 Aucune donnée d’utilisation locale -仅统计本机日志,不代表订阅账单或其他设备的用量 Journaux locaux uniquement, pas la facture ni l’utilisation d’autres appareils 缓存创建 Token Tokens de création du cache Token 活动 Activité des tokens 每日 Par jour diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index f65d771..9757105 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -335,6 +335,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ 刷新状态 状態を更新 刷新间隔 更新間隔 刷新频率 更新頻度 +剩余 残り +距重置 リセットまで 剩余 {} 残り{} 剩余寿命 残り寿命 剩余寿命 {}% · 已写入 {} 残り寿命{}% · 書き込み済み{} @@ -1245,14 +1247,26 @@ Codex / Claude Code 本机模型与 Token 使用统计 Codex / Claude Code の 用量记录 使用記録 模型排行 モデル別使用量 启用使用统计 使用統計を有効にする -只读本机 Codex / Claude Code 会话日志,按模型统计 Token;不读取登录凭据,不查询订阅额度。 ローカルの Codex / Claude Code セッションログからモデル別トークンを集計します。認証情報や契約の上限にはアクセスしません。 +启用后读取本机会话日志,并使用本机登录凭据向 Codex / Claude 查询订阅额度。 When enabled, reads local session logs and uses local login credentials to query Codex / Claude subscription usage. +统计本机会话 Token,并使用本机登录凭据自动查询 Codex / Claude 订阅额度。 Counts local session tokens and automatically queries Codex / Claude subscription usage with local login credentials. +订阅额度 Subscription usage +服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics +重置: Resets: +查询失败,显示上次额度 Query failed; showing previous quota +暂无额度数据 No quota data yet +未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI +登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI +服务暂时限流,稍后会自动重试 Rate limited; will retry later +暂时无法连接额度服务 Cannot connect to the quota service right now +额度接口返回了无法识别的数据 The quota service returned unrecognized data +启用后自动读取本机 CLI 登录凭据,直接向 Codex / Claude 查询额度;凭据不保存到 XStats。 When enabled, reads local CLI login credentials and queries Codex / Claude directly; XStats does not save the credentials. +本机 Token 统计不代表订阅账单或其他设备的用量 Local token statistics are not a subscription bill or usage from other devices 缓存 Token 已包含在输入中,推理 Token 已包含在输出中。 キャッシュは入力に、推論は出力に含まれます。 所选时间范围没有用量记录 選択期間に使用記録がありません 部分会话日志无法读取,统计可能不完整 一部のログを読み込めず、統計が不完全な可能性があります 读取失败,显示上次统计结果 読み込み失敗。前回の統計を表示中 正在读取会话日志… セッションログを読み込み中… 暂无本机用量数据 ローカル使用データがありません -仅统计本机日志,不代表订阅账单或其他设备的用量 この端末のログのみ。請求額や他の端末の使用量ではありません 缓存创建 Token キャッシュ作成トークン Token 活动 トークン活動 每日 日別 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index ef0fd45..623415b 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -335,6 +335,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키 刷新状态 상태 새로 고침 刷新间隔 새로 고침 간격 刷新频率 새로 고침 빈도 +剩余 남음 +距重置 초기화까지 剩余 {} {} 남음 剩余寿命 남은 수명 剩余寿命 {}% · 已写入 {} 남은 수명 {}% · 쓰기량 {} @@ -1245,14 +1247,26 @@ Codex / Claude Code 本机模型与 Token 使用统计 Codex / Claude Code 로 用量记录 사용 기록 模型排行 모델 순위 启用使用统计 사용 통계 활성화 -只读本机 Codex / Claude Code 会话日志,按模型统计 Token;不读取登录凭据,不查询订阅额度。 로컬 Codex / Claude Code 세션 로그로 모델별 토큰을 집계합니다. 로그인 정보와 구독 한도는 조회하지 않습니다. +启用后读取本机会话日志,并使用本机登录凭据向 Codex / Claude 查询订阅额度。 When enabled, reads local session logs and uses local login credentials to query Codex / Claude subscription usage. +统计本机会话 Token,并使用本机登录凭据自动查询 Codex / Claude 订阅额度。 Counts local session tokens and automatically queries Codex / Claude subscription usage with local login credentials. +订阅额度 Subscription usage +服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics +重置: Resets: +查询失败,显示上次额度 Query failed; showing previous quota +暂无额度数据 No quota data yet +未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI +登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI +服务暂时限流,稍后会自动重试 Rate limited; will retry later +暂时无法连接额度服务 Cannot connect to the quota service right now +额度接口返回了无法识别的数据 The quota service returned unrecognized data +启用后自动读取本机 CLI 登录凭据,直接向 Codex / Claude 查询额度;凭据不保存到 XStats。 When enabled, reads local CLI login credentials and queries Codex / Claude directly; XStats does not save the credentials. +本机 Token 统计不代表订阅账单或其他设备的用量 Local token statistics are not a subscription bill or usage from other devices 缓存 Token 已包含在输入中,推理 Token 已包含在输出中。 캐시 토큰은 입력에, 추론 토큰은 출력에 포함됩니다. 所选时间范围没有用量记录 선택한 기간에 사용 기록이 없습니다 部分会话日志无法读取,统计可能不完整 일부 로그를 읽을 수 없어 통계가 불완전할 수 있습니다 读取失败,显示上次统计结果 읽기 실패, 이전 통계 표시 중 正在读取会话日志… 세션 로그 읽는 중… 暂无本机用量数据 로컬 사용 데이터 없음 -仅统计本机日志,不代表订阅账单或其他设备的用量 로컬 로그만 집계하며 구독 청구액이나 다른 기기 사용량은 아닙니다 缓存创建 Token 캐시 생성 토큰 Token 活动 토큰 활동 每日 일별 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 1ebe5bd..7a51bd5 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -336,6 +336,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python 刷新状态 刷新狀態 刷新间隔 刷新間隔 刷新频率 刷新頻率 +剩余 剩餘 +距重置 距重置 剩余 {} 剩餘 {} 剩余寿命 剩餘壽命 剩余寿命 {}% · 已写入 {} 剩餘壽命 {}% · 已寫入 {} @@ -1246,14 +1248,26 @@ Codex / Claude Code 本机模型与 Token 使用统计 Codex / Claude Code 本 用量记录 用量記錄 模型排行 模型排行 启用使用统计 啟用使用統計 -只读本机 Codex / Claude Code 会话日志,按模型统计 Token;不读取登录凭据,不查询订阅额度。 唯讀本機 Codex / Claude Code 工作階段日誌,按模型統計 Token;不讀取登入憑證,不查詢訂閱額度。 +启用后读取本机会话日志,并使用本机登录凭据向 Codex / Claude 查询订阅额度。 啟用後讀取本機工作階段日誌,並使用本機登入憑證向 Codex / Claude 查詢訂閱額度。 +统计本机会话 Token,并使用本机登录凭据自动查询 Codex / Claude 订阅额度。 統計本機工作階段 Token,並使用本機登入憑證自動查詢 Codex / Claude 訂閱額度。 +订阅额度 訂閱額度 +服务端用量 · 与本机 Token 统计不同 服務端用量 · 與本機 Token 統計不同 +重置: 重置: +查询失败,显示上次额度 查詢失敗,顯示上次額度 +暂无额度数据 暫無額度資料 +未检测到登录凭据,请先登录对应 CLI 未偵測到登入憑證,請先登入對應的 CLI +登录已失效,请在对应 CLI 重新登录 登入已失效,請在對應的 CLI 重新登入 +服务暂时限流,稍后会自动重试 服務暫時限制請求,稍後會自動重試 +暂时无法连接额度服务 暫時無法連線至額度服務 +额度接口返回了无法识别的数据 額度介面回傳了無法辨識的資料 +启用后自动读取本机 CLI 登录凭据,直接向 Codex / Claude 查询额度;凭据不保存到 XStats。 啟用後自動讀取本機 CLI 登入憑證,直接向 Codex / Claude 查詢額度;憑證不會儲存到 XStats。 +本机 Token 统计不代表订阅账单或其他设备的用量 本機 Token 統計不代表訂閱帳單或其他裝置的用量 缓存 Token 已包含在输入中,推理 Token 已包含在输出中。 快取 Token 已包含在輸入中,推理 Token 已包含在輸出中。 所选时间范围没有用量记录 所選時間範圍沒有用量記錄 部分会话日志无法读取,统计可能不完整 部分工作階段日誌無法讀取,統計可能不完整 读取失败,显示上次统计结果 讀取失敗,顯示上次統計結果 正在读取会话日志… 正在讀取工作階段日誌… 暂无本机用量数据 暫無本機用量資料 -仅统计本机日志,不代表订阅账单或其他设备的用量 僅統計本機日誌,不代表訂閱帳單或其他裝置的用量 缓存创建 Token 快取建立 Token Token 活动 Token 活動 每日 每日 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index 3041125..9ca5163 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -36,14 +36,26 @@ Codex / Claude Code 本机模型与 Token 使用统计 Local Codex / Claude Code 用量记录 Usage records 模型排行 Model ranking 启用使用统计 Enable usage statistics -只读本机 Codex / Claude Code 会话日志,按模型统计 Token;不读取登录凭据,不查询订阅额度。 Reads local Codex / Claude Code session logs to count tokens by model. No login credentials or subscription quota requests. +启用后读取本机会话日志,并使用本机登录凭据向 Codex / Claude 查询订阅额度。 When enabled, reads local session logs and uses local login credentials to query Codex / Claude subscription usage. +统计本机会话 Token,并使用本机登录凭据自动查询 Codex / Claude 订阅额度。 Counts local session tokens and automatically queries Codex / Claude subscription usage with local login credentials. +订阅额度 Subscription usage +服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics +重置: Resets: +查询失败,显示上次额度 Query failed; showing previous quota +暂无额度数据 No quota data yet +未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI +登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI +服务暂时限流,稍后会自动重试 Rate limited; will retry later +暂时无法连接额度服务 Cannot connect to the quota service right now +额度接口返回了无法识别的数据 The quota service returned unrecognized data +启用后自动读取本机 CLI 登录凭据,直接向 Codex / Claude 查询额度;凭据不保存到 XStats。 When enabled, reads local CLI login credentials and queries Codex / Claude directly; XStats does not save the credentials. +本机 Token 统计不代表订阅账单或其他设备的用量 Local token statistics are not a subscription bill or usage from other devices 缓存 Token 已包含在输入中,推理 Token 已包含在输出中。 Cached tokens are included in input; reasoning tokens are included in output. 所选时间范围没有用量记录 No usage in this time range 部分会话日志无法读取,统计可能不完整 Some session logs could not be read; statistics may be incomplete 读取失败,显示上次统计结果 Read failed; showing previous statistics 正在读取会话日志… Reading session logs… 暂无本机用量数据 No local usage data -仅统计本机日志,不代表订阅账单或其他设备的用量 Local logs only; not a subscription bill or usage from other devices {} 英寸 {}-inch · {} 项使用中 · {} in use · 移到废纸篓 · Moves to Trash @@ -363,6 +375,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Python package and 刷新状态 Refresh Status 刷新间隔 Refresh Interval 刷新频率 Refresh Rate +剩余 Remaining +距重置 Until reset 剩余 {} {} left 剩余寿命 Life left 剩余寿命 {}% · 已写入 {} {}% life left · {} written diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift index 145407b..b0e7c03 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift @@ -1,7 +1,25 @@ import AppKit +import AIUsage import Localization import Metrics +struct MenuBarQuota { + let provider: AIProviderID + let window: AIQuotaWindow + + var remainingPercent: Int { Int(window.remainingPercent.rounded()) } + var sourceName: String { provider == .codex ? "Codex" : "Claude" } + var shortWindowName: String { window.kind == .session ? "5h" : "7d" } + + var resetText: String { + guard let reset = window.resetsAt else { return "—" } + let formatter = DateFormatter() + formatter.locale = L10n.locale + formatter.setLocalizedDateFormatFromTemplate("MdHm") + return formatter.string(from: reset) + } +} + /// 菜单栏上要画的读数,与数据来源解耦,设置页预览可以用示例数据绘制 struct MenuBarReading { var cpu: Double? @@ -21,6 +39,9 @@ struct MenuBarReading { var batteryHistory: [Double] = [] /// 本机日志今日 Token 总量;扫描未开启或还没有数据时为 nil var aiTokens: Int? + /// 每个已登录来源选一个订阅窗口;菜单栏主读数取剩余最少者,完整列表放在悬停提示中。 + var aiQuotas: [MenuBarQuota] = [] + var primaryAIQuota: MenuBarQuota? { aiQuotas.min { $0.remainingPercent < $1.remainingPercent } } var batteryCharging = false /// 附在电池项旁的蓝牙设备:电量低的那一个,或没有电池的 Mac 上电量最低的那一个 var bluetoothDevice: (symbol: String, percent: Int)? @@ -47,6 +68,14 @@ struct MenuBarReading { battery = store.battery?.level batteryHistory = battery.map { Array(repeating: $0, count: 30) } ?? [] aiTokens = model.aiUsage.todayTokens + aiQuotas = model.aiUsage.visibleQuotaProviders(for: nil).compactMap { provider in + guard let windows = model.aiUsage.visibleQuotaState(for: provider).snapshot?.windows else { return nil } + // 周额度比短时会话额度更适合作为常驻读数;模型专属周额度仅在通用周额度缺席时使用。 + let window = windows.first(where: { $0.kind == .weekly }) + ?? windows.first(where: { $0.kind == .opusWeekly || $0.kind == .sonnetWeekly }) + ?? windows.first(where: { $0.kind == .session }) + return window.map { MenuBarQuota(provider: provider, window: $0) } + } batteryCharging = store.battery?.isCharging ?? false if let lowest = model.bluetooth.lowest, battery == nil || (model.settings.bluetoothLowBatteryInMenuBar && lowest.percent <= Self.lowBluetoothPercent) { @@ -110,7 +139,13 @@ struct MenuBarReading { ?? bluetoothDevice.map { tr("蓝牙设备电量 \($0.percent)%") } // 没有读数也要给出一行,否则菜单栏只剩一个不会解释自己的 “AI —” case .aiUsage: - aiTokens.map { "AI · \(UsageNumber.exact($0)) Tokens" } ?? ("AI · " + tr("暂无本机用量数据")) + if !aiQuotas.isEmpty { + aiQuotas.map { quota in + "\(quota.sourceName) · \(quota.shortWindowName) · \(tr("剩余")) \(quota.remainingPercent)% · \(tr("重置:")) \(quota.resetText)" + }.joined(separator: "\n") + } else { + aiTokens.map { "AI · \(UsageNumber.exact($0)) Tokens" } ?? ("AI · " + tr("暂无本机用量数据")) + } } } .joined(separator: "\n") @@ -236,6 +271,12 @@ enum MenuBarRenderer { case .battery: return batterySegment(reading: reading, style: style, colorizeHighLoad: colorizeHighLoad) case .aiUsage: + if let quota = reading.primaryAIQuota { + let status = stackedText(label: "\(quota.shortWindowName) · \(quota.resetText)", + value: "\(quota.sourceName) \(quota.remainingPercent)%", + sample: "Claude 100%", alert: false) + return style == .icon ? combine([symbolSegment(item.symbol), status]) : status + } return textSegment(item: item, value: reading.aiTokens.map { Self.tokenText($0) } ?? "—", sample: "999.9M", style: style) } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift index 029f579..3b7e30b 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift @@ -36,10 +36,13 @@ private struct LocalUsageContent: View { var body: some View { VStack(alignment: .leading, spacing: compact ? DS.Space.s3 : DS.Space.s4) { toolbar + if !model.aiUsage.visibleQuotaProviders(for: provider).isEmpty { + quotaSection + } if !model.settings.aiUsageEnabled { Card { Label(tr("AI 使用统计"), systemImage: "chart.bar").dsFont(.base, weight: .semibold) - Text(tr("只读本机 Codex / Claude Code 会话日志,按模型统计 Token;不读取登录凭据,不查询订阅额度。")) + Text(tr("启用后读取本机会话日志,并使用本机登录凭据向 Codex / Claude 查询订阅额度。")) .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) .fixedSize(horizontal: false, vertical: true) Button(tr("启用使用统计")) { model.settings.aiUsageEnabled = true } @@ -104,6 +107,96 @@ private struct LocalUsageContent: View { }.frame(width: DS.Size.controlHeight, height: DS.Size.controlHeight) } + private var quotaSection: some View { + Card(spacing: DS.Space.s3) { + let visibleProviders = model.aiUsage.visibleQuotaProviders(for: provider) + HStack(spacing: DS.Space.s1) { + Text(tr("订阅额度")).dsFont(.sm, weight: .semibold) + Image(systemName: "info.circle") + .font(.system(size: DS.TextSize.xs.rawValue)) + .foregroundStyle(DS.Palette.textTertiary) + .help(tr("服务端用量 · 与本机 Token 统计不同")) + .accessibilityLabel(tr("服务端用量 · 与本机 Token 统计不同")) + Spacer() + } + ForEach(visibleProviders) { id in + let state = model.aiUsage.visibleQuotaState(for: id) + VStack(alignment: .leading, spacing: DS.Space.s3) { + if provider == nil { + Text(id == .codex ? "Codex" : "Claude Code").dsFont(.xs, weight: .semibold) + } + if let snapshot = state.snapshot { + ForEach(snapshot.windows) { window in + VStack(alignment: .leading, spacing: DS.Space.s1) { + HStack(alignment: .firstTextBaseline) { + Text(quotaTitle(window.kind)) + .foregroundStyle(DS.Palette.textSecondary) + Spacer() + Text(tr("剩余")) + .foregroundStyle(DS.Palette.textSecondary) + Text("\(Int(window.remainingPercent.rounded()))%") + .monospacedDigit() + .foregroundStyle(DS.Palette.textPrimary) + } + .dsFont(.xs) + ProgressView(value: window.remainingPercent, total: 100) + .tint(DS.Palette.primary) + .accessibilityLabel("\(quotaTitle(window.kind)) \(tr("剩余")) \(Int(window.remainingPercent.rounded()))%") + if let reset = window.resetsAt { + Text(tr("重置:") + " " + reset.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale))) + .dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + TimelineView(.periodic(from: .now, by: 60)) { context in + if let fraction = window.remainingTimeFraction(at: context.date) { + HStack(spacing: DS.Space.s2) { + Text(tr("距重置")) + .dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + ProgressView(value: fraction, total: 1) + .tint(DS.Palette.textTertiary) + .accessibilityLabel(tr("距重置")) + } + } + } + } + } + } + if state.failure != nil { + Text(tr("查询失败,显示上次额度")) + .dsFont(.xs).foregroundStyle(DS.Palette.warning) + } + } else if state.isRefreshing { + ProgressView().controlSize(.small) + } else { + Text(quotaFailureText(state.failure)) + .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + } + } + if id != visibleProviders.last { + HairlineDivider() + } + } + } + } + + private func quotaTitle(_ kind: AIQuotaKind) -> String { + switch kind { + case .session: tr("5 小时") + case .weekly: tr("7 天") + case .opusWeekly: "Opus · " + tr("7 天") + case .sonnetWeekly: "Sonnet · " + tr("7 天") + } + } + + private func quotaFailureText(_ failure: AIQuotaFailure?) -> String { + switch failure { + case nil: tr("暂无额度数据") + case .notConfigured: tr("未检测到登录凭据,请先登录对应 CLI") + case .unauthorized: tr("登录已失效,请在对应 CLI 重新登录") + case .rateLimited: tr("服务暂时限流,稍后会自动重试") + case .network: tr("暂时无法连接额度服务") + case .invalidResponse: tr("额度接口返回了无法识别的数据") + } + } + private func modelRanking(report: LocalUsageReport, rows: [ModelTokenUsage], total: ModelTokenUsage) -> some View { let models = LocalUsageReport.models(rows) return Card(spacing: DS.Space.s4) { @@ -514,7 +607,10 @@ private struct UsageSettings: View { } }.labelsHidden() } - Text(tr("仅统计本机日志,不代表订阅账单或其他设备的用量")) + Text(tr("启用后自动读取本机 CLI 登录凭据,直接向 Codex / Claude 查询额度;凭据不保存到 XStats。")) + .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) } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md index 2ae661b..93ef6db 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md @@ -8,7 +8,7 @@ This policy explains what XStats processes on your Mac and which features contac CPU, GPU, memory, network, temperature, fan, and process metrics come from local system interfaces. Those metrics, their history, hardware serial numbers, and process lists are processed on your Mac and are not uploaded to the XStats update service. Preferences are stored locally; selected preferences become a remote backup only when you manually use WebDAV sync. -AI Usage is off by default. When enabled, it reads local Codex / Claude Code session logs to summarize token usage by model. It does not read login credentials, query subscription quotas, or transmit those logs. Apple Intelligence process explanations run on-device and do not send process information to another AI service. +AI Usage is off by default. When enabled, it reads local Codex / Claude Code session logs to summarize token usage by model. It also checks the corresponding CLI login credentials read-only and directly queries Codex and Claude for five-hour and weekly quota usage. Each request sends the login token to its respective provider; XStats does not save tokens in its preferences or statistics database, and does not send session-log contents with quota requests. A quota request may fail or be rate-limited without affecting local token statistics. Turning the feature off stops subsequent scans and quota requests. Apple Intelligence process explanations run on-device and do not send process information to another AI service. You choose whether and where to export diagnostics; they are not uploaded automatically. An exported archive may contain a settings summary, the last three days of system logs, cleanup records, and crash reports. Review it before sharing. @@ -22,6 +22,7 @@ You can turn off automatic checks in Settings → About. You decide whether to d ## 3. Other network features +- When AI Usage is enabled, XStats uses local Codex / Claude Code login credentials to contact the quota endpoints on `chatgpt.com` and `api.anthropic.com` directly. Those providers can observe the login token and connection metadata. Quota results are held only in app memory and are not uploaded 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. - Globalping receives the target domain you enter. Measurement parameters and results can be retrieved by anyone with the measurement ID; do not enter a target you want to keep private. diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md index 4611cf2..5ac122c 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md @@ -8,7 +8,7 @@ CPU、GPU、内存、网络、温度、风扇、进程等监控数据来自本机系统接口,其指标、历史记录、硬件序列号和进程列表仅在本机处理,不上传到 XStats 更新服务。设置保存在本机应用偏好中;仅在你主动使用 WebDAV 同步时,所选设置会形成远端备份。 -“AI 使用统计”默认关闭;开启后只读取本机 Codex / Claude Code 会话日志,按模型汇总 Token 用量,不读取登录凭据,不查询订阅额度,也不发送这些日志。Apple 智能进程解释在本机运行,不把进程信息发送给其他 AI 服务。 +“AI 使用统计”默认关闭;开启后读取本机 Codex / Claude Code 会话日志,按模型汇总 Token 用量,并自动只读检查对应 CLI 的登录凭据,直接向 Codex、Claude 的额度接口查询 5 小时及每周用量。查询请求会向对应服务发送登录令牌;令牌不写入 XStats 设置或统计数据库,日志内容不会随额度请求发送。接口可能变化或限流,额度查询失败不影响本机 Token 统计。关闭此功能后停止后续扫描和额度查询。Apple 智能进程解释在本机运行,不把进程信息发送给其他 AI 服务。 诊断信息由你主动导出到指定位置,不会自动上传。导出的压缩包可能包含应用设置摘要、最近三天的系统日志、清理记录和崩溃报告;分享前请自行检查内容。 @@ -22,6 +22,7 @@ CPU、GPU、内存、网络、温度、风扇、进程等监控数据来自本 ## 3. 其他联网功能 +- 启用“AI 使用统计”后,应用使用本机 Codex / Claude Code 的登录凭据,分别直接请求 `chatgpt.com` 和 `api.anthropic.com` 的额度接口;对方可以看到登录令牌及连接元数据。额度结果仅在应用运行期间保留在内存中,不上传到 XStats 服务器。 - 打开网络详情且启用公网 IP 查询时,应用直接访问 Cloudflare `1.1.1.1`,失败时回退到 ipify;归属地、ASN、网络类型和纯净度查询会把公网 IP 发送给 `cleanip.io`。结果在本机缓存。 - 启用连接探测时,应用向所选目标发送 ICMP 探测。测速、出口检测和 DNS 查询仅在你使用相应功能时,直接连接所选测试节点、网站或 DNS 服务;对方可看到连接元数据及测试所需内容。 - 使用全球探针时,应用会把你输入的目标域名提交给 Globalping。测量参数和结果可通过测量 ID 被他人查询,请勿输入不希望公开的目标。 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift index 95ace45..f7bfc9d 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift @@ -107,7 +107,7 @@ struct GeneralSettings: View { SettingsGroup(caption: tr("可选功能")) { GroupRow(showsDivider: false) { SettingRow(title: tr("AI 使用统计"), - subtitle: tr("只读本机 Codex / Claude Code 会话日志,按模型统计 Token;不读取登录凭据,不查询订阅额度。"), + subtitle: tr("统计本机会话 Token,并使用本机登录凭据自动查询 Codex / Claude 订阅额度。"), icon: "sparkles") { DSToggle(isOn: $settings.aiUsageEnabled, label: tr("AI 使用统计")) } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AIUsageController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AIUsageController.swift index 46ba89c..d031ead 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AIUsageController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AIUsageController.swift @@ -14,16 +14,27 @@ public struct AIUsageProviderState: Equatable, Sendable { 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 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 观察的刷新脉冲;页面上的“上次检查”读的是各 Provider 的 fetchedAt。 public private(set) var lastAttemptAt: Date? @ObservationIgnored private let settings: AppSettings @ObservationIgnored private let providers: [any AIUsageProvider] + @ObservationIgnored private let quotaProviders: [any AIQuotaProvider] @ObservationIgnored private let now: @Sendable () -> Date @ObservationIgnored private var cadenceTask: Task? @ObservationIgnored private var refreshTask: Task? @@ -31,18 +42,43 @@ public final class AIUsageController { @ObservationIgnored private var paused = false public init(settings: AppSettings, providers: [any AIUsageProvider] = [CodexLocalUsageProvider(), ClaudeLocalUsageProvider()], + quotaProviders: [any AIQuotaProvider] = [], now: @escaping @Sendable () -> Date = Date.init) { self.settings = settings self.providers = providers + self.quotaProviders = quotaProviders 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)) }) } public func state(for provider: AIProviderID) -> AIUsageProviderState { states[provider] ?? AIUsageProviderState(provider: provider) } - public var isRefreshing: Bool { states.values.contains(where: \.isRefreshing) } + public func quotaState(for provider: AIProviderID) -> AIQuotaProviderState { + quotaStates[provider] ?? AIQuotaProviderState(provider: 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, let report = localReport(for: nil) else { return nil } @@ -68,12 +104,16 @@ public final class AIUsageController { for provider in providers where !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 { 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 } @@ -121,9 +161,15 @@ public final class AIUsageController { 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.aiUsageSources.contains(provider.id) else { states[provider.id] = AIUsageProviderState(provider: provider.id) @@ -154,6 +200,39 @@ public final class AIUsageController { 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 + case .failure(let failure): + if !failure.preservesLastGood { state.snapshot = nil } + state.failure = failure + } + state.isRefreshing = false + quotaStates[id] = state + } lastAttemptAt = attempt } + + private static func fetchQuotas(_ providers: [any AIQuotaProvider]) async -> [(AIProviderID, Result)] { + await withTaskGroup(of: (AIProviderID, Result).self) { group in + for provider in providers { + group.addTask { + do { return (provider.id, .success(try await provider.fetch())) } + catch let failure as AIQuotaFailure { return (provider.id, .failure(failure)) } + catch { return (provider.id, .failure(.network)) } + } + } + var results: [(AIProviderID, Result)] = [] + for await result in group { results.append(result) } + return results + } + } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift index 0116144..bc0673a 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift @@ -64,11 +64,12 @@ public final class AppModel { @ObservationIgnored var quit: () -> Void = {} public init(settings: AppSettings = AppSettings(), historyURL: URL? = HistoryDatabase.defaultURL, - aiUsageProviders: [any AIUsageProvider] = [CodexLocalUsageProvider(), ClaudeLocalUsageProvider()]) { + aiUsageProviders: [any AIUsageProvider] = [CodexLocalUsageProvider(), ClaudeLocalUsageProvider()], + aiQuotaProviders: [any AIQuotaProvider] = [CodexQuotaProvider(), ClaudeQuotaProvider()]) { let store = MetricsStore() let helper = HelperClient() self.settings = settings - aiUsage = AIUsageController(settings: settings, providers: aiUsageProviders) + aiUsage = AIUsageController(settings: settings, providers: aiUsageProviders, quotaProviders: aiQuotaProviders) self.store = store self.helper = helper fans = FanController(helper: helper, store: store, settings: settings) diff --git a/Packages/XStatsKit/Tests/AIUsageTests/QuotaProviderTests.swift b/Packages/XStatsKit/Tests/AIUsageTests/QuotaProviderTests.swift new file mode 100644 index 0000000..1b02071 --- /dev/null +++ b/Packages/XStatsKit/Tests/AIUsageTests/QuotaProviderTests.swift @@ -0,0 +1,108 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +@testable import AIUsage +import Foundation +import Testing + +private actor StubQuotaHTTPClient: QuotaHTTPClient { + let status: Int + let body: Data + private(set) var requests: [URLRequest] = [] + + init(status: Int = 200, body: String) { + self.status = status + self.body = Data(body.utf8) + } + + func send(_ request: URLRequest) async throws -> (Data, Int) { + requests.append(request) + return (body, status) + } + + func lastRequest() -> URLRequest? { requests.last } +} + +@Suite struct QuotaProviderTests { + @Test func quotaPresentationUsesRemainingAmountAndTimeUntilReset() throws { + let now = Date(timeIntervalSince1970: 1_000_000) + let weekly = AIQuotaWindow(kind: .weekly, usedPercent: 74, + resetsAt: now.addingTimeInterval(7 * 24 * 3600 / 4)) + let session = AIQuotaWindow(kind: .session, usedPercent: 20, + resetsAt: now.addingTimeInterval(2.5 * 3600)) + + #expect(weekly.remainingPercent == 26) + #expect(try #require(weekly.remainingTimeFraction(at: now)) == 0.25) + #expect(try #require(session.remainingTimeFraction(at: now)) == 0.5) + #expect(weekly.remainingTimeFraction(at: now.addingTimeInterval(8 * 24 * 3600)) == 0) + #expect(weekly.remainingTimeFraction(at: now.addingTimeInterval(-8 * 24 * 3600)) == 1) + } + + @Test func quotaWithoutResetTimeHasNoTimeProgress() { + let window = AIQuotaWindow(kind: .weekly, usedPercent: 74, resetsAt: nil) + #expect(window.remainingTimeFraction(at: Date()) == nil) + } + + @Test func codexMapsWeeklyWindowEvenWhenServerReversesSlots() async throws { + let http = StubQuotaHTTPClient(body: #"{"rate_limit":{"primary_window":{"used_percent":61,"limit_window_seconds":604800,"reset_at":1800000000},"secondary_window":{"used_percent":18,"limit_window_seconds":18000,"reset_at":1799500000}}}"#) + let provider = CodexQuotaProvider(credentials: { CodexQuotaCredentials(token: "test-token", accountID: "account-1") }, http: http) + + let result = try await provider.fetch() + + #expect(result.window(.weekly)?.usedPercent == 61) + #expect(result.window(.session)?.usedPercent == 18) + let request = try #require(await http.lastRequest()) + #expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/wham/usage") + #expect(request.value(forHTTPHeaderField: "ChatGPT-Account-Id") == "account-1") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token") + } + + @Test func claudeMapsFiveHourAndWeeklyBuckets() async throws { + let http = StubQuotaHTTPClient(body: #"{"five_hour":{"utilization":22.5,"resets_at":"2026-09-23T10:00:00Z"},"seven_day":{"utilization":72,"resets_at":"2026-09-29T10:00:00Z"},"seven_day_sonnet":{"utilization":81,"resets_at":"2026-09-29T10:00:00Z"}}"#) + let provider = ClaudeQuotaProvider(credentials: { "claude-test-token" }, http: http) + + let result = try await provider.fetch() + + #expect(result.window(.session)?.usedPercent == 22.5) + #expect(result.window(.weekly)?.usedPercent == 72) + #expect(result.window(.sonnetWeekly)?.usedPercent == 81) + let request = try #require(await http.lastRequest()) + #expect(request.url?.absoluteString == "https://api.anthropic.com/api/oauth/usage") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer claude-test-token") + #expect(request.value(forHTTPHeaderField: "anthropic-beta") == "oauth-2025-04-20") + } + + @Test func codexFallsBackToPrimaryAndSecondaryWhenDurationIsOmitted() throws { + let now = Date(timeIntervalSince1970: 1_799_000_000) + let data = Data(#"{"rate_limit":{"primary_window":{"used_percent":14,"reset_after_seconds":3600},"secondary_window":{"used_percent":84,"reset_after_seconds":7200}}}"#.utf8) + + let result = try CodexQuotaProvider.parse(data, now: now) + + #expect(result.window(.session)?.usedPercent == 14) + #expect(result.window(.weekly)?.usedPercent == 84) + #expect(result.window(.weekly)?.resetsAt == now.addingTimeInterval(7200)) + } + + @Test func unauthorizedDoesNotReturnOldOrEmptyQuota() async { + let http = StubQuotaHTTPClient(status: 401, body: "{}") + let provider = CodexQuotaProvider(credentials: { CodexQuotaCredentials(token: "test", accountID: nil) }, http: http) + do { + _ = try await provider.fetch() + Issue.record("Expected an authentication failure") + } catch let error as AIQuotaFailure { + #expect(error == .unauthorized) + } catch { + Issue.record("Unexpected error type") + } + } + + @Test func credentialParsersRejectAPIKeyOnlyAndExpiredClaudeToken() throws { + #expect(throws: AIQuotaFailure.notConfigured) { + try CodexQuotaCredentials.parse(Data(#"{"OPENAI_API_KEY":"sk-test"}"#.utf8)) + } + let expired = Data(#"{"claudeAiOauth":{"accessToken":"old","expiresAt":1000}}"#.utf8) + #expect(throws: AIQuotaFailure.unauthorized) { + try ClaudeQuotaCredentials.parse(expired, now: Date(timeIntervalSince1970: 2)) + } + } +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/AIUsageControllerTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/AIUsageControllerTests.swift index 691ba2c..f2b98dc 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/AIUsageControllerTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/AIUsageControllerTests.swift @@ -24,6 +24,24 @@ private actor StubUsageProvider: AIUsageProvider { func count() -> Int { fetchCount } } +private actor StubQuotaProvider: AIQuotaProvider { + nonisolated let id: AIProviderID + private var results: [Result] + private(set) var count = 0 + + init(id: AIProviderID = .codex, results: [Result]) { + self.id = id + self.results = results + } + + func fetch() async throws -> AIQuotaSnapshot { + count += 1 + return try results.removeFirst().get() + } + + func fetchCount() -> Int { count } +} + private func defaultsForAIUsage() -> UserDefaults { let name = "AIUsageControllerTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: name)! @@ -60,6 +78,123 @@ private actor GatedUsageProvider: AIUsageProvider { @MainActor @Suite struct AIUsageControllerTests { + @Test func menuBarPrefersWeeklyRemainingQuotaAndListsBothSourcesInTooltip() async { + let settings = AppSettings(defaults: defaultsForAIUsage()) + settings.aiUsageEnabled = true + let reset = Date(timeIntervalSince1970: 1_800_000_000) + let codex = AIQuotaSnapshot(provider: .codex, windows: [ + AIQuotaWindow(kind: .session, usedPercent: 95, resetsAt: reset), + AIQuotaWindow(kind: .weekly, usedPercent: 73, resetsAt: reset), + ], fetchedAt: Date()) + let claude = AIQuotaSnapshot(provider: .claude, windows: [ + AIQuotaWindow(kind: .weekly, usedPercent: 48, resetsAt: reset), + ], fetchedAt: Date()) + let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: [], aiQuotaProviders: [ + StubQuotaProvider(results: [.success(codex)]), + StubQuotaProvider(id: .claude, results: [.success(claude)]), + ]) + await model.aiUsage.refresh() + + let reading = MenuBarReading(model: model) + #expect(reading.primaryAIQuota?.provider == .codex) + #expect(reading.primaryAIQuota?.window.kind == .weekly) + #expect(reading.primaryAIQuota?.remainingPercent == 27) + #expect(reading.primaryAIQuota?.resetText != "—") + let tooltip = reading.tooltip(items: [.aiUsage], fahrenheit: false) + #expect(tooltip.contains("Codex")) + #expect(tooltip.contains("27%")) + #expect(tooltip.contains("Claude")) + #expect(tooltip.contains("52%")) + #expect(!tooltip.contains("5%")) + } + + @Test func menuBarFallsBackToLocalTokensWhenSubscriptionIsUnavailable() async { + let settings = AppSettings(defaults: defaultsForAIUsage()) + settings.aiUsageEnabled = true + let model = AppModel(settings: settings, historyURL: nil, + aiUsageProviders: [StubUsageProvider([.success(usageSnapshot(tokens: 42))])], + aiQuotaProviders: [StubQuotaProvider(results: [.failure(.notConfigured)])]) + await model.aiUsage.refresh() + + let tooltip = MenuBarReading(model: model).tooltip(items: [.aiUsage], fahrenheit: false) + #expect(tooltip.contains("42 Tokens")) + #expect(!tooltip.contains("剩余")) + } + + @Test func quotaRefreshFollowsMasterSwitchAndKeepsLocalTokensOnAuthFailure() async { + let settings = AppSettings(defaults: defaultsForAIUsage()) + let quota = StubQuotaProvider(results: [.failure(.unauthorized)]) + let local = StubUsageProvider([.success(usageSnapshot(tokens: 42))]) + let controller = AIUsageController(settings: settings, providers: [local], quotaProviders: [quota]) + + await controller.refresh() + #expect(await quota.fetchCount() == 0) + settings.aiUsageEnabled = true + await controller.refresh() + #expect(await quota.fetchCount() == 1) + #expect(controller.todayTokens == 42) + #expect(controller.quotaState(for: .codex).failure == .unauthorized) + settings.aiUsageEnabled = false + #expect(controller.visibleQuotaState(for: .codex).snapshot == nil) + } + + @Test func quotaTransportFailureKeepsLastValueButLogoutClearsIt() async { + let settings = AppSettings(defaults: defaultsForAIUsage()) + settings.aiUsageEnabled = true + let first = AIQuotaSnapshot(provider: .codex, + windows: [AIQuotaWindow(kind: .weekly, usedPercent: 63, resetsAt: nil)], fetchedAt: Date()) + let quota = StubQuotaProvider(results: [.success(first), .failure(.network), .failure(.unauthorized)]) + let controller = AIUsageController(settings: settings, providers: [], quotaProviders: [quota]) + + await controller.refresh() + #expect(controller.visibleQuotaState(for: .codex).snapshot?.window(.weekly)?.usedPercent == 63) + #expect(controller.visibleQuotaProviders(for: nil) == [.codex]) + await controller.refresh() + #expect(controller.visibleQuotaState(for: .codex).snapshot?.window(.weekly)?.usedPercent == 63) + #expect(controller.visibleQuotaState(for: .codex).failure == .network) + #expect(controller.visibleQuotaProviders(for: nil) == [.codex]) + await controller.refresh() + #expect(controller.visibleQuotaState(for: .codex).snapshot == nil) + #expect(controller.visibleQuotaState(for: .codex).failure == .unauthorized) + #expect(controller.visibleQuotaProviders(for: nil) == [.codex]) + } + + @Test func disabledSourceIsNotQueriedForQuota() async { + let settings = AppSettings(defaults: defaultsForAIUsage()) + settings.aiUsageEnabled = true + settings.aiUsageSources = [.claude] + let codex = StubQuotaProvider(results: []) + let claude = StubQuotaProvider(id: .claude, results: [.failure(.notConfigured)]) + let controller = AIUsageController(settings: settings, providers: [], quotaProviders: [codex, claude]) + + await controller.refresh() + + #expect(await codex.fetchCount() == 0) + #expect(await claude.fetchCount() == 1) + #expect(controller.visibleQuotaState(for: .codex).failure == nil) + } + + @Test func unconfiguredQuotaIsHiddenWithoutHidingLocalUsageOrOtherQuota() async { + let settings = AppSettings(defaults: defaultsForAIUsage()) + settings.aiUsageEnabled = true + let codexQuota = AIQuotaSnapshot(provider: .codex, + windows: [AIQuotaWindow(kind: .weekly, usedPercent: 61, resetsAt: nil)], fetchedAt: Date()) + let controller = AIUsageController(settings: settings, + providers: [StubUsageProvider([.success(usageSnapshot(tokens: 42))])], + quotaProviders: [ + StubQuotaProvider(results: [.success(codexQuota), .failure(.notConfigured)]), + StubQuotaProvider(id: .claude, results: [.failure(.notConfigured), .failure(.notConfigured)]), + ]) + + await controller.refresh() + #expect(controller.visibleQuotaProviders(for: nil) == [.codex]) + #expect(controller.todayTokens == 42) + + await controller.refresh() + #expect(controller.visibleQuotaProviders(for: nil).isEmpty) + #expect(controller.todayTokens == 42) + } + @Test func sourceSelectionPersistsIncludingAllDisabled() { let defaults = defaultsForAIUsage() let settings = AppSettings(defaults: defaults) diff --git a/README.en.md b/README.en.md index 8fb8233..d6f2b45 100644 --- a/README.en.md +++ b/README.en.md @@ -118,7 +118,7 @@ Choose which sections each popover shows in Settings. `Esc` closes it. 60-second history; SSD health; the apps reading and writing the most. - **GPU**, **Temperature & fans.** History, sensor groups, fan speeds and quick modes. - **Battery.** Charge level, time remaining, adapter wattage and battery temperature; a 24-hour charge curve; power draw; health and cycle count; the batteries of connected Bluetooth devices (AirPods, Magic Keyboard / Mouse / Trackpad). Macs without a battery show the Bluetooth devices only. -- **AI usage**: reads local Codex / Claude Code session logs for token totals, cache hit rate, daily trends and model ranking. Defaults to a one-year activity heatmap; switch between daily, weekly and cumulative year views and filter by model. SQLite persists parser checkpoints and statistics across restarts; appended logs are read incrementally. No credentials or quota API calls. +- **AI usage**: reads local Codex / Claude Code session logs for token totals, cache hit rate, daily trends and model ranking. Defaults to a one-year activity heatmap; switch between daily, weekly and cumulative year views and filter by model. SQLite persists parser checkpoints and statistics across restarts; appended logs are read incrementally. When enabled, it also reads local CLI login credentials and directly queries five-hour and weekly quotas.

CPU popover @@ -225,7 +225,7 @@ Every feature that touches the network can be turned off. Public IP and connecti or persist request IPs (an IP is used only for an in-memory one-minute rate limit). The original random value stays in local preferences and does not access Keychain. Turning off automatic update checks stops these automatic requests. When a new version is out it asks; nothing installs without your click. -**AI usage** is entirely local: it reads Codex / Claude Code session logs on this Mac, never reads credentials, never queries subscription quotas and makes no network requests at all. Off by default. +**AI usage** is off by default. When enabled, local token statistics read only Codex / Claude Code session logs; quota checks read CLI login credentials and contact `chatgpt.com` / `api.anthropic.com` directly for five-hour and weekly usage. Tokens go only to the corresponding provider and are not saved in XStats preferences or its statistics database. Session logs are not sent with quota requests; quota failures do not affect local statistics. WebDAV sync sends only preferences when you configure a server and start a transfer manually. It excludes monitoring data, history and WebDAV credentials. diff --git a/README.ja.md b/README.ja.md index 8243ea1..d177976 100644 --- a/README.ja.md +++ b/README.ja.md @@ -169,7 +169,7 @@ XStats の終了時やクラッシュ時には自動制御へ戻り、蓋を閉 ## データとプライバシー 指標はローカルのカーネル、IOKit、SMC から取得し、設定はアプリの UserDefaults に保存します。監視指標、履歴、ハードウェアのシリアル番号、プロセス一覧はアップロードしません。 -AI 使用統計は初期状態で無効です。ローカル Codex / Claude Code ログからモデル別 Token、キャッシュヒット率、日別推移を集計します。既定は過去1年の活動ヒートマップ。日別・週別・累計の3表示はいずれも過去1年を表示します。SQLite に読取位置と統計を保存し、再起動後も差分を読み取ります。認証情報や契約上限は読み取りません。 +AI 使用統計は初期状態で無効です。有効にすると、ローカル Codex / Claude Code ログからモデル別 Token、キャッシュヒット率、日別推移を集計し、CLI のログイン情報を読み取り専用で確認して各サービスへ直接アクセスし、5 時間・週間の利用枠も取得します。既定は過去1年の活動ヒートマップ。日別・週別・累計の3表示はいずれも過去1年を表示します。SQLite に読取位置と統計を保存し、再起動後も差分を読み取ります。ログイン情報は XStats の設定や統計 DB に保存せず、セッションログは利用枠の問い合わせに送信しません。 公開 IP は Cloudflare(失敗時は ipify)、IP 評価は cleanip.io に直接問い合わせます。接続テストは選択した対象へ ICMP ping を送ります。更新確認は現在のバージョンとランダムなインストール ID の SHA-256 を送信してバージョン情報を取得します。中国地域では `x-stats.china.12306.work`、その他では `xstats-apps.12306.work` を優先し、失敗時のみもう一方へ順番にフォールバックして、最初の成功後は停止します。サーバーはこのハッシュ、現在のバージョン、初回/最終確認時刻、確認回数のみを保存し、シリアル番号を保存せず、リクエスト IP も永続化しません(1 分間のメモリ内レート制限にのみ使用します)。自動更新確認を無効にすると自動送信も停止します。 これらは設定で無効化できます。Apple Intelligence によるプロセス説明は端末内で行います。 diff --git a/README.ko.md b/README.ko.md index 36257da..0bfcbc8 100644 --- a/README.ko.md +++ b/README.ko.md @@ -169,7 +169,7 @@ XStats를 종료하거나 앱이 비정상 종료되면 팬이 자동 모드로 ## 데이터와 개인정보 지표는 로컬 커널, IOKit, SMC에서 읽으며 설정은 앱의 UserDefaults에 저장합니다. 모니터링 지표, 기록, 하드웨어 일련번호와 프로세스 목록은 업로드하지 않습니다. -AI 사용 통계는 기본적으로 꺼져 있습니다. 로컬 Codex / Claude Code 로그로 모델별 토큰, 캐시 적중률, 일별 추이를 집계합니다. 기본값은 최근 1년 활동 히트맵입니다. SQLite에 읽기 위치와 통계를 저장하여 재시작 후에도 증분을 읽습니다. 일별·주별·누적 보기 모두 최근 1년을 표시하며 인증 정보나 구독 한도를 조회하지 않습니다. +AI 사용 통계는 기본적으로 꺼져 있습니다. 켜면 로컬 Codex / Claude Code 로그로 모델별 토큰, 캐시 적중률, 일별 추이를 집계하고, CLI 로그인 정보를 읽기 전용으로 확인하여 각 서비스에서 5시간 및 주간 사용 한도를 직접 조회합니다. 기본값은 최근 1년 활동 히트맵입니다. SQLite에 읽기 위치와 통계를 저장하여 재시작 후에도 증분을 읽습니다. 일별·주별·누적 보기 모두 최근 1년을 표시합니다. 로그인 정보는 XStats 설정이나 통계 DB에 저장하지 않으며 세션 로그를 한도 조회 요청에 보내지 않습니다. 공인 IP는 Cloudflare(실패 시 ipify), IP 평판은 cleanip.io에 직접 조회합니다. 연결 테스트는 선택한 대상으로 ICMP ping을 보냅니다. 업데이트 확인은 현재 버전과 무작위 설치 ID의 SHA-256을 보내고 버전 정보를 읽습니다. 중국 지역은 `x-stats.china.12306.work`, 그 외 지역은 `xstats-apps.12306.work`를 우선 사용하며, 실패한 경우에만 다른 엔드포인트로 순차 전환하고 첫 성공 후 중지합니다. 서버에는 해당 해시, 현재 버전, 최초·최근 확인 시각과 확인 횟수만 저장하며, 하드웨어 일련번호를 저장하지 않고 요청 IP도 영구 저장하지 않습니다(1분 동안의 메모리 내 속도 제한에만 사용). 자동 업데이트 확인을 끄면 자동 요청도 중지됩니다. 이 기능들은 설정에서 끌 수 있습니다. Apple Intelligence 프로세스 설명은 기기 안에서 처리됩니다. diff --git a/README.md b/README.md index 6adc3b4..550fd15 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登 - **磁盘**:启动磁盘容量分段条(已用、可清除、可用);读写速度与 60 秒走势;SSD 健康;读写最多的应用。 - **GPU**、**温度与风扇**:使用历史、各组温度、风扇转速与快捷模式。 - **电池**:电量与剩余 / 充满时间、适配器功率与电池温度;最近 24 小时电量曲线;功耗;健康度与循环次数;已连接蓝牙设备的电量(AirPods、妙控键盘 / 鼠标 / 触控板)。没有电池的 Mac 只显示蓝牙设备。 -- **AI 使用统计**:只读本机 Codex / Claude Code 会话日志,按模型统计输入、输出、缓存 Token;默认显示近一年活动热力图,提供每日、每周、累计三种全年活动视图及模型筛选;SQLite 保存扫描游标和统计结果,重启可复用,日志追加时只读新增部分。无需登录凭据或配额接口。 +- **AI 使用统计**:只读本机 Codex / Claude Code 会话日志,按模型统计输入、输出、缓存 Token;默认显示近一年活动热力图,提供每日、每周、累计三种全年活动视图及模型筛选;SQLite 保存扫描游标和统计结果,重启可复用,日志追加时只读新增部分。启用后还会自动使用本机 CLI 登录凭据直接查询两家服务的 5 小时及每周额度。

CPU 详情弹窗 @@ -227,7 +227,7 @@ XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登 - **连接探测**:打开网络详情时每秒(后台每 10 秒)向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。 - **检查更新与安装统计**:启动时与之后每天发送当前版本和随机安装标识的 SHA-256,并读取版本清单。中国地区优先使用 `x-stats.china.12306.work`,其他地区优先使用 `xstats-apps.12306.work`,首选失败才串行回退到另一个,首次成功后停止。服务端只保存该哈希、当前版本、首次与最近检查时间及检查次数,不保存硬件序列号,也不持久化请求 IP(IP 只用于一分钟内的内存限流)。原始随机值只在本机偏好设置中,不访问钥匙串。关闭自动检查更新后不会再自动请求;发现新版本时仍由你决定是否安装。 -**AI 使用统计**完全在本机完成:只读取 Codex / Claude Code 的本机会话日志,不读取登录凭据、不查询订阅配额,也不发送任何网络请求。默认关闭。 +**AI 使用统计**默认关闭。启用后,本机 Token 统计只读取 Codex / Claude Code 会话日志;额度部分会只读检查 CLI 登录凭据,直接向 `chatgpt.com` / `api.anthropic.com` 查询 5 小时及每周用量。登录令牌只发送到对应服务,不保存到 XStats 设置或统计数据库,日志内容不随请求发送。额度接口可能变化或限流,失败时不影响本机统计。 WebDAV 仅在你自行配置服务器并手动同步时传输偏好设置,不上传监控数据、历史记录或 WebDAV 凭据。