diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9c7115e..9c09ecc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -279,7 +279,7 @@ Local logs cannot reliably identify the paying account or usage on other devices prices may estimate token costs, explicitly excluding tiered surcharges and subscription billing. Subscription limits and HTTP success rates are not inferred from local logs. The separate quota snapshot prefers the installed Codex CLI's local app-server `account/rateLimits/read`; the CLI -owns token refresh and XStats receives only rate-limit data. If that path is unavailable, XStats +owns token refresh and XStats receives quota and read-only subscription/reset-credit metadata. If that path is unavailable, XStats queries Codex `wham/usage` using its cached CLI OAuth token. Claude uses its CLI OAuth token for `api/oauth/usage`. It maps five-hour and seven-day windows, plus Claude's model-specific weekly windows, without combining them with local token totals. Direct credential discovery is repeated @@ -291,6 +291,15 @@ The controller restores it before the first network request and labels it with i Requests use isolated URL sessions and reject redirects so bearer tokens cannot be forwarded. Codex app-server uses bounded stdio JSONL and falls back to direct HTTP if unavailable. An HTML 403 from an intermediary is a transient network failure, not proof of expired login. +Codex quota snapshots retain optional plan type, subscription validity, reset-credit count and earliest +available expiry. CLI camelCase and direct HTTP snake_case fields are normalized into the same summary. +Local ID-token subscription dates are accepted only for matching account IDs, and never inferred from +JWT expiry. Valid start/end claims supply a remaining-time progress bar; without a valid start only +the expiry date is shown, never an assumed 30-day cycle. This bar uses existing page/usage refreshes +and adds no timer. Reset-card count and earliest expiry sit next to the provider/plan heading. Old CLIs or missing claims leave the date absent. The direct fallback reads reset-credit +details only when the known count is positive, with a four-second timeout and no retry; an auxiliary +failure preserves the quota/count. No redeem/consume operation is implemented. Persisted metadata +contains no account identifier, token or reset-credit IDs; legacy quota snapshots still decode. The existing per-provider Sub2API option is tried only after both automatic Codex paths fail. Missing or expired credentials clear that provider's quota display; transient errors keep the last successful result marked stale across restarts. Authentication failures or removal of a manual @@ -328,15 +337,16 @@ exact hover/VoiceOver values. Cache details are disclosed on demand. AI usage settings open in a separate window so users can copy account details from another app without closing the form. Unsaved Sub2API fields stay in memory for the current app session across page changes; passwords are not written to preferences. -The activity selector offers Today, 7 days, 30 days and Cumulative. Today shows hourly bars; +The main-window activity selector offers Today, 7 days, 30 days and Cumulative; menu-bar popovers +only offer the first three and select at most 30 days for charts. Today shows hourly bars; the trailing 7/30 local-calendar days (including today) show daily bars. Cumulative shows a daily heatmap over the retained 365-day window. Summary, cost estimate and model ranking use the same source/model filters and time window as the chart. Hour buckets span actual 60-minute intervals from local midnight, with a shorter final bucket on fractional DST days, so parser and chart agree even across half-hour daylight-saving transitions. Missing legacy hourly details are reported, never assigned to midnight. Empty buckets remain zero and do not add to the totals. The heatmap legend explicitly labels the daily peak, separately from the cumulative summary. -Pointer hover and accessibility expose exact bucket counts. Compact popovers scroll the year grid -horizontally, initially at the recent end; the wide panel shrinks cells to fit. Charts have no timers +Pointer hover and accessibility expose exact bucket counts. The main-window year grid shrinks cells +to fit the available width. Charts have no timers or continuous animation and use existing usage-refresh updates. Numbers and dates follow the app language rather than the system locale. Pointer selection fades only the selected control background (180 ms); keyboard selection and diff --git a/Packages/XStatsKit/Sources/AIUsage/AIQuotaDetails.swift b/Packages/XStatsKit/Sources/AIUsage/AIQuotaDetails.swift new file mode 100644 index 0000000..5e65355 --- /dev/null +++ b/Packages/XStatsKit/Sources/AIUsage/AIQuotaDetails.swift @@ -0,0 +1,62 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import CoreFoundation +import Foundation + +/// 套餐与重置券的只读摘要;不保存账号、令牌、券 ID 或可执行的重置操作。 +public struct AIQuotaDetails: Codable, Equatable, Sendable { + public var plan: String? + public var subscriptionValidFrom: Date? + public var subscriptionValidUntil: Date? + public var resetCredits: Int? + public var resetCreditsExpireAt: Date? + + public init(plan: String? = nil, subscriptionValidUntil: Date? = nil, + resetCredits: Int? = nil, resetCreditsExpireAt: Date? = nil, subscriptionValidFrom: Date? = nil) { + self.plan = plan + self.subscriptionValidFrom = subscriptionValidFrom + self.subscriptionValidUntil = subscriptionValidUntil + self.resetCredits = resetCredits + self.resetCreditsExpireAt = resetCreditsExpireAt + } + + /// 使用真实起止时间计算剩余有效期;缺少起点时不假定月付或 30 天周期。 + public func subscriptionRemainingFraction(at now: Date) -> Double? { + guard let start = subscriptionValidFrom, let end = subscriptionValidUntil, + start.timeIntervalSince1970.isFinite, end.timeIntervalSince1970.isFinite, + now.timeIntervalSince1970.isFinite, end > start else { return nil } + let duration = end.timeIntervalSince(start) + let remaining = end.timeIntervalSince(now) + guard duration.isFinite, remaining.isFinite else { return nil } + return min(1, max(0, remaining / duration)) + } + + static func parse(plan: Any?, resets: [String: Any]?, appServer: Bool, now: Date) -> Self { + let plan = (plan as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + var value = Self(plan: plan.flatMap { !$0.isEmpty && $0.count <= 64 && $0 != "unknown" ? $0 : nil }) + value.resetCredits = count(resets?[appServer ? "availableCount" : "available_count"]) + guard let count = value.resetCredits, count > 0, + let credits = resets?["credits"] as? [[String: Any]] else { return value } + let iso = ISO8601Parser() + value.resetCreditsExpireAt = credits.lazy.filter { $0["status"] as? String == "available" }.compactMap { credit in + let date: Date? + if appServer, let number = credit["expiresAt"] as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), number.doubleValue.isFinite, + number.doubleValue > 0, number.doubleValue < 253_402_300_800 { + date = Date(timeIntervalSince1970: number.doubleValue) + } else { + date = iso.date(credit["expires_at"]) + } + return date.flatMap { $0 > now ? $0 : nil } + }.min() + return value + } + + private static func count(_ value: Any?) -> Int? { + guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.isFinite, number.doubleValue >= 0, number.doubleValue <= 1_000_000, + number.doubleValue.rounded(.towardZero) == number.doubleValue else { return nil } + return number.intValue + } +} diff --git a/Packages/XStatsKit/Sources/AIUsage/CodexAppServerQuotaClient.swift b/Packages/XStatsKit/Sources/AIUsage/CodexAppServerQuotaClient.swift index 7ef611f..af0ad37 100644 --- a/Packages/XStatsKit/Sources/AIUsage/CodexAppServerQuotaClient.swift +++ b/Packages/XStatsKit/Sources/AIUsage/CodexAppServerQuotaClient.swift @@ -22,10 +22,11 @@ enum CodexAppServerQuotaClient { } onCancel: { cancelled.set() } - return try parse(response, now: Date()) + // CLI 返回的账号 ID 与本机 ID token 一致时才补套餐期限,不能混合不同账号的信息。 + return try parse(response, now: Date(), credentials: try? CodexQuotaCredentials.load()) } - static func parse(_ data: Data, now: Date) throws -> AIQuotaSnapshot { + static func parse(_ data: Data, now: Date, credentials: CodexQuotaCredentials? = nil) throws -> AIQuotaSnapshot { guard let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let result = message["result"] as? [String: Any] else { throw AIQuotaFailure.invalidResponse } let buckets = result["rateLimitsByLimitId"] as? [String: Any] @@ -55,7 +56,14 @@ enum CodexAppServerQuotaClient { } let windows = [AIQuotaKind.session, .weekly].compactMap { selected[$0]?.window } guard !windows.isEmpty else { throw AIQuotaFailure.invalidResponse } - return AIQuotaSnapshot(provider: .codex, windows: windows, fetchedAt: now) + var details = AIQuotaDetails.parse(plan: limits["planType"], + resets: result["rateLimitResetCredits"] as? [String: Any], appServer: true, now: now) + if let accountID = result["accountId"] as? String, !accountID.isEmpty, + accountID == credentials?.accountID { + details.subscriptionValidUntil = credentials?.subscriptionValidUntil + details.subscriptionValidFrom = credentials?.subscriptionValidFrom + } + return AIQuotaSnapshot(provider: .codex, windows: windows, fetchedAt: now, details: details) } private static func executableURL() -> URL? { diff --git a/Packages/XStatsKit/Sources/AIUsage/QuotaProviders.swift b/Packages/XStatsKit/Sources/AIUsage/QuotaProviders.swift index d965c56..c5b4e2e 100644 --- a/Packages/XStatsKit/Sources/AIUsage/QuotaProviders.swift +++ b/Packages/XStatsKit/Sources/AIUsage/QuotaProviders.swift @@ -31,13 +31,15 @@ public struct AIQuotaSnapshot: Codable, Equatable, Sendable { public let windows: [AIQuotaWindow] public let fetchedAt: Date public let source: AIQuotaSource + public var details: AIQuotaDetails? public init(provider: AIProviderID, windows: [AIQuotaWindow], fetchedAt: Date, - source: AIQuotaSource = .direct) { + source: AIQuotaSource = .direct, details: AIQuotaDetails? = nil) { self.provider = provider self.windows = windows self.fetchedAt = fetchedAt self.source = source + self.details = details } public func window(_ kind: AIQuotaKind) -> AIQuotaWindow? { windows.first { $0.kind == kind } } @@ -98,8 +100,15 @@ private final class RejectQuotaRedirects: NSObject, URLSessionTaskDelegate, Send struct CodexQuotaCredentials: Sendable { let token: String let accountID: String? + let subscriptionValidFrom: Date? + let subscriptionValidUntil: Date? - static func parse(_ data: Data) throws -> Self { + init(token: String, accountID: String?, subscriptionValidUntil: Date? = nil, subscriptionValidFrom: Date? = nil) { + self.token = token; self.accountID = accountID; self.subscriptionValidUntil = subscriptionValidUntil + self.subscriptionValidFrom = subscriptionValidFrom + } + + static func parse(_ data: Data, now: Date = Date()) throws -> Self { guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw AIQuotaFailure.notConfigured } @@ -108,7 +117,28 @@ struct CodexQuotaCredentials: Sendable { let token = tokens["access_token"] as? String, !token.isEmpty else { throw AIQuotaFailure.notConfigured } - return Self(token: token, accountID: tokens["account_id"] as? String) + let accountID = tokens["account_id"] as? String + var until: Date? + var start: Date? + // ID token 仅用于显示已付款期限;不把 JWT 的 exp 当作订阅到期日,也不用于授权。 + if let jwt = tokens["id_token"] as? String, jwt.utf8.count <= 131_072 { + let parts = jwt.split(separator: ".", omittingEmptySubsequences: false) + if parts.count == 3 { + var encoded = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4) + if let payload = Data(base64Encoded: encoded), + let claims = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], + let auth = claims["https://api.openai.com/auth"] as? [String: Any], + let accountID, !accountID.isEmpty, auth["chatgpt_account_id"] as? String == accountID, + let date = ISO8601Parser().date(auth["chatgpt_subscription_active_until"]), date > now { + until = date + if let begin = ISO8601Parser().date(auth["chatgpt_subscription_active_start"]), begin < date, begin <= now { + start = begin + } + } + } + } + return Self(token: token, accountID: accountID, subscriptionValidUntil: until, subscriptionValidFrom: start) } static func load(environment: [String: String] = ProcessInfo.processInfo.environment, @@ -121,7 +151,11 @@ struct CodexQuotaCredentials: Sendable { 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 } + guard let file = try? FileHandle(forReadingFrom: url) else { throw AIQuotaFailure.notConfigured } + defer { try? file.close() } + guard let data = try? file.read(upToCount: 1_048_577), data.count <= 1_048_576 else { + throw AIQuotaFailure.notConfigured + } return try parse(data) } throw AIQuotaFailure.notConfigured @@ -223,7 +257,31 @@ public actor CodexQuotaProvider: AIQuotaProvider { 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()) + var snapshot = try Self.parse(data, now: Date()) + snapshot.details?.subscriptionValidUntil = credential.subscriptionValidUntil + snapshot.details?.subscriptionValidFrom = credential.subscriptionValidFrom + if (snapshot.details?.resetCredits ?? 0) > 0 { + // 附加信息失败不能隐藏已取得的额度;只读查询有单独的短超时,不做重试。 + request.url = URL(string: "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits")! + request.timeoutInterval = 4 + do { + try Task.checkCancellation() + let response = try await quotaResponse(for: request, using: http) + try Task.checkCancellation() + if response.count <= 1_048_576, + let object = try? JSONSerialization.jsonObject(with: response) as? [String: Any] { + let details = AIQuotaDetails.parse(plan: nil, resets: object, appServer: false, now: Date()) + if let count = details.resetCredits { + snapshot.details?.resetCredits = count + snapshot.details?.resetCreditsExpireAt = details.resetCreditsExpireAt + } + } + } catch { + try Task.checkCancellation() + if error is CancellationError { throw error } + } + } + return snapshot } static func parse(_ data: Data, now: Date) throws -> AIQuotaSnapshot { @@ -251,7 +309,9 @@ public actor CodexQuotaProvider: AIQuotaProvider { } let windows = [AIQuotaKind.session, .weekly].compactMap { selected[$0]?.window } guard !windows.isEmpty else { throw AIQuotaFailure.invalidResponse } - return AIQuotaSnapshot(provider: .codex, windows: windows, fetchedAt: now) + return AIQuotaSnapshot(provider: .codex, windows: windows, fetchedAt: now, + details: AIQuotaDetails.parse(plan: object["plan_type"], + resets: object["rate_limit_reset_credits"] as? [String: Any], appServer: false, now: now)) } } diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index e0e57e6..46a30c7 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1639,3 +1639,13 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 است 按天统计 الاستخدام اليومي 单日最高:{} Tokens أعلى استخدام يومي: {} Tokens 暂无小时明细,请刷新用量。 تفاصيل الساعات غير متاحة. حدّث الاستخدام. +套餐:{} الخطة: {} +套餐有效期至:{} الخطة صالحة حتى: {} +可用重置次数:{} عمليات إعادة الضبط المتاحة: {} +重置次数最早到期:{} أقرب انتهاء لرصيد إعادة الضبط: {} +可手动恢复额度的剩余次数,不是已重置次数。 عدد مرات إعادة ضبط حدود الاستخدام يدويًا المتبقية، وليس عدد مرات إعادة الضبط السابقة. +套餐有效期 صلاحية الخطة +最早到期:{} أقرب انتهاء: {} +重置卡:{} رصيد إعادة الضبط: {} +套餐剩余时间 الوقت المتبقي للخطة +Token 用量 الرموز diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index 662c830..d3e8b11 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -1639,3 +1639,13 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 KI-Nutz 按天统计 Tägliche Nutzung 单日最高:{} Tokens Tagesmaximum: {} Tokens 暂无小时明细,请刷新用量。 Keine Stundendaten verfügbar. Nutzung aktualisieren. +套餐:{} Tarif: {} +套餐有效期至:{} Tarif gültig bis: {} +可用重置次数:{} Verfügbare Zurücksetzungen: {} +重置次数最早到期:{} Frühester Ablauf eines Reset-Guthabens: {} +可手动恢复额度的剩余次数,不是已重置次数。 Verbleibende Guthaben zum manuellen Zurücksetzen der Nutzungslimits, nicht die Anzahl bisheriger Zurücksetzungen. +套餐有效期 Tarifgültigkeit +最早到期:{} Frühester Ablauf: {} +重置卡:{} Reset-Guthaben: {} +套餐剩余时间 Verbleibende Tariflaufzeit +Token 用量 Tokens diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index f364281..ca6c183 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -1639,3 +1639,13 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 El uso 按天统计 Uso diario 单日最高:{} Tokens Máximo diario: {} Tokens 暂无小时明细,请刷新用量。 No hay detalles por hora. Actualiza el uso. +套餐:{} Plan: {} +套餐有效期至:{} Plan válido hasta: {} +可用重置次数:{} Restablecimientos disponibles: {} +重置次数最早到期:{} Primer vencimiento de un crédito de restablecimiento: {} +可手动恢复额度的剩余次数,不是已重置次数。 Créditos restantes para restablecer manualmente los límites de uso, no el número de restablecimientos anteriores. +套餐有效期 Vigencia del plan +最早到期:{} Primer vencimiento: {} +重置卡:{} Créditos de restablecimiento: {} +套餐剩余时间 Tiempo restante del plan +Token 用量 Tokens diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 8c6ef71..35017ee 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -1639,3 +1639,13 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 L’uti 按天统计 Utilisation quotidienne 单日最高:{} Tokens Maximum quotidien : {} Tokens 暂无小时明细,请刷新用量。 Détails horaires indisponibles. Actualisez l’utilisation. +套餐:{} Abonnement : {} +套餐有效期至:{} Abonnement valable jusqu’au : {} +可用重置次数:{} Réinitialisations disponibles : {} +重置次数最早到期:{} Première expiration d’un crédit de réinitialisation : {} +可手动恢复额度的剩余次数,不是已重置次数。 Crédits restants pour réinitialiser manuellement les limites, et non le nombre de réinitialisations passées. +套餐有效期 Validité du forfait +最早到期:{} Première expiration : {} +重置卡:{} Crédits de réinitialisation : {} +套餐剩余时间 Durée restante du forfait +Token 用量 Tokens diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 05b0b2a..bbc6aba 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -1639,3 +1639,13 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 使 按天统计 日別の使用量 单日最高:{} Tokens 1 日の最大:{} Tokens 暂无小时明细,请刷新用量。 時間別の詳細がありません。使用量を更新してください。 +套餐:{} プラン:{} +套餐有效期至:{} プラン有効期限:{} +可用重置次数:{} 利用可能なリセット:{} +重置次数最早到期:{} リセット権の最短有効期限:{} +可手动恢复额度的剩余次数,不是已重置次数。 使用量の上限を手動でリセットできる残り回数です。過去のリセット回数ではありません。 +套餐有效期 プランの有効期限 +最早到期:{} 最短有効期限:{} +重置卡:{} リセット権:{} +套餐剩余时间 プランの残り時間 +Token 用量 トークン diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 12c540e..762a4b7 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -1639,3 +1639,13 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 사 按天统计 일별 사용량 单日最高:{} Tokens 일일 최대: {} Tokens 暂无小时明细,请刷新用量。 시간별 세부 정보가 없습니다. 사용량을 새로 고치세요. +套餐:{} 요금제: {} +套餐有效期至:{} 요금제 유효 기한: {} +可用重置次数:{} 사용 가능한 초기화: {} +重置次数最早到期:{} 초기화 크레딧의 가장 이른 만료: {} +可手动恢复额度的剩余次数,不是已重置次数。 사용 한도를 수동으로 초기화할 수 있는 남은 횟수이며, 이미 초기화한 횟수가 아닙니다. +套餐有效期 요금제 유효 기한 +最早到期:{} 가장 이른 만료: {} +重置卡:{} 초기화 크레딧: {} +套餐剩余时间 요금제 남은 시간 +Token 用量 토큰 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 23fd9ba..d7e88ee 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -1640,3 +1640,13 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 用 按天统计 按天統計 单日最高:{} Tokens 單日最高:{} Tokens 暂无小时明细,请刷新用量。 暫無小時明細,請重新整理用量。 +套餐:{} 方案:{} +套餐有效期至:{} 方案有效期至:{} +可用重置次数:{} 可用重設次數:{} +重置次数最早到期:{} 重設次數最早到期:{} +可手动恢复额度的剩余次数,不是已重置次数。 可手動恢復額度的剩餘次數,不是已重設次數。 +套餐有效期 方案有效期 +最早到期:{} 最早到期:{} +重置卡:{} 重設卡:{} +套餐剩余时间 方案剩餘時間 +Token 用量 Token 用量 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index d254e57..08ea319 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -79,6 +79,16 @@ Token 活动 Token activity 累计 Cumulative 缓存创建 Token Cache creation tokens AI 用量 AI Usage +Token 用量 Tokens +重置卡:{} Reset credits: {} +套餐剩余时间 Plan time remaining +套餐有效期 Plan validity +最早到期:{} Earliest expiry: {} +套餐:{} Plan: {} +套餐有效期至:{} Plan valid until: {} +可用重置次数:{} Available resets: {} +重置次数最早到期:{} Earliest reset credit expiry: {} +可手动恢复额度的剩余次数,不是已重置次数。 Remaining credits for manually resetting usage limits, not the number of past resets. 30 天 30 days 按小时统计 Hourly usage 按天统计 Daily usage diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift index 6ecc5e7..16da9ef 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift @@ -60,9 +60,12 @@ private struct LocalUsageContent: View { VStack(alignment: .leading, spacing: compact ? DS.Space.s3 : DS.Space.s4) { toolbar if model.settings.aiUsageEnabled && !enabledProviders.isEmpty { - Text(sourceTitle) - .dsFont(.sm, weight: .semibold) - .foregroundStyle(DS.Palette.textPrimary) + if let provider { + QuotaProviderHeading(name: sourceTitle, + details: model.aiUsage.visibleQuotaState(for: provider).snapshot?.details, titleSize: .sm) + } else { + Text(sourceTitle).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary) + } } if !model.aiUsage.visibleQuotaProviders(for: provider).isEmpty { quotaSection @@ -85,7 +88,7 @@ private struct LocalUsageContent: View { } else if model.settings.aiUsageShowsLocalUsage { if let report = model.aiUsage.localReport(for: provider) { let rows = report.summary(mode: activityMode, model: selectedModel.isEmpty ? nil : selectedModel, now: now) - let activityRows = report.selected(days: 365, model: selectedModel.isEmpty ? nil : selectedModel, now: now) + let activityRows = report.selected(days: compact ? 30 : 365, model: selectedModel.isEmpty ? nil : selectedModel, now: now) let total = LocalUsageReport.total(rows) UsageSummary(total: total, compact: compact, title: selectedModel.isEmpty ? tr("本地用量") : selectedModel, @@ -159,7 +162,7 @@ private struct LocalUsageContent: View { VStack(alignment: .leading, spacing: DS.Space.s3) { if provider == nil { HStack { - Text(id == .codex ? "Codex" : "Claude Code").dsFont(.xs, weight: .semibold) + QuotaProviderHeading(name: id == .codex ? "Codex" : "Claude Code", details: state.snapshot?.details) Spacer() if state.snapshot?.source == .sub2api { Text("Sub2API").dsFont(.xs, weight: .medium) @@ -168,6 +171,9 @@ private struct LocalUsageContent: View { } } if let snapshot = state.snapshot { + if let details = snapshot.details, details.subscriptionValidUntil != nil { + QuotaValidityView(details: details, now: Date()) + } ForEach(snapshot.windows) { window in VStack(alignment: .leading, spacing: DS.Space.s1) { HStack(alignment: .firstTextBaseline) { @@ -350,11 +356,93 @@ private struct LocalUsageContent: View { } } +/// 标题与重置卡属于同一账号;空间不足时分层换行,保留长译文。 +private struct QuotaProviderHeading: View { + let name: String + let details: AIQuotaDetails? + var titleSize: DS.TextSize = .xs + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: DS.Space.s2) { identity; resetCount; expiry } + VStack(alignment: .leading, spacing: DS.Space.s1) { + HStack(spacing: DS.Space.s2) { identity; resetCount } + expiry + } + VStack(alignment: .leading, spacing: DS.Space.s1) { identity; resetCount; expiry } + } + } + + private var identity: some View { + HStack(spacing: DS.Space.s2) { + Text(name).dsFont(titleSize, weight: .semibold).foregroundStyle(DS.Palette.textPrimary) + if let plan = details?.plan { + TagBadge(text: plan, tone: .primary, compact: true).accessibilityLabel(tr("套餐:\(plan)")) + } + }.fixedSize(horizontal: true, vertical: false) + } + + @ViewBuilder private var resetCount: some View { + if let count = details?.resetCredits { + Label(tr("重置卡:\(count.formatted(.number.locale(L10n.locale)))"), systemImage: "arrow.counterclockwise") + .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityElement(children: .ignore) + .help(tr("可手动恢复额度的剩余次数,不是已重置次数。")) + .accessibilityLabel(tr("可用重置次数:\(count.formatted(.number.locale(L10n.locale)))")) + } + } + + @ViewBuilder private var expiry: some View { + if (details?.resetCredits ?? 0) > 0, let date = details?.resetCreditsExpireAt { + Text(tr("最早到期:\(UsageNumber.quotaDate(date, includesTime: true))")) + .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .help(tr("重置次数最早到期:\(date.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))")) + .accessibilityLabel(tr("重置次数最早到期:\(date.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))")) + } + } +} + +/// 有真实起止时间才显示剩余时间比例;沿用页面刷新,不额外注册计时器。 +private struct QuotaValidityView: View { + let details: AIQuotaDetails + let now: Date + + var body: some View { + if let until = details.subscriptionValidUntil { + VStack(alignment: .leading, spacing: DS.Space.s1) { + HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) { + Label(tr("套餐有效期"), systemImage: "calendar") + Spacer(minLength: DS.Space.s1) + Text(UsageNumber.quotaDate(until, includesTime: true, now: now)) + .multilineTextAlignment(.trailing) + } + .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + .help(tr("套餐有效期至:\(date(until))")) + .accessibilityElement(children: .ignore) + .accessibilityLabel(tr("套餐有效期至:\(date(until))")) + if let fraction = details.subscriptionRemainingFraction(at: now), let start = details.subscriptionValidFrom { + ProgressTrack(fraction: fraction, color: DS.Palette.secondary, height: DS.Space.s1) + .help(date(start) + " – " + date(until)) + .accessibilityLabel(tr("套餐剩余时间")) + .accessibilityValue(fraction.formatted(.percent.precision(.fractionLength(0)).locale(L10n.locale))) + } + } + } + } + + private func date(_ value: Date) -> String { + value.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)) + } +} + /// 鼠标选择只对选中底色作短暂淡入;键盘选择即时响应,不动画数据、图表或布局。 private struct UsageChoices: View { @Binding var selection: Value let options: [(Value, String)] let label: String + var equalWidth = true @State private var pointerSelection = false @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -369,7 +457,8 @@ private struct UsageChoices: View { .lineLimit(1) .minimumScaleFactor(0.8) .foregroundStyle(selection == value ? DS.Palette.textPrimary : DS.Palette.textSecondary) - .frame(maxWidth: .infinity) + .frame(minWidth: equalWidth ? nil : 36, maxWidth: equalWidth ? .infinity : nil) + .padding(.horizontal, equalWidth ? 0 : DS.Space.s1) .padding(.vertical, DS.Space.s2) .background { RoundedRectangle(cornerRadius: DS.Radius.md) @@ -417,6 +506,16 @@ enum UsageNumber { date.formatted(.dateTime.year().month().day().locale(locale)) } + /// 同年省略年份,跨年保留;判断与显示使用同一个日历、时区,避免年末边界不一致。 + static func quotaDate(_ date: Date, includesTime: Bool, now: Date = Date(), + locale: Locale = L10n.locale, calendar: Calendar = .current) -> String { + var style = Date.FormatStyle(date: .omitted, time: .omitted, locale: locale, + calendar: calendar, timeZone: calendar.timeZone).month(.abbreviated).day() + if !calendar.isDate(date, equalTo: now, toGranularity: .year) { style = style.year() } + if includesTime { style = style.hour().minute() } + return date.formatted(style) + } + static func short(_ value: Int, locale: Locale = L10n.locale, westernUnits: Bool = false) -> String { AIUsageFormat.tokens(value, westernUnits: westernUnits, locale: locale) } @@ -579,11 +678,12 @@ private struct UsageActivityPanel: View { var body: some View { Card(spacing: DS.Space.s2) { HStack(spacing: DS.Space.s2) { - Text(tr("Token 活动")).dsFont(.sm, weight: .semibold) - Spacer(minLength: DS.Space.s2) - if !compact { choices.frame(width: 280) } + Text(tr(compact ? "Token 用量" : "Token 活动")).dsFont(.sm, weight: .semibold) + .lineLimit(1).minimumScaleFactor(0.8) + Spacer(minLength: DS.Space.s1) + if compact { choices.fixedSize(horizontal: true, vertical: false) } + else { choices.frame(width: 280) } } - if compact { choices } Text(hovered ?? tr(mode == .daily ? "按小时统计" : "按天统计")) .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) .lineLimit(1).minimumScaleFactor(0.75) @@ -591,14 +691,7 @@ private struct UsageActivityPanel: View { .frame(height: DS.Space.s6) if mode == .cumulative { let activity = UsageActivity(rows: rows, now: now) - if compact { - ScrollView(.horizontal) { - ActivityGrid(activity: activity, hovered: $hovered, side: DS.Space.s3) - .frame(height: gridHeight) - }.defaultScrollAnchor(.trailing) - } else { - ActivityGrid(activity: activity, hovered: $hovered).frame(height: gridHeight) - } + ActivityGrid(activity: activity, hovered: $hovered).frame(height: gridHeight) Text(UsageNumber.day(activity.start) + " – " + UsageNumber.day(activity.end)) .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) HStack { @@ -627,9 +720,9 @@ private struct UsageActivityPanel: View { } private var choices: some View { - UsageChoices(selection: $mode, options: [(.daily, tr("今天")), (.weekly, tr("7 天")), - (.monthly, tr("30 天")), (.cumulative, tr("累计"))], - label: tr("Token 活动")) + let periods: [(UsageActivityMode, String)] = [(.daily, tr("今天")), (.weekly, tr("7 天")), (.monthly, tr("30 天"))] + return UsageChoices(selection: $mode, options: compact ? periods : periods + [(.cumulative, tr("累计"))], + label: tr("Token 活动"), equalWidth: !compact) } private var gridHeight: CGFloat { 7 * DS.Space.s3 + 6 * (DS.Space.s1 / 2) + DS.Space.s6 } } @@ -676,7 +769,8 @@ private struct UsageBarChart: View { } }.frame(height: 140) GeometryReader { geometry in - let indices = buckets.count == 7 ? [0, 2, 4, 6] + let indices = geometry.size.width < 240 ? [0, buckets.count / 2, max(0, buckets.count - 1)] + : buckets.count == 7 ? [0, 2, 4, 6] : Array(Set([0, buckets.count / 4, buckets.count / 2, buckets.count * 3 / 4, max(0, buckets.count - 1)])).sorted() ForEach(indices, id: \.self) { index in if buckets.indices.contains(index) { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md index 1aed4c0..ee77f08 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md @@ -10,7 +10,7 @@ CPU, GPU, memory, network, temperature, fan, and process metrics come from local Calendar events and due reminders are off by default. After you explicitly grant access, XStats reads the visible month range from your selected lists through macOS calendar APIs while the panel is open. It displays the contents locally without creating, modifying, deleting, or uploading records, and does not include them in settings backups or widget summaries. Closing the panel clears its displayed list. List selections stay on this Mac. You can turn reading off in calendar settings or revoke access in System Settings. -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 subscription quotas. Codex first uses the installed local Codex CLI App Server, which maintains its own login; if unavailable, XStats reads cached CLI credentials and queries the quota service directly. Claude Code uses its local CLI credentials for direct quota requests. Requests send the login token to the 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. +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 subscription quotas. Codex first uses the installed local Codex CLI App Server, which maintains its own login; if unavailable, XStats uses cached CLI credentials to query the quota service directly. XStats also reads the local ID token’s subscription validity date and displays it only when the account matches. Claude Code uses its local CLI credentials for direct quota requests. Requests send the login token to the 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. If you manually configure Sub2API as a backup quota source for Codex or Claude Code, the app contacts the HTTPS server specified for that source only when its automatic quota lookup fails. The two configurations are stored separately, and XStats verifies the queried account's platform. Your admin email and password are sent to the corresponding server to sign in. Passwords are stored separately in this Mac's Keychain; addresses, emails, and account IDs stay in local preferences. Background quota requests use `force=false` and do not trigger active probes. Removing one source's configuration deletes its locally stored Sub2API password and connection details. 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. @@ -25,8 +25,9 @@ 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. +- 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, plan type and validity date, available reset count and earliest expiry, source, and fetch time, but no login token, account identifier, or reset-credit ID. 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. +- The Codex CLI can return reset counts and expiry details directly. When direct quota access is used as a fallback, XStats makes an additional read-only request to `chatgpt.com/backend-api/wham/rate-limit-reset-credits` only if resets are available. Failure preserves the quota and count already obtained. XStats does not redeem resets or consume them automatically. - 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. 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 f727106..921a340 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md @@ -10,7 +10,7 @@ CPU、GPU、内存、网络、温度、风扇、进程等监控数据来自本 日历中的系统日程与到期提醒默认关闭。你主动授权后,XStats 通过 macOS 日历接口在面板打开时读取所选列表的当前月历范围,仅在本机展示,不创建、修改或删除记录,不上传内容,也不将其写入设置备份或小组件摘要。关闭面板后清空展示列表;列表选择保存在本机,可在日历设置中关闭读取,也可在系统设置中撤销权限。 -“AI 用量”默认关闭;开启后读取本机 Codex / Claude Code 会话日志,按模型汇总 Token 用量,并查询相应的订阅额度。Codex 优先通过本机已安装的 Codex CLI App Server 读取额度,由 CLI 自行维护登录;若该方式不可用,才读取本机 CLI 登录凭据并直连额度接口。Claude Code 使用本机 CLI 凭据直连。查询请求会向对应服务发送登录令牌;令牌不写入 XStats 设置或统计数据库,日志内容不会随额度请求发送。接口可能变化或限流,额度查询失败不影响本机 Token 统计。关闭此功能后停止后续扫描和额度查询。Apple 智能进程解释在本机运行,不把进程信息发送给其他 AI 服务。 +“AI 用量”默认关闭;开启后读取本机 Codex / Claude Code 会话日志,按模型汇总 Token 用量,并查询相应的订阅额度。Codex 优先通过本机已安装的 Codex CLI App Server 读取额度,由 CLI 自行维护登录;若该方式不可用,则使用本机 CLI 登录凭据直连额度接口。应用也会读取本机 ID token 中的套餐有效期,仅在账号匹配时展示。Claude Code 使用本机 CLI 凭据直连。查询请求会向对应服务发送登录令牌;令牌不写入 XStats 设置或统计数据库,日志内容不会随额度请求发送。接口可能变化或限流,额度查询失败不影响本机 Token 统计。关闭此功能后停止后续扫描和额度查询。Apple 智能进程解释在本机运行,不把进程信息发送给其他 AI 服务。 如果你为 Codex 或 Claude Code 手动配置 Sub2API 额度备选来源,只有对应来源的自动额度查询失败时,应用才会连接你为它填写的 HTTPS 服务器。两份配置分别保存,并核对查询账号的平台。管理员邮箱与密码在登录时发送到对应服务器;密码分别保存在本机钥匙串,地址、邮箱和账号 ID 只保存在本机设置中。后台额度请求使用 `force=false`,不触发主动探测。移除某个来源的配置会删除其本机保存的 Sub2API 密码和连接信息。 诊断信息由你主动导出到指定位置,不会自动上传。导出的压缩包可能包含应用设置摘要、最近三天的系统日志、清理记录和崩溃报告;分享前请自行检查内容。 @@ -25,8 +25,9 @@ CPU、GPU、内存、网络、温度、风扇、进程等监控数据来自本 ## 3. 其他联网功能 -- 启用“AI 用量”后,Codex 优先通过本机已安装的 Codex CLI App Server 查询额度;不可用时使用本机 Codex 登录凭据直连 `chatgpt.com`。Claude Code 使用本机登录凭据直连 `api.anthropic.com`。相应服务可以看到登录令牌及连接元数据。每个来源最近一次成功的额度快照保存在本机 `ai-usage.sqlite`,包含百分比、重置时间、数据来源及获取时间,不含登录令牌;查询失败时可显示并标记为旧数据。认证失效或移除手动配置会删除对应快照。关闭功能后停止查询并隐藏额度,本地快照仍保留以便重新启用;额度结果不上传到 XStats 服务器。 +- 启用“AI 用量”后,Codex 优先通过本机已安装的 Codex CLI App Server 查询额度;不可用时使用本机 Codex 登录凭据直连 `chatgpt.com`。Claude Code 使用本机登录凭据直连 `api.anthropic.com`。相应服务可以看到登录令牌及连接元数据。每个来源最近一次成功的额度快照保存在本机 `ai-usage.sqlite`,包含百分比、重置时间、套餐类型与有效期、可用重置次数及其最早到期时间、数据来源及获取时间,不含登录令牌、账号标识或重置券 ID;查询失败时可显示并标记为旧数据。认证失效或移除手动配置会删除对应快照。关闭功能后停止查询并隐藏额度,本地快照仍保留以便重新启用;额度结果不上传到 XStats 服务器。 - 启用本地 AI 用量且用量界面可见时,费用估算按需读取 `models.dev/api.json` 的公开模型基础单价;选择人民币时另读取 `open.er-api.com/v6/latest/USD` 的参考汇率。请求不发送会话日志、Token 数量、模型使用记录或登录凭据,第三方可看到连接元数据。单价与汇率分别在本机缓存 24 小时和 12 小时后按需更新,失败时可继续显示带更新时间的旧缓存。关闭界面、关闭功能或系统暂停时取消不再需要的请求。费用根据本机 Token 统计计算,不含阶梯加价,不代表订阅账单。 +- Codex CLI 可直接返回重置次数及到期明细。直连额度接口作为后备方式时,仅在存在可用次数时,另向 `chatgpt.com/backend-api/wham/rate-limit-reset-credits` 发起只读查询;查询失败仍保留已有额度与次数,不执行或自动消耗重置。 - 配置 Codex 或 Claude Code 的 Sub2API 备选来源后,对应的自动额度查询失败时,应用直接请求你为该来源指定的 Sub2API 服务器;该服务器会收到管理员邮箱、密码、登录令牌和查询账号 ID。XStats 不预设 Sub2API 服务器,也不向 XStats 服务器上传这些信息或额度结果。 - 打开网络详情且启用公网 IP 查询时,应用直接访问 Cloudflare `1.1.1.1`,失败时回退到 ipify;归属地、ASN、网络类型和纯净度查询会把公网 IP 发送给 `cleanip.io`。结果在本机缓存。 - 启用连接探测时,应用向所选目标发送 ICMP 探测。测速、出口检测和 DNS 查询仅在你使用相应功能时,直接连接所选测试节点、网站或 DNS 服务;对方可看到连接元数据及测试所需内容。 diff --git a/Packages/XStatsKit/Tests/AIUsageTests/AIQuotaDetailsTests.swift b/Packages/XStatsKit/Tests/AIUsageTests/AIQuotaDetailsTests.swift new file mode 100644 index 0000000..7f2d9c5 --- /dev/null +++ b/Packages/XStatsKit/Tests/AIUsageTests/AIQuotaDetailsTests.swift @@ -0,0 +1,144 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Testing +@testable import AIUsage + +@Suite struct AIQuotaDetailsTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test func appServerKeepsPlanCountAndEarliestAvailableExpiry() throws { + let data = Data(#"{"result":{"accountId":"a","rateLimits":{"planType":"pro","primary":{"usedPercent":30,"windowDurationMins":10080}},"rateLimitResetCredits":{"availableCount":3,"credits":[{"status":"redeemed","expiresAt":1800000100},{"status":"available","expiresAt":1799999900},{"status":"available","expiresAt":null},{"status":"available","expiresAt":1800003000},{"status":"available","expiresAt":1800002000}]}}}"#.utf8) + let credentials = CodexQuotaCredentials(token: "unused", accountID: "a", subscriptionValidUntil: now.addingTimeInterval(9000), subscriptionValidFrom: now.addingTimeInterval(-9000)) + let snapshot = try CodexAppServerQuotaClient.parse(data, now: now, credentials: credentials) + #expect(snapshot.details?.plan == "pro") + #expect(snapshot.details?.resetCredits == 3) + #expect(snapshot.details?.resetCreditsExpireAt == now.addingTimeInterval(2000)) + #expect(snapshot.details?.subscriptionValidUntil == now.addingTimeInterval(9000)) + #expect(snapshot.details?.subscriptionValidFrom == now.addingTimeInterval(-9000)) + #expect(snapshot.details?.subscriptionRemainingFraction(at: now) == 0.5) + let other = CodexQuotaCredentials(token: "unused", accountID: "other", subscriptionValidUntil: now.addingTimeInterval(9000), subscriptionValidFrom: now.addingTimeInterval(-9000)) + #expect(try CodexAppServerQuotaClient.parse(data, now: now, credentials: other).details?.subscriptionValidUntil == nil) + #expect(try CodexAppServerQuotaClient.parse(data, now: now, credentials: other).details?.subscriptionValidFrom == nil) + } + + @Test func countsAndUnknownExpiryStayDistinct() { + for invalid: Any in [true, -1, 1.5, "3", Double.infinity] { + #expect(AIQuotaDetails.parse(plan: nil, resets: ["availableCount": invalid], appServer: true, now: now).resetCredits == nil) + } + let zero = AIQuotaDetails.parse(plan: "unknown", resets: ["availableCount": 0], appServer: true, now: now) + #expect(zero.plan == nil) + #expect(zero.resetCredits == 0) + #expect(zero.resetCreditsExpireAt == nil) + let missing = AIQuotaDetails.parse(plan: "pro", resets: ["availableCount": 3], appServer: true, now: now) + #expect(missing.resetCredits == 3) + #expect(missing.resetCreditsExpireAt == nil) + } + + @Test func subscriptionTermUsesMatchingIdentityAndNotJWTExpiry() throws { + func auth(account: String = "a", until: Date?, start: Date? = nil, exp: Double = 1_900_000_000) throws -> Data { + var subscription: [String: Any] = ["chatgpt_account_id": account] + if let until { subscription["chatgpt_subscription_active_until"] = ISO8601DateFormatter().string(from: until) } + if let start { subscription["chatgpt_subscription_active_start"] = ISO8601DateFormatter().string(from: start) } + let payload = try JSONSerialization.data(withJSONObject: ["exp": exp, "https://api.openai.com/auth": subscription]) + .base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + return try JSONSerialization.data(withJSONObject: ["tokens": ["access_token": "test", "account_id": "a", "id_token": "header.\(payload).signature"]]) + } + let date = now.addingTimeInterval(3600) + #expect(try CodexQuotaCredentials.parse(auth(until: date), now: now).subscriptionValidUntil == date) + let start = now.addingTimeInterval(-3600) + #expect(try CodexQuotaCredentials.parse(auth(until: date, start: start), now: now).subscriptionValidFrom == start) + #expect(try CodexQuotaCredentials.parse(auth(until: date), now: now).subscriptionValidFrom == nil) + #expect(try CodexQuotaCredentials.parse(auth(until: date, start: date), now: now).subscriptionValidFrom == nil) + #expect(try CodexQuotaCredentials.parse(auth(until: date, start: now.addingTimeInterval(100)), now: now).subscriptionValidFrom == nil) + #expect(try CodexQuotaCredentials.parse(auth(account: "b", until: date, start: start), now: now).subscriptionValidFrom == nil) + #expect(try CodexQuotaCredentials.parse(auth(account: "b", until: date), now: now).subscriptionValidUntil == nil) + #expect(try CodexQuotaCredentials.parse(auth(until: nil), now: now).subscriptionValidUntil == nil) + #expect(try CodexQuotaCredentials.parse(auth(until: now.addingTimeInterval(-1)), now: now).subscriptionValidUntil == nil) + } + + @Test func validityProgressRequiresRealBoundsAndClampsTime() { + let start = now.addingTimeInterval(-9000) + let end = now.addingTimeInterval(9000) + let details = AIQuotaDetails(subscriptionValidUntil: end, subscriptionValidFrom: start) + #expect(details.subscriptionRemainingFraction(at: now) == 0.5) + #expect(details.subscriptionRemainingFraction(at: start.addingTimeInterval(-1)) == 1) + #expect(details.subscriptionRemainingFraction(at: end.addingTimeInterval(1)) == 0) + #expect(AIQuotaDetails(subscriptionValidUntil: end).subscriptionRemainingFraction(at: now) == nil) + #expect(AIQuotaDetails(subscriptionValidUntil: start, subscriptionValidFrom: end).subscriptionRemainingFraction(at: now) == nil) + #expect(AIQuotaDetails(subscriptionValidUntil: end, subscriptionValidFrom: end).subscriptionRemainingFraction(at: now) == nil) + #expect(details.subscriptionRemainingFraction(at: Date(timeIntervalSince1970: .infinity)) == nil) + } + + @Test func directDetailFailurePreservesQuotaAndCount() async throws { + let http = DetailsHTTP(failure: true) + let provider = CodexQuotaProvider(credentials: { CodexQuotaCredentials(token: "test", accountID: "a") }, http: http) + let result = try await provider.fetch() + #expect(result.window(.weekly)?.usedPercent == 25) + #expect(result.details?.plan == "pro") + #expect(result.details?.resetCredits == 3) + #expect(result.details?.resetCreditsExpireAt == nil) + #expect(await http.paths == ["/backend-api/wham/usage", "/backend-api/wham/rate-limit-reset-credits"]) + #expect(await http.methods == ["GET", "GET"]) + } + + @Test func directReadsLatestResetDetailsAndNeverConsumes() async throws { + let http = DetailsHTTP() + let provider = CodexQuotaProvider(credentials: { CodexQuotaCredentials(token: "test", accountID: "a") }, http: http) + let result = try await provider.fetch() + #expect(result.details?.resetCredits == 2) + #expect(result.details?.resetCreditsExpireAt == ISO8601DateFormatter().date(from: "2099-01-01T00:00:00Z")) + #expect(await http.methods == ["GET", "GET"]) + #expect(await http.detailTimeout == 4) + } + + @Test func cancelledDetailLookupPropagatesCancellation() async { + let http = DetailsHTTP(cancel: true) + let provider = CodexQuotaProvider(credentials: { CodexQuotaCredentials(token: "test", accountID: nil) }, http: http) + await #expect(throws: CancellationError.self) { try await provider.fetch() } + #expect(await http.paths.count == 2) + } + + @Test func zeroResetCountSkipsExtraRequest() async throws { + let http = DetailsHTTP(count: 0) + let provider = CodexQuotaProvider(credentials: { CodexQuotaCredentials(token: "test", accountID: nil) }, http: http) + #expect(try await provider.fetch().details?.resetCredits == 0) + #expect(await http.paths.count == 1) + } + + @Test func cacheRoundTripsDetailsAndDecodesLegacySnapshot() throws { + let legacy = Data(#"{"provider":"codex","windows":[{"kind":"weekly","usedPercent":25}],"fetchedAt":0,"source":"direct"}"#.utf8) + let snapshot = try JSONDecoder().decode(AIQuotaSnapshot.self, from: legacy) + #expect(snapshot.details == nil) + var updated = snapshot + updated.details = AIQuotaDetails(plan: "pro", subscriptionValidUntil: now, resetCredits: 3, resetCreditsExpireAt: now, subscriptionValidFrom: now.addingTimeInterval(-100)) + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".sqlite") + defer { for suffix in ["", "-wal", "-shm"] { try? FileManager.default.removeItem(atPath: url.path + suffix) } } + let cache = try AIQuotaCacheStore(url: url) + try cache.save(updated) + #expect(try cache.load()[.codex] == updated) + let encoded = String(decoding: try JSONEncoder().encode(updated), as: UTF8.self) + #expect(!encoded.contains("accountId") && !encoded.contains("access_token")) + } +} + +private actor DetailsHTTP: QuotaHTTPClient { + let failure: Bool + let cancel: Bool + let count: Int + var paths: [String] = [] + var methods: [String] = [] + var detailTimeout: TimeInterval? + init(failure: Bool = false, count: Int = 3, cancel: Bool = false) { self.failure = failure; self.count = count; self.cancel = cancel } + func send(_ request: URLRequest) async throws -> (Data, Int) { + paths.append(request.url!.path); methods.append(request.httpMethod ?? "GET") + if request.url!.path.hasSuffix("/usage") { + return (Data("{\"plan_type\":\"pro\",\"rate_limit_reset_credits\":{\"available_count\":\(count)},\"rate_limit\":{\"primary_window\":{\"used_percent\":25,\"limit_window_seconds\":604800}}}".utf8), 200) + } + detailTimeout = request.timeoutInterval + if cancel { throw CancellationError() } + if failure { return (Data(), 503) } + return (Data(#"{"available_count":2,"credits":[{"status":"available","expires_at":"2099-02-01T00:00:00Z"},{"status":"available","expires_at":"2099-01-01T00:00:00Z"},{"status":"redeemed","expires_at":"2098-01-01T00:00:00Z"}]}"#.utf8), 200) + } +} diff --git a/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift b/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift index aa331e4..c1ae321 100644 --- a/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift +++ b/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift @@ -88,6 +88,7 @@ struct LanguageSupportTests { for key in ["语言", "应用 UI 语言", "自动检测", "搜索语言", "没有匹配的语言", "额度显示", "数字单位", "货币", "估算费用", "30 天", "按小时统计", "按天统计", "暂无小时明细,请刷新用量。", + "套餐:Pro", "套餐有效期至:2026-10-20", "可用重置次数:3", "重置次数最早到期:2026-10-05", "AI 用量与额度共用此间隔;新数据到达后更新菜单栏。", "价格来源:models.dev;人民币按缓存汇率换算。", "按模型基础 API 单价估算,不含阶梯加价,非订阅账单。", diff --git a/Packages/XStatsKit/Tests/XStatsUITests/UsageNumberTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/UsageNumberTests.swift index 6225e1e..8d67c1b 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/UsageNumberTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/UsageNumberTests.swift @@ -19,3 +19,43 @@ import Testing #expect(UsageNumber.short(1_374_863_940, locale: Locale(identifier: "zh_Hant_TW")) == "13.75億") } } + +@Suite struct QuotaDateTests { + @Test func sameYearOmitsYearButKeepsTime() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-10-03T00:00:00Z")) + let date = try #require(ISO8601DateFormatter().date(from: "2026-10-20T09:09:00Z")) + let chinese = Locale(identifier: "zh_CN") + #expect(UsageNumber.quotaDate(date, includesTime: false, now: now, locale: chinese, calendar: calendar) == "10月20日") + let timed = UsageNumber.quotaDate(date, includesTime: true, now: now, locale: chinese, calendar: calendar) + #expect(timed.contains("10月20日") && timed.contains("17:09") && !timed.contains("2026")) + let english = UsageNumber.quotaDate(date, includesTime: false, now: now, locale: Locale(identifier: "en_US"), calendar: calendar) + #expect(english == "Oct 20") + } + + @Test(arguments: ["2025-10-20T09:09:00Z", "2027-01-01T09:09:00Z"]) + func otherYearsRemainExplicit(_ timestamp: String) throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let now = try #require(ISO8601DateFormatter().date(from: "2026-10-03T00:00:00Z")) + let date = try #require(ISO8601DateFormatter().date(from: timestamp)) + for locale in [Locale(identifier: "zh_CN"), Locale(identifier: "en_US")] { + for includesTime in [true, false] { + let text = UsageNumber.quotaDate(date, includesTime: includesTime, now: now, locale: locale, calendar: calendar) + #expect(text.contains(String(timestamp.prefix(4)))) + } + } + } + + @Test func localYearBoundaryUsesTheDisplayTimeZone() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-12-31T16:01:00Z")) + let date = try #require(ISO8601DateFormatter().date(from: "2027-01-02T00:00:00Z")) + let locale = Locale(identifier: "zh_CN") + #expect(UsageNumber.quotaDate(date, includesTime: false, now: now, locale: locale, calendar: calendar) == "1月2日") + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + #expect(UsageNumber.quotaDate(date, includesTime: false, now: now, locale: locale, calendar: calendar).contains("2027")) + } +}