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:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
nonisolated func deleteConversation(id: UUID) throws -> Bool {
|
||||||
Log.db.info("Deleting conversation \(id.uuidString)")
|
Log.db.info("Deleting conversation \(id.uuidString)")
|
||||||
return try dbQueue.write { db in
|
return try dbQueue.write { db in
|
||||||
|
|||||||
@@ -23,87 +23,44 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private enum StatsTab: String, CaseIterable {
|
||||||
|
case session = "Session"
|
||||||
|
case allTime = "All-Time"
|
||||||
|
}
|
||||||
|
|
||||||
struct StatsView: View {
|
struct StatsView: View {
|
||||||
let stats: SessionStats
|
let stats: SessionStats
|
||||||
let model: ModelInfo?
|
let model: ModelInfo?
|
||||||
let provider: Settings.Provider
|
let provider: Settings.Provider
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
VStack(spacing: 0) {
|
||||||
Section("Session Info") {
|
Picker("", selection: $selectedTab) {
|
||||||
StatRow(label: "Provider", value: provider.displayName)
|
Text("Session").tag(StatsTab.session)
|
||||||
StatRow(label: "Model", value: model?.name ?? "None selected")
|
Text("All-Time").tag(StatsTab.allTime)
|
||||||
StatRow(label: "Messages", value: "\(stats.messageCount)")
|
|
||||||
}
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
Section("Token Usage") {
|
.labelsHidden()
|
||||||
StatRow(label: "Input Tokens", value: stats.totalInputTokens.formatted())
|
.padding(.horizontal, 16)
|
||||||
StatRow(label: "Output Tokens", value: stats.totalOutputTokens.formatted())
|
.padding(.top, 12)
|
||||||
StatRow(label: "Total Tokens", value: stats.totalTokens.formatted())
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
if stats.totalTokens > 0 {
|
Group {
|
||||||
HStack {
|
switch selectedTab {
|
||||||
Text("Token Distribution")
|
case .session:
|
||||||
.font(.caption)
|
sessionList
|
||||||
.foregroundColor(.secondary)
|
case .allTime:
|
||||||
Spacer()
|
allTimeList
|
||||||
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
|
|
||||||
.navigationTitle("Statistics")
|
.navigationTitle("Statistics")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
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()) ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,3 +121,102 @@ struct DatabaseServiceConversationTests {
|
|||||||
#expect(loaded?.1.first?.content == "hello")
|
#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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user