Add all-time usage statistics tab

Extends the existing Stats sheet (⌘⇧S) with a Session/All-Time segmented
picker. All-Time aggregates tokens, cost, and message counts across every
saved conversation, broken down by model and by conversation, using the
modelId already stored per message — no schema change needed.
This commit is contained in:
2026-07-28 12:35:45 +02:00
parent 71e8a49d77
commit 0642f3746f
4 changed files with 489 additions and 71 deletions
+95
View File
@@ -0,0 +1,95 @@
//
// UsageStats.swift
// oAI
//
// All-time usage statistics (aggregated from the messages table)
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use including selling oAI or any part of
// it, standalone or bundled into another product or service
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import Foundation
struct UsageStats: Sendable {
var totalMessages: Int = 0
var totalTokens: Int = 0
var totalCost: Double = 0.0
var hasCostData: Bool = false
var firstMessageDate: Date?
var lastMessageDate: Date?
var totalTokensDisplay: String {
if totalTokens >= 1_000_000 {
return String(format: "%.1fM", Double(totalTokens) / 1_000_000)
} else if totalTokens >= 1000 {
return String(format: "%.1fK", Double(totalTokens) / 1000)
} else {
return "\(totalTokens)"
}
}
var totalCostDisplay: String {
hasCostData ? String(format: "$%.4f", totalCost) : "N/A"
}
}
struct ModelUsageStat: Identifiable, Sendable {
var id: String { modelId }
let modelId: String
var messageCount: Int
var totalTokens: Int
var totalCost: Double
var hasCostData: Bool
var lastUsed: Date
var totalTokensDisplay: String {
if totalTokens >= 1_000_000 {
return String(format: "%.1fM", Double(totalTokens) / 1_000_000)
} else if totalTokens >= 1000 {
return String(format: "%.1fK", Double(totalTokens) / 1000)
} else {
return "\(totalTokens)"
}
}
var totalCostDisplay: String {
hasCostData ? String(format: "$%.4f", totalCost) : "N/A"
}
}
struct ConversationUsageStat: Identifiable, Sendable {
let conversationId: UUID
var id: UUID { conversationId }
var name: String
var messageCount: Int
var totalTokens: Int
var totalCost: Double
var hasCostData: Bool
var totalTokensDisplay: String {
if totalTokens >= 1_000_000 {
return String(format: "%.1fM", Double(totalTokens) / 1_000_000)
} else if totalTokens >= 1000 {
return String(format: "%.1fK", Double(totalTokens) / 1000)
} else {
return "\(totalTokens)"
}
}
var totalCostDisplay: String {
hasCostData ? String(format: "$%.4f", totalCost) : "N/A"
}
}
+112
View File
@@ -576,6 +576,118 @@ final class DatabaseService: Sendable {
}
}
// MARK: - Usage Statistics
nonisolated func getOverallUsageStats() throws -> UsageStats {
try dbQueue.read { db in
guard let row = try Row.fetchOne(db, sql: """
SELECT COUNT(*) AS cnt,
COALESCE(SUM(tokens), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MIN(timestamp) AS minTs,
MAX(timestamp) AS maxTs
FROM messages
""")
else {
return UsageStats()
}
let costCount: Int = row["costCount"]
let minTs: String? = row["minTs"]
let maxTs: String? = row["maxTs"]
return UsageStats(
totalMessages: row["cnt"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
firstMessageDate: minTs.flatMap { Self.isoDate(from: $0) },
lastMessageDate: maxTs.flatMap { Self.isoDate(from: $0) }
)
}
}
nonisolated func getUsageByModel() throws -> [ModelUsageStat] {
try dbQueue.read { db in
let rows = try Row.fetchAll(db, sql: """
SELECT modelId,
COUNT(*) AS cnt,
COALESCE(SUM(tokens), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MAX(timestamp) AS lastUsed
FROM messages
WHERE modelId IS NOT NULL
GROUP BY modelId
""")
let stats: [ModelUsageStat] = rows.compactMap { row in
guard let modelId: String = row["modelId"],
let lastUsedString: String = row["lastUsed"],
let lastUsed = Self.isoDate(from: lastUsedString)
else { return nil }
let costCount: Int = row["costCount"]
return ModelUsageStat(
modelId: modelId,
messageCount: row["cnt"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
lastUsed: lastUsed
)
}
return stats.sorted { lhs, rhs in
if lhs.hasCostData || rhs.hasCostData {
return lhs.totalCost > rhs.totalCost
}
return lhs.totalTokens > rhs.totalTokens
}
}
}
nonisolated func getUsageByConversation(limit: Int = 20) throws -> [ConversationUsageStat] {
try dbQueue.read { db in
let rows = try Row.fetchAll(db, sql: """
SELECT m.conversationId AS conversationId,
c.name AS name,
COUNT(*) AS cnt,
COALESCE(SUM(m.tokens), 0) AS tokens,
COALESCE(SUM(m.cost), 0) AS cost,
COUNT(m.cost) AS costCount
FROM messages m
JOIN conversations c ON m.conversationId = c.id
GROUP BY m.conversationId
""")
let stats: [ConversationUsageStat] = rows.compactMap { row in
guard let conversationIdString: String = row["conversationId"],
let conversationId = UUID(uuidString: conversationIdString)
else { return nil }
let costCount: Int = row["costCount"]
return ConversationUsageStat(
conversationId: conversationId,
name: row["name"],
messageCount: row["cnt"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0
)
}
let sorted = stats.sorted { lhs, rhs in
if lhs.hasCostData || rhs.hasCostData {
return lhs.totalCost > rhs.totalCost
}
return lhs.totalTokens > rhs.totalTokens
}
return Array(sorted.prefix(limit))
}
}
nonisolated func deleteConversation(id: UUID) throws -> Bool {
Log.db.info("Deleting conversation \(id.uuidString)")
return try dbQueue.write { db in
+183 -71
View File
@@ -23,87 +23,44 @@
import SwiftUI
private enum StatsTab: String, CaseIterable {
case session = "Session"
case allTime = "All-Time"
}
struct StatsView: View {
let stats: SessionStats
let model: ModelInfo?
let provider: Settings.Provider
@Environment(\.dismiss) var dismiss
@State private var selectedTab: StatsTab = .session
@State private var overallStats = UsageStats()
@State private var modelStats: [ModelUsageStat] = []
@State private var conversationStats: [ConversationUsageStat] = []
var body: some View {
NavigationStack {
List {
Section("Session Info") {
StatRow(label: "Provider", value: provider.displayName)
StatRow(label: "Model", value: model?.name ?? "None selected")
StatRow(label: "Messages", value: "\(stats.messageCount)")
VStack(spacing: 0) {
Picker("", selection: $selectedTab) {
Text("Session").tag(StatsTab.session)
Text("All-Time").tag(StatsTab.allTime)
}
Section("Token Usage") {
StatRow(label: "Input Tokens", value: stats.totalInputTokens.formatted())
StatRow(label: "Output Tokens", value: stats.totalOutputTokens.formatted())
StatRow(label: "Total Tokens", value: stats.totalTokens.formatted())
if stats.totalTokens > 0 {
HStack {
Text("Token Distribution")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
GeometryReader { geo in
HStack(spacing: 0) {
Rectangle()
.fill(Color.blue)
.frame(width: geo.size.width * CGFloat(stats.totalInputTokens) / CGFloat(stats.totalTokens))
Rectangle()
.fill(Color.green)
.frame(width: geo.size.width * CGFloat(stats.totalOutputTokens) / CGFloat(stats.totalTokens))
}
}
.frame(height: 20)
.cornerRadius(4)
}
}
}
Section("Costs") {
StatRow(label: "Total Cost", value: stats.totalCostDisplay)
if stats.messageCount > 0 {
StatRow(label: "Avg per Message", value: stats.averageCostDisplay)
}
}
if let model = model {
Section("Model Details") {
StatRow(label: "Context Length", value: model.contextLengthDisplay)
StatRow(label: "Prompt Price", value: model.promptPriceDisplay + "/1M tokens")
StatRow(label: "Completion Price", value: model.completionPriceDisplay + "/1M tokens")
HStack {
Text("Capabilities")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
HStack(spacing: 8) {
if model.capabilities.vision {
CapabilityBadge(icon: "👁️", label: "Vision")
}
if model.capabilities.tools {
CapabilityBadge(icon: "🔧", label: "Tools")
}
if model.capabilities.online {
CapabilityBadge(icon: "🌐", label: "Online")
}
}
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 4)
Group {
switch selectedTab {
case .session:
sessionList
case .allTime:
allTimeList
}
}
}
#if os(iOS)
.listStyle(.insetGrouped)
#else
.listStyle(.sidebar)
#endif
.navigationTitle("Statistics")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
@@ -112,8 +69,163 @@ struct StatsView: View {
}
}
}
.frame(minWidth: 500, idealWidth: 550, minHeight: 450, idealHeight: 500)
.frame(minWidth: 500, idealWidth: 550, minHeight: 450, idealHeight: 500)
}
.task {
loadAllTimeStats()
}
}
private var sessionList: some View {
List {
Section("Session Info") {
StatRow(label: "Provider", value: provider.displayName)
StatRow(label: "Model", value: model?.name ?? "None selected")
StatRow(label: "Messages", value: "\(stats.messageCount)")
}
Section("Token Usage") {
StatRow(label: "Input Tokens", value: stats.totalInputTokens.formatted())
StatRow(label: "Output Tokens", value: stats.totalOutputTokens.formatted())
StatRow(label: "Total Tokens", value: stats.totalTokens.formatted())
if stats.totalTokens > 0 {
HStack {
Text("Token Distribution")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
GeometryReader { geo in
HStack(spacing: 0) {
Rectangle()
.fill(Color.blue)
.frame(width: geo.size.width * CGFloat(stats.totalInputTokens) / CGFloat(stats.totalTokens))
Rectangle()
.fill(Color.green)
.frame(width: geo.size.width * CGFloat(stats.totalOutputTokens) / CGFloat(stats.totalTokens))
}
}
.frame(height: 20)
.cornerRadius(4)
}
}
}
Section("Costs") {
StatRow(label: "Total Cost", value: stats.totalCostDisplay)
if stats.messageCount > 0 {
StatRow(label: "Avg per Message", value: stats.averageCostDisplay)
}
}
if let model = model {
Section("Model Details") {
StatRow(label: "Context Length", value: model.contextLengthDisplay)
StatRow(label: "Prompt Price", value: model.promptPriceDisplay + "/1M tokens")
StatRow(label: "Completion Price", value: model.completionPriceDisplay + "/1M tokens")
HStack {
Text("Capabilities")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
HStack(spacing: 8) {
if model.capabilities.vision {
CapabilityBadge(icon: "👁️", label: "Vision")
}
if model.capabilities.tools {
CapabilityBadge(icon: "🔧", label: "Tools")
}
if model.capabilities.online {
CapabilityBadge(icon: "🌐", label: "Online")
}
}
}
}
}
}
#if os(iOS)
.listStyle(.insetGrouped)
#else
.listStyle(.sidebar)
#endif
}
private var allTimeList: some View {
List {
Section("Totals") {
StatRow(label: "Total Messages", value: "\(overallStats.totalMessages)")
StatRow(label: "Total Tokens", value: overallStats.totalTokensDisplay)
StatRow(label: "Total Cost", value: overallStats.totalCostDisplay)
if let first = overallStats.firstMessageDate {
StatRow(label: "Since", value: first.formatted(date: .abbreviated, time: .omitted))
}
}
if !modelStats.isEmpty {
Section("By Model") {
ForEach(modelStats) { stat in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(stat.modelId)
.font(.body)
.lineLimit(1)
Spacer()
Text(stat.totalCostDisplay)
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
}
HStack {
Text("^[\(stat.messageCount) message](inflect: true) · \(stat.totalTokensDisplay) tokens")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 2)
}
}
}
if !conversationStats.isEmpty {
Section("Top Conversations") {
ForEach(conversationStats) { stat in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(stat.name)
.font(.body)
.lineLimit(1)
Spacer()
Text(stat.totalCostDisplay)
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
}
Text("^[\(stat.messageCount) message](inflect: true) · \(stat.totalTokensDisplay) tokens")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 2)
}
}
}
if overallStats.totalMessages == 0 {
Section {
Text("No usage data yet")
.foregroundColor(.secondary)
}
}
}
#if os(iOS)
.listStyle(.insetGrouped)
#else
.listStyle(.sidebar)
#endif
}
private func loadAllTimeStats() {
overallStats = (try? DatabaseService.shared.getOverallUsageStats()) ?? UsageStats()
modelStats = (try? DatabaseService.shared.getUsageByModel()) ?? []
conversationStats = (try? DatabaseService.shared.getUsageByConversation()) ?? []
}
}
+99
View File
@@ -121,3 +121,102 @@ struct DatabaseServiceConversationTests {
#expect(loaded?.1.first?.content == "hello")
}
}
@Suite("DatabaseService usage statistics, against a throwaway in-memory queue")
struct DatabaseServiceUsageStatsTests {
@Test("Overall stats aggregate tokens, cost, and message count across conversations")
func overallStatsAggregate() throws {
let db = DatabaseService.makeInMemory()
_ = try db.saveConversation(name: "Chat A", messages: [
Message(role: .user, content: "hi", tokens: 10, modelId: "claude-sonnet"),
Message(role: .assistant, content: "hello", tokens: 20, cost: 0.01, modelId: "claude-sonnet"),
])
_ = try db.saveConversation(name: "Chat B", messages: [
Message(role: .user, content: "hey", tokens: 5, modelId: "gpt-4"),
Message(role: .assistant, content: "hi", tokens: 15, cost: 0.02, modelId: "gpt-4"),
])
let stats = try db.getOverallUsageStats()
#expect(stats.totalMessages == 4)
#expect(stats.totalTokens == 50)
#expect(stats.hasCostData == true)
#expect(abs(stats.totalCost - 0.03) < 0.0001)
}
@Test("Overall stats report no cost data when no message has a cost")
func overallStatsNoCostData() throws {
let db = DatabaseService.makeInMemory()
_ = try db.saveConversation(name: "Chat", messages: [
Message(role: .user, content: "hi", tokens: 10),
])
let stats = try db.getOverallUsageStats()
#expect(stats.hasCostData == false)
#expect(stats.totalCost == 0)
}
@Test("Usage by model groups messages by modelId and sums their tokens/cost")
func usageByModelGroups() throws {
let db = DatabaseService.makeInMemory()
_ = try db.saveConversation(name: "Chat A", messages: [
Message(role: .assistant, content: "a1", tokens: 20, cost: 0.01, modelId: "claude-sonnet"),
])
_ = try db.saveConversation(name: "Chat B", messages: [
Message(role: .assistant, content: "b1", tokens: 15, cost: 0.02, modelId: "gpt-4"),
Message(role: .assistant, content: "b2", tokens: 5, cost: 0.02, modelId: "gpt-4"),
])
let byModel = try db.getUsageByModel()
#expect(byModel.count == 2)
let gpt4 = byModel.first { $0.modelId == "gpt-4" }
#expect(gpt4?.messageCount == 2)
#expect(gpt4?.totalTokens == 20)
#expect(abs((gpt4?.totalCost ?? 0) - 0.04) < 0.0001)
// gpt-4 has higher total cost than claude-sonnet, so it should sort first
#expect(byModel.first?.modelId == "gpt-4")
}
@Test("Usage by model excludes messages with no modelId")
func usageByModelExcludesNilModelId() throws {
let db = DatabaseService.makeInMemory()
_ = try db.saveConversation(name: "Chat", messages: [
Message(role: .user, content: "hi", tokens: 10),
Message(role: .assistant, content: "hello", tokens: 20, modelId: "claude-sonnet"),
])
let byModel = try db.getUsageByModel()
#expect(byModel.count == 1)
#expect(byModel.first?.modelId == "claude-sonnet")
}
@Test("Usage by conversation joins conversation names and sorts by cost descending")
func usageByConversationSortsByCost() throws {
let db = DatabaseService.makeInMemory()
_ = try db.saveConversation(name: "Cheap Chat", messages: [
Message(role: .assistant, content: "a", tokens: 10, cost: 0.001, modelId: "m"),
])
_ = try db.saveConversation(name: "Expensive Chat", messages: [
Message(role: .assistant, content: "b", tokens: 10, cost: 0.05, modelId: "m"),
])
let byConversation = try db.getUsageByConversation()
#expect(byConversation.count == 2)
#expect(byConversation.first?.name == "Expensive Chat")
}
@Test("Usage by conversation respects the limit parameter")
func usageByConversationRespectsLimit() throws {
let db = DatabaseService.makeInMemory()
for i in 0..<5 {
_ = try db.saveConversation(name: "Chat \(i)", messages: [
Message(role: .assistant, content: "a", tokens: 10, cost: Double(i) * 0.01, modelId: "m"),
])
}
let byConversation = try db.getUsageByConversation(limit: 3)
#expect(byConversation.count == 3)
}
}