diff --git a/oAI/Models/UsageStats.swift b/oAI/Models/UsageStats.swift new file mode 100644 index 0000000..a35fa7d --- /dev/null +++ b/oAI/Models/UsageStats.swift @@ -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 +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + + +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" + } +} diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index a56b195..1d1150b 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -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 diff --git a/oAI/Views/Screens/StatsView.swift b/oAI/Views/Screens/StatsView.swift index b60e136..dbd9834 100644 --- a/oAI/Views/Screens/StatsView.swift +++ b/oAI/Views/Screens/StatsView.swift @@ -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()) ?? [] } } diff --git a/oAITests/DatabaseServiceTests.swift b/oAITests/DatabaseServiceTests.swift index 680f738..d9835cf 100644 --- a/oAITests/DatabaseServiceTests.swift +++ b/oAITests/DatabaseServiceTests.swift @@ -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) + } +}