feat(ai-usage): 按时间范围展示小时与每日用量
This commit is contained in:
1 parent
e33fa55154
commit
0d8f68372d
18 files changed
+416
-225
No files matched your search
+21
-16
@@ -261,18 +261,23 @@ only the suffix; truncation, replacement, parser namespace or time-zone changes
|
||||
An unterminated final line is previewed separately from committed state, so completing it cannot
|
||||
double-count usage. No raw log lines, prompts or credentials are persisted. Directory metadata is
|
||||
still enumerated on each scheduled refresh; only currently discovered files contribute to totals.
|
||||
The dashboard defaults to the last 365 local-calendar days by model, and shows input/output tokens, cache hit
|
||||
rate, record count, daily totals and model ranking. The menu bar shows today's token total.
|
||||
The dashboard defaults to today and retains the last 365 local-calendar days by model. It shows
|
||||
input/output tokens, cache hit rate, record count, period totals and model ranking. The menu bar shows today's token total.
|
||||
Cached input is a subset of input; reasoning is a subset of output, so neither is added twice.
|
||||
Rows are aggregated by day and model before leaving the provider, and ordered by day then model, so
|
||||
`ModelTokenUsage.id` stays unique and an unchanged corpus produces an equal report.
|
||||
`ModelTokenUsage.id` stays unique and an unchanged corpus produces an equal report. Each daily row
|
||||
also retains a bounded histogram of hour-start timestamps to token counts (23–25 entries per local
|
||||
day). Only same-day/model aggregation merges these histograms; period totals and model rankings
|
||||
sum numeric fields without constructing a year-long hourly map. Parser namespace v3 rebuilds old
|
||||
checkpoints once to recover hours, then resumes normal unchanged-file and suffix-only reuse.
|
||||
Duplicate session IDs and unchanged cumulative snapshots are excluded. Child replayed history
|
||||
only seeds the cumulative baseline until the first `task_started`, which always follows that
|
||||
history; `started_at` is not compared against the session creation time, because a subagent's
|
||||
`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 from them. The separate quota
|
||||
Local logs cannot reliably identify the paying account or usage on other devices. Public base API
|
||||
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
|
||||
queries Codex `wham/usage` using its cached CLI OAuth token. Claude uses its CLI OAuth token for
|
||||
@@ -322,17 +327,17 @@ are enabled. Token totals use compact notation with
|
||||
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. Since source data is aggregated
|
||||
by day, all three activity modes cover the same trailing 365 days: daily uses one cell per day;
|
||||
weekly uses a seven-cell-high bar per calendar week; cumulative sums usage from the window start
|
||||
through the selected week and uses the same bar grid. Zero weeks have no lit cells. Partial weeks
|
||||
include only days inside the window through today. The mode switch lives in the Token activity
|
||||
header, not in a date-range toolbar. Summary and model ranking follow the mode: daily means today,
|
||||
weekly means the current calendar week, and cumulative means the current calendar month (all through
|
||||
now, using the local calendar). The heatmap independently keeps its 365-day window. Pointer hover
|
||||
immediately shows exact daily/weekly/cumulative counts above the chart; the same label is available
|
||||
to accessibility. The narrow popover lays cells out at their design size and scrolls the year grid
|
||||
horizontally, initially at the recent end; the wide panel shrinks cells to fit instead.
|
||||
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 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
|
||||
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
|
||||
background data refresh do not animate the data layout. Reduced motion uses the existing quick fade.
|
||||
|
||||
@@ -7,7 +7,7 @@ import CoreFoundation
|
||||
/// Claude Code 的输入不包含缓存字段;归一化后与 Codex 共用“含缓存输入”的统计口径。
|
||||
public actor ClaudeLocalUsageProvider: AIUsageProvider {
|
||||
/// 解析口径变化时提升版本号;旧命名空间的缓存行会在下一次扫描时被清掉。
|
||||
private static let source = "claude-v2"
|
||||
private static let source = "claude-v3"
|
||||
public nonisolated let id = AIProviderID.claude
|
||||
private let roots: [URL]
|
||||
private let databaseURL: URL
|
||||
@@ -124,8 +124,10 @@ enum ClaudeLocalLogParser {
|
||||
let rawModel = (message["model"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard rawModel != "<synthetic>" else { return nil }
|
||||
let model = rawModel.flatMap { $0.isEmpty ? nil : $0 } ?? "unknown"
|
||||
let row = ModelTokenUsage(day: calendar.startOfDay(for: time), model: model,
|
||||
input: input + read + created, cached: read, output: output, records: 1, cacheCreated: created)
|
||||
let day = calendar.startOfDay(for: time)
|
||||
let row = ModelTokenUsage(day: day, model: model,
|
||||
input: input + read + created, cached: read, output: output, records: 1, cacheCreated: created,
|
||||
hourlyTokens: [UsageTimeSeries.hourStart(for: time, dayStart: day): input + read + created + output])
|
||||
return Event(id: id, row: row, final: message["stop_reason"] is String, timestamp: time)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import CoreFoundation
|
||||
/// 只读取会话的模型和 Token 元数据,不读取 auth.json,不发送网络请求。
|
||||
public actor CodexLocalUsageProvider: AIUsageProvider {
|
||||
/// 解析口径变化时提升版本号;旧命名空间的缓存行会在下一次扫描时被清掉。
|
||||
private static let source = "codex-v2"
|
||||
private static let source = "codex-v3"
|
||||
public nonisolated let id = AIProviderID.codex
|
||||
private let root: URL
|
||||
private let databaseURL: URL
|
||||
@@ -142,7 +142,11 @@ struct CodexLocalLogParser: Codable {
|
||||
let key = "\(day.timeIntervalSince1970):\(model)"
|
||||
var row = rows[key] ?? ModelTokenUsage(day: day, model: model)
|
||||
row.input += usage.input; row.cached += min(usage.cached, usage.input)
|
||||
row.output += usage.output; row.records += 1; rows[key] = row
|
||||
row.output += usage.output; row.records += 1
|
||||
let hour = UsageTimeSeries.hourStart(for: timestamp, dayStart: day)
|
||||
if row.hourlyTokens == nil { row.hourlyTokens = [:] }
|
||||
row.hourlyTokens?[hour, default: 0] += usage.input + usage.output
|
||||
rows[key] = row
|
||||
}
|
||||
|
||||
private func modelName(_ object: [String: Any]) -> String? {
|
||||
|
||||
@@ -10,14 +10,17 @@ public struct ModelTokenUsage: Equatable, Sendable, Identifiable, Codable {
|
||||
public var cached: Int = 0
|
||||
public var cacheCreated: Int = 0
|
||||
public var output: Int = 0
|
||||
/// 同一天的小时 Token 分布;nil 表示旧数据没有小时明细,不能当作午夜用量。
|
||||
public var hourlyTokens: [Date: Int]?
|
||||
public var records: Int = 0
|
||||
public var total: Int { input + output }
|
||||
public var id: String { "\(day.timeIntervalSince1970):\(model)" }
|
||||
|
||||
public init(day: Date, model: String, input: Int = 0, cached: Int = 0, output: Int = 0, records: Int = 0, cacheCreated: Int = 0) {
|
||||
public init(day: Date, model: String, input: Int = 0, cached: Int = 0, output: Int = 0, records: Int = 0, cacheCreated: Int = 0, hourlyTokens: [Date: Int]? = nil) {
|
||||
self.day = day; self.model = model; self.input = input
|
||||
self.cached = cached; self.output = output; self.records = records
|
||||
self.cacheCreated = cacheCreated
|
||||
self.hourlyTokens = hourlyTokens
|
||||
}
|
||||
|
||||
public mutating func add(_ other: Self) {
|
||||
@@ -45,14 +48,10 @@ public struct LocalUsageReport: Equatable, Sendable {
|
||||
rows.reduce(into: ModelTokenUsage(day: .distantPast, model: "")) { $0.add($1) }
|
||||
}
|
||||
|
||||
/// 活动图模式同时控制摘要:每日取本日,每周取本周,累计与活动图共用最近 365 天。
|
||||
/// 摘要、费用、排行和图表共用滚动窗口,包含今天;累计仅指保留的最近 365 天。
|
||||
public func summary(mode: UsageActivityMode, model: String? = nil, now: Date = Date(),
|
||||
calendar: Calendar = .current) -> [ModelTokenUsage] {
|
||||
// 累计摘要、费用和排行必须覆盖图表同一窗口,不能仅从本月月初开始。
|
||||
if mode == .cumulative { return selected(days: 365, model: model, now: now, calendar: calendar) }
|
||||
let component: Calendar.Component = mode == .daily ? .day : .weekOfYear
|
||||
let start = calendar.dateInterval(of: component, for: now)?.start ?? calendar.startOfDay(for: now)
|
||||
return rows.filter { $0.day >= start && $0.day <= now && (model == nil || $0.model == model) }
|
||||
selected(days: mode.days, model: model, now: now, calendar: calendar)
|
||||
}
|
||||
|
||||
/// 同一天同一模型会来自多个会话文件,而 `ModelTokenUsage.id` 正以“日期 + 模型”为键,
|
||||
@@ -62,6 +61,16 @@ public struct LocalUsageReport: Equatable, Sendable {
|
||||
for row in rows {
|
||||
var value = merged[row.id] ?? ModelTokenUsage(day: row.day, model: row.model)
|
||||
value.add(row)
|
||||
// 小时明细只在同一天/同模型间合并;任一旧行缺失明细时,不伪造完整分布。
|
||||
if let previous = merged[row.id] {
|
||||
if let priorHours = previous.hourlyTokens, let hours = row.hourlyTokens {
|
||||
value.hourlyTokens = priorHours.merging(hours, uniquingKeysWith: +)
|
||||
} else {
|
||||
value.hourlyTokens = nil
|
||||
}
|
||||
} else {
|
||||
value.hourlyTokens = row.hourlyTokens
|
||||
}
|
||||
merged[row.id] = value
|
||||
}
|
||||
// 必须定序:解析状态存在字典里,冷扫描和从检查点恢复的迭代顺序不同,
|
||||
|
||||
@@ -4,69 +4,98 @@
|
||||
import Foundation
|
||||
|
||||
public enum UsageActivityMode: String, CaseIterable, Sendable {
|
||||
case daily, weekly, cumulative
|
||||
case daily, weekly, monthly, cumulative
|
||||
|
||||
public var days: Int {
|
||||
switch self {
|
||||
case .daily: 1
|
||||
case .weekly: 7
|
||||
case .monthly: 30
|
||||
case .cumulative: 365
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 三种图形共用同一年度窗口和筛选结果,累计值从窗口起点累加到所选周。
|
||||
/// 短周期柱状图。小时以绝对时间标识,夏令时切换日自然产生 23 或 25 个桶。
|
||||
public struct UsageTimeSeries: Sendable {
|
||||
public struct Bucket: Identifiable, Sendable {
|
||||
public let start: Date
|
||||
public let end: Date
|
||||
public let total: Int
|
||||
public var id: Date { start }
|
||||
}
|
||||
public let buckets: [Bucket]
|
||||
public let hasMissingHourlyData: Bool
|
||||
|
||||
/// 从本地零点起按实际 60 分钟分桶;半小时夏令时地区的最后一个桶可能不足一小时。
|
||||
/// 解析与绘图必须采用同一起点,不能混用可能重叠的 Calendar hour interval。
|
||||
static func hourStart(for timestamp: Date, dayStart: Date) -> Date {
|
||||
dayStart.addingTimeInterval(floor(timestamp.timeIntervalSince(dayStart) / 3600) * 3600)
|
||||
}
|
||||
|
||||
public init(rows: [ModelTokenUsage], mode: UsageActivityMode, now: Date = Date(), calendar: Calendar = .current) {
|
||||
let today = calendar.startOfDay(for: now)
|
||||
let start = calendar.date(byAdding: .day, value: -(mode.days - 1), to: today)!
|
||||
let end = calendar.date(byAdding: .day, value: 1, to: today)!
|
||||
let component: Calendar.Component = mode == .daily ? .hour : .day
|
||||
var values: [Date: Int] = [:]
|
||||
var missing = false
|
||||
for row in rows where row.day >= start && row.day <= now {
|
||||
if mode == .daily {
|
||||
guard let hours = row.hourlyTokens else { missing = missing || row.total > 0; continue }
|
||||
for (hour, tokens) in hours where hour >= start && hour <= now {
|
||||
values[hour, default: 0] += tokens
|
||||
}
|
||||
} else {
|
||||
values[calendar.startOfDay(for: row.day), default: 0] += row.total
|
||||
}
|
||||
}
|
||||
var buckets: [Bucket] = []
|
||||
var cursor = start
|
||||
while cursor < end {
|
||||
let next = min(end, calendar.date(byAdding: component, value: 1, to: cursor)!)
|
||||
buckets.append(Bucket(start: cursor, end: next, total: values[cursor] ?? 0))
|
||||
cursor = next
|
||||
}
|
||||
self.buckets = buckets
|
||||
hasMissingHourlyData = missing
|
||||
}
|
||||
}
|
||||
|
||||
/// 累计视图展示最近 365 个本地日期;每格是当天用量,不把累计快照再次相加。
|
||||
public struct UsageActivity: Sendable {
|
||||
public struct Week: Identifiable, Sendable {
|
||||
public let start: Date
|
||||
public let end: Date
|
||||
public let days: [Date?]
|
||||
public let total: Int
|
||||
public let cumulative: Int
|
||||
public var id: Date { start }
|
||||
}
|
||||
public let start: Date
|
||||
public let end: Date
|
||||
public let daily: [Date: Int]
|
||||
public let weeks: [Week]
|
||||
public var peak: Int { daily.values.max() ?? 0 }
|
||||
public var total: Int { daily.values.reduce(0, +) }
|
||||
|
||||
public init(rows: [ModelTokenUsage], now: Date = Date(), calendar: Calendar = .current) {
|
||||
let end = calendar.startOfDay(for: now)
|
||||
let start = calendar.date(byAdding: .day, value: -364, to: end)!
|
||||
self.start = start; self.end = end
|
||||
var daily: [Date: Int] = [:]
|
||||
for row in rows where row.day >= start && row.day <= end {
|
||||
for row in rows where row.day >= start && row.day <= now {
|
||||
daily[calendar.startOfDay(for: row.day), default: 0] += row.total
|
||||
}
|
||||
self.daily = daily
|
||||
let leading = (calendar.component(.weekday, from: start) - calendar.firstWeekday + 7) % 7
|
||||
let firstWeek = calendar.date(byAdding: .day, value: -leading, to: start)!
|
||||
var weeks: [Week] = []
|
||||
var cumulative = 0
|
||||
for column in 0..<((leading + 365 + 6) / 7) {
|
||||
let weekStart = calendar.date(byAdding: .day, value: column * 7, to: firstWeek)!
|
||||
let dates: [Date?] = (0..<7).map { index in
|
||||
let date = calendar.date(byAdding: .day, value: index, to: weekStart)!
|
||||
return date >= start && date <= end ? date : nil
|
||||
}
|
||||
let total = dates.compactMap { $0 }.reduce(0) { $0 + (daily[$1] ?? 0) }
|
||||
cumulative += total
|
||||
weeks.append(Week(start: max(start, weekStart), end: dates.compactMap { $0 }.last ?? end,
|
||||
days: dates, total: total, cumulative: cumulative))
|
||||
weeks.append(Week(start: max(start, weekStart), days: dates))
|
||||
}
|
||||
self.weeks = weeks
|
||||
}
|
||||
|
||||
public func peak(for mode: UsageActivityMode) -> Int {
|
||||
switch mode {
|
||||
case .daily: daily.values.max() ?? 0
|
||||
case .weekly: weeks.map(\.total).max() ?? 0
|
||||
case .cumulative: weeks.last?.cumulative ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
public static func filledCells(value: Int, peak: Int) -> Int {
|
||||
guard value > 0, peak > 0 else { return 0 }
|
||||
return min(7, max(1, Int(ceil(Double(value) / Double(peak) * 7))))
|
||||
}
|
||||
|
||||
/// 周用量可能被单周峰值拉开数量级,用平方根提升低用量周的辨识度;累计保持线性递进。
|
||||
public static func colorIntensity(value: Int, peak: Int, mode: UsageActivityMode) -> Double {
|
||||
guard value > 0, peak > 0 else { return 0 }
|
||||
let fraction = min(1, Double(value) / Double(peak))
|
||||
let strength = mode == .weekly ? sqrt(fraction) : fraction
|
||||
return 0.25 + 0.75 * strength
|
||||
}
|
||||
}
|
||||
@@ -1300,8 +1300,6 @@ Token 活动 نشاط الرموز
|
||||
每日 يومي
|
||||
每周 أسبوعي
|
||||
累计 تراكمي
|
||||
{} 当周:{} Tokens أسبوع {}: {} Tokens
|
||||
截至 {} 当周累计:{} Tokens الإجمالي حتى أسبوع {}: {} Tokens
|
||||
Apple 智能 Apple Intelligence
|
||||
日历 التقويم
|
||||
月历 التقويم الشهري
|
||||
@@ -1636,3 +1634,8 @@ GitHub 项目 مشروع GitHub
|
||||
汇率更新:{} تحديث سعر الصرف: {}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 مصدر الأسعار: models.dev؛ تُحوَّل المبالغ إلى اليوان الصيني بسعر الصرف المخزّن مؤقتًا.
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 استخدام الذكاء الاصطناعي والحصة يشتركان في هذا الفاصل؛ يُحدَّث شريط القوائم عند وصول بيانات جديدة.
|
||||
30 天 30 يومًا
|
||||
按小时统计 الاستخدام بالساعة
|
||||
按天统计 الاستخدام اليومي
|
||||
单日最高:{} Tokens أعلى استخدام يومي: {} Tokens
|
||||
暂无小时明细,请刷新用量。 تفاصيل الساعات غير متاحة. حدّث الاستخدام.
|
||||
|
@@ -1300,8 +1300,6 @@ Token 活动 Token-Aktivität
|
||||
每日 Täglich
|
||||
每周 Wöchentlich
|
||||
累计 Kumuliert
|
||||
{} 当周:{} Tokens Woche vom {}: {} Tokens
|
||||
截至 {} 当周累计:{} Tokens Bis zur Woche vom {}: {} Tokens kumuliert
|
||||
Apple 智能 Apple Intelligence
|
||||
日历 Kalender
|
||||
月历 Monatskalender
|
||||
@@ -1636,3 +1634,8 @@ GitHub 项目 GitHub-Projekt
|
||||
汇率更新:{} Wechselkursaktualisierung: {}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 Preisquelle: models.dev; Yuan-Beträge werden mit dem gespeicherten Wechselkurs umgerechnet.
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 KI-Nutzung und Kontingent teilen dieses Intervall; Die Menüleiste wird aktualisiert, sobald neue Daten vorliegen.
|
||||
30 天 30 Tage
|
||||
按小时统计 Stündliche Nutzung
|
||||
按天统计 Tägliche Nutzung
|
||||
单日最高:{} Tokens Tagesmaximum: {} Tokens
|
||||
暂无小时明细,请刷新用量。 Keine Stundendaten verfügbar. Nutzung aktualisieren.
|
||||
|
@@ -1300,8 +1300,6 @@ Token 活动 Actividad de tokens
|
||||
每日 Diario
|
||||
每周 Semanal
|
||||
累计 Acumulado
|
||||
{} 当周:{} Tokens Semana del {}: {} Tokens
|
||||
截至 {} 当周累计:{} Tokens Acumulado hasta la semana del {}: {} Tokens
|
||||
Apple 智能 Apple Intelligence
|
||||
日历 Calendario
|
||||
月历 Calendario mensual
|
||||
@@ -1636,3 +1634,8 @@ GitHub 项目 Proyecto en GitHub
|
||||
汇率更新:{} Tipo de cambio actualizado: {}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 Fuente de precios: models.dev; Los importes en yuanes se convierten con el tipo de cambio almacenado.
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 El uso de IA y la cuota comparten este intervalo; La barra de menús se actualiza cuando llegan nuevos datos.
|
||||
30 天 30 días
|
||||
按小时统计 Uso por hora
|
||||
按天统计 Uso diario
|
||||
单日最高:{} Tokens Máximo diario: {} Tokens
|
||||
暂无小时明细,请刷新用量。 No hay detalles por hora. Actualiza el uso.
|
||||
|
@@ -1300,8 +1300,6 @@ Token 活动 Activité des tokens
|
||||
每日 Par jour
|
||||
每周 Par semaine
|
||||
累计 Cumulé
|
||||
{} 当周:{} Tokens Semaine du {} : {} Tokens
|
||||
截至 {} 当周累计:{} Tokens Cumul jusqu’à la semaine du {} : {} Tokens
|
||||
Apple 智能 Apple Intelligence
|
||||
日历 Calendrier
|
||||
月历 Calendrier mensuel
|
||||
@@ -1636,3 +1634,8 @@ GitHub 项目 Projet GitHub
|
||||
汇率更新:{} Taux de change mis à jour : {}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 Source des tarifs : models.dev; Les montants en yuans sont convertis au taux de change en cache.
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 L’utilisation de l’IA et le quota partagent cet intervalle; La barre des menus est mise à jour à l’arrivée de nouvelles données.
|
||||
30 天 30 jours
|
||||
按小时统计 Utilisation horaire
|
||||
按天统计 Utilisation quotidienne
|
||||
单日最高:{} Tokens Maximum quotidien : {} Tokens
|
||||
暂无小时明细,请刷新用量。 Détails horaires indisponibles. Actualisez l’utilisation.
|
||||
|
@@ -1300,8 +1300,6 @@ Token 活动 トークン活動
|
||||
每日 日別
|
||||
每周 週別
|
||||
累计 累計
|
||||
{} 当周:{} Tokens {} の週:{} Tokens
|
||||
截至 {} 当周累计:{} Tokens {} の週までの累計:{} Tokens
|
||||
Apple 智能 Apple Intelligence
|
||||
日历 カレンダー
|
||||
月历 月間カレンダー
|
||||
@@ -1636,3 +1634,8 @@ GitHub 项目 GitHub プロジェクト
|
||||
汇率更新:{} 為替レート更新:{}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 料金情報元:models.dev;人民元はキャッシュされた為替レートで換算します。
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 使用量と割り当てでこの間隔を共用;新しいデータの取得後にメニューバーを更新します。
|
||||
30 天 30 日
|
||||
按小时统计 時間別の使用量
|
||||
按天统计 日別の使用量
|
||||
单日最高:{} Tokens 1 日の最大:{} Tokens
|
||||
暂无小时明细,请刷新用量。 時間別の詳細がありません。使用量を更新してください。
|
||||
|
@@ -1300,8 +1300,6 @@ Token 活动 토큰 활동
|
||||
每日 일별
|
||||
每周 주별
|
||||
累计 누적
|
||||
{} 当周:{} Tokens {} 주: {} Tokens
|
||||
截至 {} 当周累计:{} Tokens {} 주까지 누적: {} Tokens
|
||||
Apple 智能 Apple Intelligence
|
||||
日历 달력
|
||||
月历 월간 달력
|
||||
@@ -1636,3 +1634,8 @@ GitHub 项目 GitHub 프로젝트
|
||||
汇率更新:{} 환율 업데이트: {}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 가격 출처: models.dev; 중국 위안 금액은 캐시된 환율로 환산합니다.
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 사용량과 할당량에 동일한 간격 적용; 새 데이터가 도착하면 메뉴 막대가 업데이트됩니다.
|
||||
30 天 30일
|
||||
按小时统计 시간별 사용량
|
||||
按天统计 일별 사용량
|
||||
单日最高:{} Tokens 일일 최대: {} Tokens
|
||||
暂无小时明细,请刷新用量。 시간별 세부 정보가 없습니다. 사용량을 새로 고치세요.
|
||||
|
@@ -1301,8 +1301,6 @@ Token 活动 Token 活動
|
||||
每日 每日
|
||||
每周 每週
|
||||
累计 累計
|
||||
{} 当周:{} Tokens {} 當週:{} Tokens
|
||||
截至 {} 当周累计:{} Tokens 截至 {} 當週累計:{} Tokens
|
||||
Apple 智能 Apple Intelligence
|
||||
日历 日曆
|
||||
月历 月曆
|
||||
@@ -1637,3 +1635,8 @@ GitHub 项目 GitHub 專案
|
||||
汇率更新:{} 匯率更新:{}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 價格來源:models.dev;人民幣按快取匯率換算。
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 用量與額度共用此間隔;新資料到達後更新選單列。
|
||||
30 天 30 天
|
||||
按小时统计 按小時統計
|
||||
按天统计 按天統計
|
||||
单日最高:{} Tokens 單日最高:{} Tokens
|
||||
暂无小时明细,请刷新用量。 暫無小時明細,請重新整理用量。
|
||||
|
@@ -77,10 +77,13 @@ Token 活动 Token activity
|
||||
每日 Daily
|
||||
每周 Weekly
|
||||
累计 Cumulative
|
||||
{} 当周:{} Tokens Week of {}: {} Tokens
|
||||
截至 {} 当周累计:{} Tokens Through the week of {}: {} Tokens cumulative
|
||||
缓存创建 Token Cache creation tokens
|
||||
AI 用量 AI Usage
|
||||
30 天 30 days
|
||||
按小时统计 Hourly usage
|
||||
按天统计 Daily usage
|
||||
单日最高:{} Tokens Daily peak: {} Tokens
|
||||
暂无小时明细,请刷新用量。 Hourly details unavailable. Refresh usage.
|
||||
额度显示 Quota display
|
||||
数字单位 Number units
|
||||
万 / 亿 10K / 100M
|
||||
|
||||
@@ -56,6 +56,7 @@ private struct LocalUsageContent: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let now = Date()
|
||||
VStack(alignment: .leading, spacing: compact ? DS.Space.s3 : DS.Space.s4) {
|
||||
toolbar
|
||||
if model.settings.aiUsageEnabled && !enabledProviders.isEmpty {
|
||||
@@ -83,13 +84,13 @@ 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)
|
||||
let activityRows = report.selected(days: 365, model: selectedModel.isEmpty ? nil : selectedModel)
|
||||
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 total = LocalUsageReport.total(rows)
|
||||
UsageSummary(total: total, compact: compact,
|
||||
title: selectedModel.isEmpty ? tr("本地用量") : selectedModel,
|
||||
costRows: rows)
|
||||
UsageHeatmap(rows: activityRows, compact: compact, mode: $activityMode)
|
||||
UsageActivityPanel(rows: activityRows, compact: compact, now: now, mode: $activityMode)
|
||||
modelRanking(report: report, rows: rows, total: total)
|
||||
status(report)
|
||||
} else {
|
||||
@@ -566,79 +567,137 @@ private struct UsageModelRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 活动图始终展示最近 365 天;模式同时切换摘要和排行的本日/本周/同窗口累计口径。
|
||||
private struct UsageHeatmap: View {
|
||||
/// 摘要和图表共用筛选范围;短周期看柱状分布,累计看年度每日热力图。
|
||||
private struct UsageActivityPanel: View {
|
||||
@Environment(\.aiUsageWesternUnits) private var westernUnits
|
||||
let rows: [ModelTokenUsage]
|
||||
let compact: Bool
|
||||
let now: Date
|
||||
@Binding var mode: UsageActivityMode
|
||||
@State private var hovered: String?
|
||||
|
||||
var body: some View {
|
||||
let activity = UsageActivity(rows: rows)
|
||||
let peak = activity.peak(for: mode)
|
||||
Card(spacing: DS.Space.s2) {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
Text(tr("Token 活动")).dsFont(.sm, weight: .semibold)
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
UsageChoices(selection: $mode, options: [
|
||||
(.daily, tr("每日")), (.weekly, tr("每周")), (.cumulative, tr("累计"))
|
||||
], label: tr("Token 活动"))
|
||||
.frame(width: compact ? 175 : 200)
|
||||
if !compact { choices.frame(width: 280) }
|
||||
}
|
||||
Text(hovered ?? " ")
|
||||
if compact { choices }
|
||||
Text(hovered ?? tr(mode == .daily ? "按小时统计" : "按天统计"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1).minimumScaleFactor(0.75)
|
||||
.padding(.horizontal, DS.Space.s2)
|
||||
.padding(.vertical, DS.Space.s1 / 2)
|
||||
.background(hovered == nil ? Color.clear : DS.Palette.track, in: Capsule())
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.frame(height: DS.Space.s6)
|
||||
.accessibilityHidden(hovered == nil)
|
||||
if compact {
|
||||
// 窄弹窗里格子按设计尺寸铺开、溢出视口再横向滚动;
|
||||
// 若把网格宽度钉在面板宽度上,格子会被压到刚好塞满,滚动就永远不会发生。
|
||||
ScrollView(.horizontal) {
|
||||
ActivityGrid(activity: activity, mode: mode, hovered: $hovered, side: DS.Space.s3)
|
||||
.frame(height: gridHeight)
|
||||
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)
|
||||
}
|
||||
.defaultScrollAnchor(.trailing)
|
||||
Text(UsageNumber.day(activity.start) + " – " + UsageNumber.day(activity.end))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
HStack {
|
||||
Text(tr("单日最高:\(UsageNumber.short(activity.peak, westernUnits: westernUnits)) Tokens"))
|
||||
.help(UsageNumber.exact(activity.peak) + " Tokens")
|
||||
Spacer(minLength: DS.Space.s1)
|
||||
ForEach(0..<5) { level in
|
||||
RoundedRectangle(cornerRadius: DS.Space.s1 / 2)
|
||||
.fill(level == 0 ? DS.Palette.track : DS.Palette.primary.opacity(Double(level) / 4))
|
||||
.frame(width: DS.Space.s3, height: DS.Space.s3)
|
||||
}
|
||||
}.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
} else {
|
||||
ActivityGrid(activity: activity, mode: mode, hovered: $hovered)
|
||||
.frame(height: gridHeight)
|
||||
}
|
||||
HStack {
|
||||
if !compact {
|
||||
Text(UsageNumber.day(activity.start) + " – " +
|
||||
activity.end.formatted(.dateTime.month().day().locale(L10n.locale)))
|
||||
Spacer()
|
||||
let series = UsageTimeSeries(rows: rows, mode: mode, now: now)
|
||||
if series.hasMissingHourlyData {
|
||||
Text(tr("暂无小时明细,请刷新用量。"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, minHeight: 140)
|
||||
} else {
|
||||
UsageBarChart(buckets: series.buckets, hourly: mode == .daily, hovered: $hovered)
|
||||
}
|
||||
Text("0")
|
||||
HStack(spacing: DS.Space.s1 / 2) {
|
||||
ForEach(0..<5) { level in
|
||||
RoundedRectangle(cornerRadius: DS.Space.s1 / 2)
|
||||
.fill(level == 0 ? DS.Palette.track : DS.Palette.primary.opacity(mode == .daily
|
||||
? Double(level) / 4 : UsageActivity.colorIntensity(value: level, peak: 4, mode: mode)))
|
||||
.frame(width: DS.Space.s3, height: DS.Space.s3)
|
||||
}
|
||||
}.accessibilityHidden(true)
|
||||
Text(UsageNumber.short(peak, westernUnits: westernUnits) + " Tokens").help(UsageNumber.exact(peak) + " Tokens")
|
||||
}.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
.onChange(of: mode) { _, _ in hovered = nil }
|
||||
.onChange(of: rows) { _, _ in hovered = nil }
|
||||
}
|
||||
|
||||
private var choices: some View {
|
||||
UsageChoices(selection: $mode, options: [(.daily, tr("今天")), (.weekly, tr("7 天")),
|
||||
(.monthly, tr("30 天")), (.cumulative, tr("累计"))],
|
||||
label: tr("Token 活动"))
|
||||
}
|
||||
private var gridHeight: CGFloat { 7 * DS.Space.s3 + 6 * (DS.Space.s1 / 2) + DS.Space.s6 }
|
||||
}
|
||||
|
||||
private struct UsageBarChart: View {
|
||||
@Environment(\.aiUsageWesternUnits) private var westernUnits
|
||||
let buckets: [UsageTimeSeries.Bucket]
|
||||
let hourly: Bool
|
||||
@Binding var hovered: String?
|
||||
|
||||
var body: some View {
|
||||
let peak = max(1, buckets.map(\.total).max() ?? 0)
|
||||
VStack(spacing: DS.Space.s1) {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
VStack(alignment: .trailing) {
|
||||
Text(UsageNumber.short(peak, westernUnits: westernUnits))
|
||||
Spacer()
|
||||
Text(UsageNumber.short(peak / 2, westernUnits: westernUnits))
|
||||
Spacer()
|
||||
Text("0")
|
||||
}.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
.frame(width: 48, alignment: .trailing).accessibilityHidden(true)
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
VStack { HairlineDivider(); Spacer(); HairlineDivider(); Spacer(); HairlineDivider() }
|
||||
.accessibilityHidden(true)
|
||||
HStack(alignment: .bottom, spacing: DS.Space.s1 / 2) {
|
||||
ForEach(buckets) { bucket in
|
||||
let label = bucket.start.formatted(hourly
|
||||
? .dateTime.month().day().hour().minute().timeZone().locale(L10n.locale)
|
||||
: .dateTime.month().day().locale(L10n.locale))
|
||||
+ " · " + UsageNumber.exact(bucket.total) + " Tokens"
|
||||
RoundedRectangle(cornerRadius: DS.Space.s1 / 2)
|
||||
.fill(DS.Palette.primary.opacity(0.8))
|
||||
.frame(maxWidth: 24)
|
||||
.frame(height: bucket.total == 0 ? 0 : max(2, geometry.size.height * Double(bucket.total) / Double(peak)))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
.contentShape(Rectangle())
|
||||
.onHover { active in hovered = active ? label : nil }
|
||||
.accessibilityElement(children: .ignore).accessibilityLabel(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.frame(height: 140)
|
||||
GeometryReader { geometry in
|
||||
let indices = 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) {
|
||||
Text(buckets[index].start.formatted(hourly
|
||||
? .dateTime.hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).locale(L10n.locale)
|
||||
: .dateTime.month(.twoDigits).day(.twoDigits).locale(L10n.locale)))
|
||||
.position(x: min(geometry.size.width - 20, max(20, geometry.size.width * (Double(index) + 0.5) / Double(buckets.count))), y: 9)
|
||||
}
|
||||
}
|
||||
}.frame(height: 20).padding(.leading, 48 + DS.Space.s2)
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).accessibilityHidden(true)
|
||||
}
|
||||
// 时间轴固定从左到右,阿拉伯语标签仍按其文字方向排版。
|
||||
.environment(\.layoutDirection, .leftToRight)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActivityGrid: View {
|
||||
let activity: UsageActivity
|
||||
let mode: UsageActivityMode
|
||||
@Binding var hovered: String?
|
||||
/// 指定边长时按固有尺寸铺开(窄弹窗横向滚动);为 nil 时收缩到可用宽度(宽面板整年铺满)。
|
||||
var side: CGFloat?
|
||||
|
||||
private var gap: CGFloat { DS.Space.s1 / 2 }
|
||||
|
||||
var body: some View {
|
||||
@@ -646,67 +705,39 @@ private struct ActivityGrid: View {
|
||||
grid(side: side)
|
||||
} else {
|
||||
GeometryReader { geometry in
|
||||
let columns = activity.weeks.count
|
||||
grid(side: min(DS.Space.s3,
|
||||
max(1, (geometry.size.width - CGFloat(columns - 1) * gap) / CGFloat(max(1, columns)))))
|
||||
max(1, (geometry.size.width - CGFloat(activity.weeks.count - 1) * gap)
|
||||
/ CGFloat(max(1, activity.weeks.count)))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func grid(side: CGFloat) -> some View {
|
||||
HStack(alignment: .top, spacing: gap) {
|
||||
let peak = max(1, activity.peak)
|
||||
return HStack(alignment: .top, spacing: gap) {
|
||||
ForEach(activity.weeks) { week in
|
||||
ActivityWeek(week: week, activity: activity, mode: mode, side: side, hovered: $hovered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActivityWeek: View {
|
||||
@Environment(\.aiUsageWesternUnits) private var westernUnits
|
||||
let week: UsageActivity.Week
|
||||
let activity: UsageActivity
|
||||
let mode: UsageActivityMode
|
||||
let side: CGFloat
|
||||
@Binding var hovered: String?
|
||||
|
||||
private var weekLabel: String {
|
||||
let date = UsageNumber.day(week.start)
|
||||
return mode == .weekly
|
||||
? tr("\(date) 当周:\(UsageNumber.short(week.total, westernUnits: westernUnits)) Tokens")
|
||||
: tr("截至 \(date) 当周累计:\(UsageNumber.short(week.cumulative, westernUnits: westernUnits)) Tokens")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let peak = activity.peak(for: mode)
|
||||
let value = mode == .weekly ? week.total : week.cumulative
|
||||
let filled = UsageActivity.filledCells(value: value, peak: peak)
|
||||
let intensity = UsageActivity.colorIntensity(value: value, peak: peak, mode: mode)
|
||||
VStack(spacing: DS.Space.s1 / 2) {
|
||||
ForEach(0..<7, id: \.self) { row in
|
||||
if mode == .daily {
|
||||
if let date = week.days[row] {
|
||||
let count = activity.daily[date] ?? 0
|
||||
let level = min(4, max(1, Int(ceil(Double(count) / Double(max(1, peak)) * 4))))
|
||||
ActivityCell(side: side,
|
||||
color: count == 0 ? DS.Palette.track : DS.Palette.primary.opacity(Double(level) / 4),
|
||||
label: UsageNumber.day(date) + " · " + UsageNumber.short(count, westernUnits: westernUnits) + " Tokens",
|
||||
hovered: $hovered)
|
||||
} else {
|
||||
Color.clear.frame(width: side, height: side).accessibilityHidden(true)
|
||||
VStack(spacing: gap) {
|
||||
ForEach(0..<7, id: \.self) { row in
|
||||
if let date = week.days[row] {
|
||||
let count = activity.daily[date] ?? 0
|
||||
let level = min(4, max(1, Int(ceil(Double(count) / Double(peak) * 4))))
|
||||
ActivityCell(side: side,
|
||||
color: count == 0 ? DS.Palette.track : DS.Palette.primary.opacity(Double(level) / 4),
|
||||
label: UsageNumber.day(date) + " · " + UsageNumber.exact(count) + " Tokens",
|
||||
hovered: $hovered)
|
||||
} else {
|
||||
Color.clear.frame(width: side, height: side).accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ActivityCell(side: side, color: row >= 7 - filled ? DS.Palette.primary.opacity(intensity) : DS.Palette.track,
|
||||
label: weekLabel, hovered: $hovered)
|
||||
Color.clear.frame(width: side, height: DS.Space.s4)
|
||||
.overlay(alignment: .leading) {
|
||||
if let month = week.days.compactMap({ $0 }).first(where: { Calendar.current.component(.day, from: $0) == 1 }) {
|
||||
Text(month.formatted(.dateTime.month(.abbreviated).locale(L10n.locale)))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).fixedSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Color.clear.frame(width: side, height: DS.Space.s4)
|
||||
.overlay(alignment: .leading) {
|
||||
if let month = week.days.compactMap({ $0 }).first(where: { Calendar.current.component(.day, from: $0) == 1 }) {
|
||||
Text(month.formatted(.dateTime.month(.abbreviated).locale(L10n.locale)))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).fixedSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,21 @@ import Testing
|
||||
""".utf8)
|
||||
}
|
||||
|
||||
@Test func hourlyBucketsUseFinalMessageTimestampWithoutDoubleCounting() throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
let partial = try #require(ClaudeLocalLogParser.parse(record(output: 1), calendar: calendar))
|
||||
let data = Data(String(decoding: record(output: 20, final: true), as: UTF8.self)
|
||||
.replacingOccurrences(of: "10:00:00Z", with: "11:15:00Z").utf8)
|
||||
let final = try #require(ClaudeLocalLogParser.parse(data, calendar: calendar))
|
||||
var events: [String: ClaudeLocalLogParser.Event] = [:]
|
||||
for event in [partial, final, partial, final] { ClaudeLocalLogParser.merge(event, into: &events) }
|
||||
let rows = LocalUsageReport.aggregated(events.values.map(\.row))
|
||||
let hour = ISO8601DateFormatter().date(from: "2026-09-22T11:00:00Z")!
|
||||
#expect(rows.first?.hourlyTokens == [hour: 130])
|
||||
#expect(LocalUsageReport.total(rows).total == 130)
|
||||
}
|
||||
|
||||
@Test func cacheIsAddedToAnthropicInputExactlyOnce() throws {
|
||||
let event = try #require(ClaudeLocalLogParser.parse(record()))
|
||||
#expect(event.row.input == 110)
|
||||
|
||||
@@ -13,6 +13,25 @@ import Testing
|
||||
"{\"type\":\"token_count\",\"info\":{\"total_token_usage\":{\"input_tokens\":\(input),\"cached_input_tokens\":\(cached),\"output_tokens\":\(output)}}}"
|
||||
}
|
||||
|
||||
@Test func hourlyBucketsKeepDeltasDeduplicationAndCacheRestoration() throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
var parser = CodexLocalLogParser(calendar: calendar)
|
||||
parser.consume(line(usage(100, 20), time: "2026-09-22T10:15:00Z"))
|
||||
parser.consume(line(usage(100, 20), time: "2026-09-22T11:00:00Z"))
|
||||
parser = try JSONDecoder().decode(CodexLocalLogParser.self, from: JSONEncoder().encode(parser))
|
||||
parser.consume(line(usage(150, 30), time: "2026-09-22T11:15:00Z"))
|
||||
let row = try #require(parser.rows.values.first)
|
||||
let hour10 = ISO8601DateFormatter().date(from: "2026-09-22T10:00:00Z")!
|
||||
let hour11 = hour10.addingTimeInterval(3600)
|
||||
#expect(row.hourlyTokens == [hour10: 120, hour11: 60])
|
||||
#expect(row.total == 180)
|
||||
let merged = LocalUsageReport.aggregated([row, row])
|
||||
#expect(merged.count == 1)
|
||||
#expect(merged.first?.hourlyTokens == [hour10: 240, hour11: 120])
|
||||
#expect(LocalUsageReport.total(merged).hourlyTokens == nil)
|
||||
}
|
||||
|
||||
@Test func cumulativeDeltasDeduplicateAndFollowModels() {
|
||||
var parser = CodexLocalLogParser()
|
||||
parser.consume(line(#"{"model":"model-a"}"#, type: "turn_context"))
|
||||
@@ -74,6 +93,61 @@ import Testing
|
||||
#expect(LocalUsageReport.total(Array(parser.rows.values)).total == 12)
|
||||
}
|
||||
|
||||
@Test func legacyCheckpointRebuildsHourlyDetailsOnce() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root.appendingPathComponent("sessions"), withIntermediateDirectories: true)
|
||||
let url = root.appendingPathComponent("sessions/a.jsonl")
|
||||
let time = ISO8601DateFormatter().string(from: Date())
|
||||
var content = line(usage(100, 20), time: time)
|
||||
var legacy = CodexLocalLogParser()
|
||||
legacy.consume(content)
|
||||
for key in legacy.rows.keys { legacy.rows[key]?.hourlyTokens = nil }
|
||||
content.append(10)
|
||||
try content.write(to: url)
|
||||
let database = root.appendingPathComponent("usage-cache.sqlite")
|
||||
let store = try UsageScanStore(url: database)
|
||||
_ = try store.scan(url: url, source: "codex-v2", initial: legacy) { _, _ in }
|
||||
let provider = CodexLocalUsageProvider(root: root)
|
||||
let first = try #require(try await provider.fetch().localUsage)
|
||||
#expect(first.rows.first?.hourlyTokens?.values.reduce(0, +) == 120)
|
||||
#expect(try await CodexLocalUsageProvider(root: root).fetch().localUsage == first)
|
||||
#expect(try await provider.fetch().localUsage == first)
|
||||
}
|
||||
|
||||
@Test func repeatedClockHourKeepsTwoAbsoluteBuckets() throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles"))
|
||||
var parser = CodexLocalLogParser(calendar: calendar)
|
||||
parser.consume(line(usage(100, 20), time: "2026-11-01T08:30:00Z"))
|
||||
parser.consume(line(usage(150, 30), time: "2026-11-01T09:30:00Z"))
|
||||
let hours = try #require(parser.rows.values.first?.hourlyTokens)
|
||||
#expect(hours.count == 2)
|
||||
#expect(hours.values.reduce(0, +) == 180)
|
||||
#expect(hours.keys.allSatisfy { calendar.component(.hour, from: $0) == 1 })
|
||||
}
|
||||
|
||||
@Test(arguments: ["2026-10-04T12:00:00Z", "2026-04-05T12:00:00Z"])
|
||||
func halfHourDSTKeepsEveryParsedTokenInTheChart(_ timestamp: String) throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = try #require(TimeZone(identifier: "Australia/Lord_Howe"))
|
||||
let now = try #require(ISO8601DateFormatter().date(from: timestamp))
|
||||
let start = calendar.startOfDay(for: now)
|
||||
var parser = CodexLocalLogParser(calendar: calendar)
|
||||
var time = start
|
||||
var input = 0
|
||||
while time <= now {
|
||||
input += 10
|
||||
parser.consume(line(usage(input, 0), time: ISO8601DateFormatter().string(from: time)))
|
||||
time = time.addingTimeInterval(1800)
|
||||
}
|
||||
let rows = LocalUsageReport.aggregated(parser.rows.values)
|
||||
let series = UsageTimeSeries(rows: rows, mode: .daily, now: now, calendar: calendar)
|
||||
#expect(series.buckets.reduce(0) { $0 + $1.total } == LocalUsageReport.total(rows).total)
|
||||
let hours = Set(rows.flatMap { Array(($0.hourlyTokens ?? [:]).keys) })
|
||||
#expect(hours.isSubset(of: Set(series.buckets.map(\.start))))
|
||||
}
|
||||
|
||||
@Test func archiveDedupAndChangedFilesRefresh() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
@@ -6,26 +6,30 @@ import Testing
|
||||
@testable import AIUsage
|
||||
|
||||
@Suite struct UsageActivityTests {
|
||||
@Test func summaryFollowsTodayThisWeekAndAnnualCumulative() {
|
||||
@Test func summariesUseTodayAndRollingSevenThirtyAnd365Days() throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
calendar.firstWeekday = 2
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let now = formatter.date(from: "2026-09-22T12:00:00Z")!
|
||||
func row(_ date: String, _ value: Int, model: String = "a") -> ModelTokenUsage {
|
||||
ModelTokenUsage(day: formatter.date(from: date + "T00:00:00Z")!, model: model, input: value)
|
||||
let now = try #require(ISO8601DateFormatter().date(from: "2026-01-02T12:00:00Z"))
|
||||
let today = calendar.startOfDay(for: now)
|
||||
let rows = [-1, 0, 6, 7, 29, 30, 364, 365].map { ago in
|
||||
ModelTokenUsage(day: calendar.date(byAdding: .day, value: -ago, to: today)!, model: "a", input: 100)
|
||||
}
|
||||
let report = LocalUsageReport(rows: rows, fileCount: 1)
|
||||
for (mode, total) in [(UsageActivityMode.daily, 100), (.weekly, 200), (.monthly, 400), (.cumulative, 600)] {
|
||||
let summary = report.summary(mode: mode, now: now, calendar: calendar)
|
||||
#expect(LocalUsageReport.total(summary).total == total)
|
||||
#expect(report.summary(mode: mode, model: "missing", now: now, calendar: calendar).isEmpty)
|
||||
if mode == .weekly || mode == .monthly {
|
||||
let series = UsageTimeSeries(rows: rows, mode: mode, now: now, calendar: calendar)
|
||||
#expect(series.buckets.count == mode.days)
|
||||
#expect(series.buckets.reduce(0) { $0 + $1.total } == total)
|
||||
#expect(series.buckets.last?.start == today)
|
||||
}
|
||||
}
|
||||
let report = LocalUsageReport(rows: [row("2026-09-22", 1), row("2026-09-21", 10),
|
||||
row("2026-09-20", 100), row("2026-09-01", 1000), row("2026-08-31", 10000),
|
||||
row("2026-09-23", 99999), row("2026-09-22", 2, model: "b")], fileCount: 1)
|
||||
#expect(LocalUsageReport.total(report.summary(mode: .daily, now: now, calendar: calendar)).total == 3)
|
||||
#expect(LocalUsageReport.total(report.summary(mode: .weekly, now: now, calendar: calendar)).total == 13)
|
||||
#expect(LocalUsageReport.total(report.summary(mode: .cumulative, now: now, calendar: calendar)).total == 11113)
|
||||
#expect(LocalUsageReport.total(report.summary(mode: .cumulative, model: "a", now: now, calendar: calendar)).total == 11111)
|
||||
#expect(LocalUsageReport.total(report.selected(days: 365, now: now, calendar: calendar)).total == 11113)
|
||||
}
|
||||
|
||||
@Test(arguments: ["2026-01-01T12:00:00Z", "2026-03-01T12:00:00Z"])
|
||||
func cumulativeSummaryMatchesChartAcrossYearAndMonthBoundaries(_ timestamp: String) throws {
|
||||
func cumulativeSummaryMatchesDailyHeatmapAcrossBoundaries(_ timestamp: String) throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai"))
|
||||
let now = try #require(ISO8601DateFormatter().date(from: timestamp))
|
||||
@@ -42,51 +46,44 @@ import Testing
|
||||
let activity = UsageActivity(rows: report.selected(days: 365, model: model, now: now, calendar: calendar),
|
||||
now: now, calendar: calendar)
|
||||
let total = LocalUsageReport.total(summary).total
|
||||
#expect(total == activity.peak(for: .cumulative))
|
||||
#expect(total == activity.total)
|
||||
#expect(total == (model == nil ? 960 : model == "a" ? 330 : 630))
|
||||
#expect(activity.peak == (model == nil ? 320 : model == "a" ? 110 : 210))
|
||||
#expect(activity.weeks.flatMap(\.days).compactMap { $0 }.count == 365)
|
||||
#expect(LocalUsageReport.models(summary).reduce(0) { $0 + $1.total } == total)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func weeklyAndCumulativeColorsDistinguishUsageWithoutLightingZero() {
|
||||
#expect(UsageActivity.colorIntensity(value: 0, peak: 100, mode: .weekly) == 0)
|
||||
#expect(UsageActivity.colorIntensity(value: 100, peak: 100, mode: .cumulative) == 1)
|
||||
let values = [1, 10, 25, 50, 100]
|
||||
for mode in [UsageActivityMode.weekly, .cumulative] {
|
||||
let colors = values.map { UsageActivity.colorIntensity(value: $0, peak: 100, mode: mode) }
|
||||
#expect(colors == colors.sorted())
|
||||
#expect(Set(colors).count == values.count)
|
||||
}
|
||||
#expect(UsageActivity.colorIntensity(value: 10, peak: 100, mode: .weekly)
|
||||
> UsageActivity.colorIntensity(value: 10, peak: 100, mode: .cumulative))
|
||||
}
|
||||
@Test func modesShareYearAndWeeklyAccumulationIsNotRepeatedPerDay() throws {
|
||||
@Test(arguments: [("2026-03-08T20:00:00Z", 23), ("2026-11-01T20:00:00Z", 25), ("2026-10-03T20:00:00Z", 24)])
|
||||
func hourlyChartHonorsDayLengthAndRepeatedHours(_ timestamp: String, _ count: Int) throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
calendar.firstWeekday = 2
|
||||
let now = ISO8601DateFormatter().date(from: "2026-09-22T12:00:00Z")!
|
||||
let today = calendar.startOfDay(for: now)
|
||||
func row(_ ago: Int, _ count: Int) -> ModelTokenUsage {
|
||||
ModelTokenUsage(day: calendar.date(byAdding: .day, value: -ago, to: today)!, model: "test", input: count)
|
||||
}
|
||||
let activity = UsageActivity(rows: [row(0, 20), row(1, 10), row(2, 100), row(365, 999), row(-1, 999)], now: now, calendar: calendar)
|
||||
#expect(activity.weeks.flatMap(\.days).compactMap { $0 }.count == 365)
|
||||
#expect(activity.weeks.last?.total == 30)
|
||||
#expect(activity.weeks.last?.cumulative == 130)
|
||||
#expect(activity.weeks.last?.end == today)
|
||||
#expect(activity.peak(for: .daily) == 100)
|
||||
#expect(activity.peak(for: .weekly) == 100)
|
||||
#expect(activity.peak(for: .cumulative) == 130)
|
||||
#expect(activity.weeks.reduce(0) { $0 + $1.total } == 130)
|
||||
let accumulated = activity.weeks.map(\.cumulative)
|
||||
#expect(accumulated == accumulated.sorted())
|
||||
calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles"))
|
||||
let now = try #require(ISO8601DateFormatter().date(from: timestamp))
|
||||
let empty = UsageTimeSeries(rows: [], mode: .daily, now: now, calendar: calendar)
|
||||
#expect(empty.buckets.count == count)
|
||||
#expect(Set(empty.buckets.map(\.id)).count == count)
|
||||
let hour1 = empty.buckets[1].start
|
||||
let hour2 = empty.buckets[2].start
|
||||
let row = ModelTokenUsage(day: calendar.startOfDay(for: now), model: "a", input: 100, output: 20,
|
||||
hourlyTokens: [hour1: 50, hour2: 70])
|
||||
let series = UsageTimeSeries(rows: [row], mode: .daily, now: now, calendar: calendar)
|
||||
#expect(series.buckets[1].total == 50)
|
||||
#expect(series.buckets[2].total == 70)
|
||||
#expect(series.buckets.reduce(0) { $0 + $1.total } == row.total)
|
||||
#expect(!series.hasMissingHourlyData)
|
||||
}
|
||||
|
||||
@Test func emptyAndSmallBarsHaveHonestCellCounts() {
|
||||
#expect(UsageActivity.filledCells(value: 0, peak: 100) == 0)
|
||||
#expect(UsageActivity.filledCells(value: 1, peak: 100) == 1)
|
||||
#expect(UsageActivity.filledCells(value: 50, peak: 100) == 4)
|
||||
#expect(UsageActivity.filledCells(value: 100, peak: 100) == 7)
|
||||
#expect(UsageActivity(rows: []).peak(for: .cumulative) == 0)
|
||||
@Test func legacyDataDoesNotInventMidnightUsage() throws {
|
||||
let now = Date()
|
||||
let old = ModelTokenUsage(day: Calendar.current.startOfDay(for: now), model: "a", input: 100)
|
||||
var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(old)) as? [String: Any])
|
||||
json.removeValue(forKey: "hourlyTokens")
|
||||
let restored = try JSONDecoder().decode(ModelTokenUsage.self, from: JSONSerialization.data(withJSONObject: json))
|
||||
let series = UsageTimeSeries(rows: [restored], mode: .daily, now: now)
|
||||
#expect(series.hasMissingHourlyData)
|
||||
#expect(series.buckets.allSatisfy { $0.total == 0 })
|
||||
let hourly = ModelTokenUsage(day: old.day, model: "a", input: 100, hourlyTokens: [old.day: 100])
|
||||
#expect(LocalUsageReport.aggregated([old, hourly]).first?.hourlyTokens == nil)
|
||||
#expect(LocalUsageReport.aggregated([hourly, old]).first?.hourlyTokens == nil)
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ struct LanguageSupportTests {
|
||||
#expect(catalog.sourceKeys == Translations.shared.sourceKeys)
|
||||
for key in ["语言", "应用 UI 语言", "自动检测", "搜索语言", "没有匹配的语言",
|
||||
"额度显示", "数字单位", "货币", "估算费用",
|
||||
"30 天", "按小时统计", "按天统计", "暂无小时明细,请刷新用量。",
|
||||
"AI 用量与额度共用此间隔;新数据到达后更新菜单栏。",
|
||||
"价格来源:models.dev;人民币按缓存汇率换算。",
|
||||
"按模型基础 API 单价估算,不含阶梯加价,非订阅账单。",
|
||||
|
||||
Reference in new issue
Block a user