Track usage independent of conversation save state; fix Analytics toolbar button
Conversation text is still only persisted when explicitly saved (⌘S), but tokens/cost/model/provider are now logged to a new usage_events table for every completed AI response regardless — the Analytics view now reads from this table instead of messages, so it reflects real usage even for conversations that were never saved. Adds a By Provider chart mode alongside Over Time/By Model. Also fixes the Analytics entry point: ToolbarItem(placement: .navigation) silently doesn't render in a plain .sheet-presented NavigationStack on macOS. Moved the button inline next to the segmented picker, matching the codebase's existing convention (e.g. the model-favorites star filter).
This commit is contained in:
@@ -1,6 +1,40 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"By Provider" : {
|
||||
"localizations" : {
|
||||
"da" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Efter udbyder"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nach Anbieter"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Par fournisseur"
|
||||
}
|
||||
},
|
||||
"nb" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Etter leverandør"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Efter leverantör"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Today" : {
|
||||
"localizations" : {
|
||||
"da" : {
|
||||
|
||||
@@ -108,6 +108,47 @@ struct ModelUsageStat: Identifiable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage grouped by provider (openrouter/anthropic/openai/ollama/appleOnDevice), from `usage_events`.
|
||||
struct ProviderUsageStat: Identifiable, Sendable {
|
||||
var id: String { provider }
|
||||
let provider: String
|
||||
var questionCount: Int
|
||||
var totalTokens: Int
|
||||
var totalCost: Double
|
||||
var hasCostData: Bool
|
||||
var lastUsed: Date
|
||||
|
||||
nonisolated init(
|
||||
provider: String,
|
||||
questionCount: Int,
|
||||
totalTokens: Int,
|
||||
totalCost: Double,
|
||||
hasCostData: Bool,
|
||||
lastUsed: Date
|
||||
) {
|
||||
self.provider = provider
|
||||
self.questionCount = questionCount
|
||||
self.totalTokens = totalTokens
|
||||
self.totalCost = totalCost
|
||||
self.hasCostData = hasCostData
|
||||
self.lastUsed = lastUsed
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
/// One day's usage totals, for time-series charting in the Analytics view.
|
||||
struct DailyUsageStat: Identifiable, Sendable {
|
||||
var id: Date { day }
|
||||
|
||||
@@ -65,6 +65,19 @@ struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
var modelId: String?
|
||||
}
|
||||
|
||||
struct UsageEventRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "usage_events"
|
||||
|
||||
var id: String
|
||||
var timestamp: String
|
||||
var provider: String
|
||||
var modelId: String
|
||||
var promptTokens: Int?
|
||||
var completionTokens: Int?
|
||||
var cost: Double?
|
||||
var conversationId: String?
|
||||
}
|
||||
|
||||
struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "settings"
|
||||
|
||||
@@ -404,6 +417,26 @@ final class DatabaseService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
migrator.registerMigration("v13") { db in
|
||||
// Usage tracking independent of conversation persistence: conversation *text* is only
|
||||
// ever saved when the user explicitly saves (⌘S), but usage (tokens/cost/model/provider)
|
||||
// should be recorded for every completed AI response regardless — one row per finished
|
||||
// generation. conversationId is a best-effort link (set only if the conversation had
|
||||
// already been saved at least once at logging time) and is not a foreign key, since the
|
||||
// referenced conversation may never exist.
|
||||
try db.create(table: "usage_events") { t in
|
||||
t.column("id", .text).primaryKey()
|
||||
t.column("timestamp", .text).notNull()
|
||||
t.column("provider", .text).notNull()
|
||||
t.column("modelId", .text).notNull()
|
||||
t.column("promptTokens", .integer)
|
||||
t.column("completionTokens", .integer)
|
||||
t.column("cost", .double)
|
||||
t.column("conversationId", .text)
|
||||
}
|
||||
try db.create(index: "idx_usage_events_timestamp", on: "usage_events", columns: ["timestamp"])
|
||||
}
|
||||
|
||||
return migrator
|
||||
}
|
||||
|
||||
@@ -959,6 +992,204 @@ final class DatabaseService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Usage Events (save-independent usage tracking)
|
||||
|
||||
/// Records one completed AI generation's usage — independent of whether the conversation it
|
||||
/// belongs to is ever explicitly saved. `conversationId` is a best-effort link, set only if the
|
||||
/// conversation had already been saved at least once when this was logged.
|
||||
nonisolated func logUsageEvent(
|
||||
provider: String,
|
||||
modelId: String,
|
||||
promptTokens: Int?,
|
||||
completionTokens: Int?,
|
||||
cost: Double?,
|
||||
conversationId: UUID?
|
||||
) throws {
|
||||
let record = UsageEventRecord(
|
||||
id: UUID().uuidString,
|
||||
timestamp: Self.isoString(from: Date()),
|
||||
provider: provider,
|
||||
modelId: modelId,
|
||||
promptTokens: promptTokens,
|
||||
completionTokens: completionTokens,
|
||||
cost: cost,
|
||||
conversationId: conversationId?.uuidString
|
||||
)
|
||||
try dbQueue.write { db in
|
||||
try record.insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// Overall usage-event totals (each row = one completed generation = one "question").
|
||||
nonisolated func getUsageEventTotals(from: Date? = nil, to: Date? = nil) throws -> UsageStats {
|
||||
try dbQueue.read { db in
|
||||
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
|
||||
let sql = """
|
||||
SELECT COUNT(*) AS cnt,
|
||||
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost,
|
||||
COUNT(cost) AS costCount,
|
||||
MIN(timestamp) AS minTs,
|
||||
MAX(timestamp) AS maxTs
|
||||
FROM usage_events
|
||||
\(whereSQL)
|
||||
"""
|
||||
guard let row = try Row.fetchOne(db, sql: sql, arguments: StatementArguments(arguments))
|
||||
else {
|
||||
return UsageStats()
|
||||
}
|
||||
|
||||
let costCount: Int = row["costCount"]
|
||||
let cnt: Int = row["cnt"]
|
||||
let minTs: String? = row["minTs"]
|
||||
let maxTs: String? = row["maxTs"]
|
||||
|
||||
return UsageStats(
|
||||
totalMessages: cnt,
|
||||
totalQuestions: cnt,
|
||||
totalTokens: row["tokens"],
|
||||
totalCost: row["cost"],
|
||||
hasCostData: costCount > 0,
|
||||
firstMessageDate: minTs.flatMap { Self.isoDate(from: $0) },
|
||||
lastMessageDate: maxTs.flatMap { Self.isoDate(from: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage events grouped by model.
|
||||
nonisolated func getUsageEventsByModel(from: Date? = nil, to: Date? = nil) throws -> [ModelUsageStat] {
|
||||
try dbQueue.read { db in
|
||||
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
|
||||
let sql = """
|
||||
SELECT modelId,
|
||||
COUNT(*) AS cnt,
|
||||
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost,
|
||||
COUNT(cost) AS costCount,
|
||||
MAX(timestamp) AS lastUsed
|
||||
FROM usage_events
|
||||
\(whereSQL)
|
||||
GROUP BY modelId
|
||||
"""
|
||||
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
|
||||
|
||||
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"]
|
||||
let cnt: Int = row["cnt"]
|
||||
return ModelUsageStat(
|
||||
modelId: modelId,
|
||||
messageCount: cnt,
|
||||
questionCount: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage events grouped by provider.
|
||||
nonisolated func getUsageEventsByProvider(from: Date? = nil, to: Date? = nil) throws -> [ProviderUsageStat] {
|
||||
try dbQueue.read { db in
|
||||
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
|
||||
let sql = """
|
||||
SELECT provider,
|
||||
COUNT(*) AS cnt,
|
||||
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost,
|
||||
COUNT(cost) AS costCount,
|
||||
MAX(timestamp) AS lastUsed
|
||||
FROM usage_events
|
||||
\(whereSQL)
|
||||
GROUP BY provider
|
||||
"""
|
||||
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
|
||||
|
||||
let stats: [ProviderUsageStat] = rows.compactMap { row in
|
||||
guard let provider: String = row["provider"],
|
||||
let lastUsedString: String = row["lastUsed"],
|
||||
let lastUsed = Self.isoDate(from: lastUsedString)
|
||||
else { return nil }
|
||||
|
||||
let costCount: Int = row["costCount"]
|
||||
return ProviderUsageStat(
|
||||
provider: provider,
|
||||
questionCount: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage events bucketed by calendar day (GMT, matching `isoStyle`), for the Analytics view.
|
||||
nonisolated func getDailyUsageEvents(from: Date, to: Date) throws -> [DailyUsageStat] {
|
||||
try dbQueue.read { db in
|
||||
let sql = """
|
||||
SELECT substr(timestamp, 1, 10) AS day,
|
||||
COUNT(*) AS cnt,
|
||||
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
|
||||
COALESCE(SUM(cost), 0) AS cost,
|
||||
COUNT(cost) AS costCount
|
||||
FROM usage_events
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
"""
|
||||
let rows = try Row.fetchAll(
|
||||
db, sql: sql,
|
||||
arguments: [Self.isoString(from: from), Self.isoString(from: to)]
|
||||
)
|
||||
|
||||
var gmtCalendar = Calendar(identifier: .gregorian)
|
||||
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
|
||||
|
||||
return rows.compactMap { row -> DailyUsageStat? in
|
||||
guard let dayString: String = row["day"] else { return nil }
|
||||
let parts = dayString.split(separator: "-").compactMap { Int($0) }
|
||||
guard parts.count == 3 else { return nil }
|
||||
var components = DateComponents()
|
||||
components.year = parts[0]
|
||||
components.month = parts[1]
|
||||
components.day = parts[2]
|
||||
guard let day = gmtCalendar.date(from: components) else { return nil }
|
||||
|
||||
let costCount: Int = row["costCount"]
|
||||
let cnt: Int = row["cnt"]
|
||||
return DailyUsageStat(
|
||||
day: day,
|
||||
messageCount: cnt,
|
||||
questionCount: cnt,
|
||||
totalTokens: row["tokens"],
|
||||
totalCost: row["cost"],
|
||||
hasCostData: costCount > 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func getUsageByConversation(limit: Int = 20) throws -> [ConversationUsageStat] {
|
||||
try dbQueue.read { db in
|
||||
let rows = try Row.fetchAll(db, sql: """
|
||||
|
||||
@@ -1153,6 +1153,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
|
||||
messages[index].cost = cost
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1216,6 +1217,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
|
||||
messages[index].cost = cost
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1572,6 +1574,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
let cost = usage.rawCostUSD
|
||||
messages[index].cost = cost
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -1863,6 +1866,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
outputTokens: usage.completionTokens,
|
||||
cost: cost
|
||||
)
|
||||
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
} else {
|
||||
let assistantMessage = Message(
|
||||
@@ -1890,6 +1894,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
outputTokens: usage.completionTokens,
|
||||
cost: cost
|
||||
)
|
||||
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2610,6 +2615,24 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
return calculateCost(usage: usage, pricing: pricing)
|
||||
}
|
||||
|
||||
/// Records usage independent of whether this conversation ever gets explicitly saved (⌘S) —
|
||||
/// conversation *text* is opt-in, but tokens/cost/model/provider are tracked for every
|
||||
/// completed generation regardless, so the Analytics view reflects real usage either way.
|
||||
private func logUsageEvent(modelId: String, promptTokens: Int?, completionTokens: Int?, cost: Double?) {
|
||||
do {
|
||||
try DatabaseService.shared.logUsageEvent(
|
||||
provider: currentProvider.rawValue,
|
||||
modelId: modelId,
|
||||
promptTokens: promptTokens,
|
||||
completionTokens: completionTokens,
|
||||
cost: cost,
|
||||
conversationId: currentConversationId
|
||||
)
|
||||
} catch {
|
||||
Log.db.error("Failed to log usage event: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Summarize a chunk of messages into a concise summary
|
||||
private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
|
||||
guard let provider = providerRegistry.getProvider(for: currentProvider),
|
||||
|
||||
@@ -43,12 +43,21 @@ struct StatsView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Session").tag(StatsTab.session)
|
||||
Text("All-Time").tag(StatsTab.allTime)
|
||||
HStack(spacing: 8) {
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Session").tag(StatsTab.session)
|
||||
Text("All-Time").tag(StatsTab.allTime)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
|
||||
Button {
|
||||
showAnalytics = true
|
||||
} label: {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
}
|
||||
.help("View detailed usage analytics")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 4)
|
||||
@@ -64,14 +73,6 @@ struct StatsView: View {
|
||||
}
|
||||
.navigationTitle("Statistics")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
Button {
|
||||
showAnalytics = true
|
||||
} label: {
|
||||
Label("Analytics", systemImage: "chart.bar.xaxis")
|
||||
}
|
||||
.help("View detailed usage analytics")
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
|
||||
@@ -51,6 +51,7 @@ private enum AnalyticsMetric: String, CaseIterable, Identifiable {
|
||||
private enum AnalyticsChartMode: String, CaseIterable, Identifiable {
|
||||
case overTime
|
||||
case byModel
|
||||
case byProvider
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
@@ -64,6 +65,7 @@ struct UsageAnalyticsView: View {
|
||||
|
||||
@State private var overallStats = UsageStats()
|
||||
@State private var modelStats: [ModelUsageStat] = []
|
||||
@State private var providerStats: [ProviderUsageStat] = []
|
||||
@State private var dailyStats: [DailyUsageStat] = []
|
||||
|
||||
var body: some View {
|
||||
@@ -85,6 +87,10 @@ struct UsageAnalyticsView: View {
|
||||
if chartMode == .byModel && !modelStats.isEmpty {
|
||||
modelBreakdownList
|
||||
}
|
||||
|
||||
if chartMode == .byProvider && !providerStats.isEmpty {
|
||||
providerBreakdownList
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
@@ -156,10 +162,11 @@ struct UsageAnalyticsView: View {
|
||||
Picker("", selection: $chartMode) {
|
||||
Text("Over Time").tag(AnalyticsChartMode.overTime)
|
||||
Text("By Model").tag(AnalyticsChartMode.byModel)
|
||||
Text("By Provider").tag(AnalyticsChartMode.byProvider)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 280)
|
||||
.frame(maxWidth: 400)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -169,6 +176,8 @@ struct UsageAnalyticsView: View {
|
||||
timeSeriesChart
|
||||
case .byModel:
|
||||
byModelChart
|
||||
case .byProvider:
|
||||
byProviderChart
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +218,26 @@ struct UsageAnalyticsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var byProviderChart: some View {
|
||||
Group {
|
||||
if providerStats.isEmpty {
|
||||
emptyChartPlaceholder
|
||||
} else {
|
||||
Chart(providerStats) { stat in
|
||||
SectorMark(
|
||||
angle: .value("Value", providerValue(for: stat)),
|
||||
innerRadius: .ratio(0.55),
|
||||
angularInset: 1.5
|
||||
)
|
||||
.foregroundStyle(by: .value("Provider", providerDisplayName(stat.provider)))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.frame(height: 240)
|
||||
.chartLegend(position: .bottom, alignment: .center, spacing: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyChartPlaceholder: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
@@ -252,6 +281,40 @@ struct UsageAnalyticsView: View {
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
// MARK: - By Provider List
|
||||
|
||||
private var providerBreakdownList: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("By Provider")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
ForEach(providerStats) { stat in
|
||||
HStack {
|
||||
Text(providerDisplayName(stat.provider))
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(providerValueDisplay(for: stat))
|
||||
.font(.body.monospacedDigit())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if stat.id != providerStats.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.confabSurface.opacity(0.5))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private func providerDisplayName(_ rawProvider: String) -> String {
|
||||
Settings.Provider(rawValue: rawProvider)?.displayName ?? rawProvider
|
||||
}
|
||||
|
||||
// MARK: - Value helpers
|
||||
|
||||
private func dailyValue(for stat: DailyUsageStat) -> Double {
|
||||
@@ -278,15 +341,34 @@ struct UsageAnalyticsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func providerValue(for stat: ProviderUsageStat) -> Double {
|
||||
switch metric {
|
||||
case .tokens: return Double(stat.totalTokens)
|
||||
case .questions: return Double(stat.questionCount)
|
||||
case .cost: return stat.totalCost
|
||||
}
|
||||
}
|
||||
|
||||
private func providerValueDisplay(for stat: ProviderUsageStat) -> String {
|
||||
switch metric {
|
||||
case .tokens: return stat.totalTokensDisplay
|
||||
case .questions: return "\(stat.questionCount)"
|
||||
case .cost: return stat.totalCostDisplay
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
/// Sourced from `usage_events`, not `messages` — reflects usage for every completed
|
||||
/// generation regardless of whether the conversation it belongs to was ever saved.
|
||||
private func loadStats() {
|
||||
let range = timeframe.dateRange()
|
||||
overallStats = (try? DatabaseService.shared.getOverallUsageStats(from: range.start, to: range.end)) ?? UsageStats()
|
||||
modelStats = (try? DatabaseService.shared.getUsageByModel(from: range.start, to: range.end)) ?? []
|
||||
overallStats = (try? DatabaseService.shared.getUsageEventTotals(from: range.start, to: range.end)) ?? UsageStats()
|
||||
modelStats = (try? DatabaseService.shared.getUsageEventsByModel(from: range.start, to: range.end)) ?? []
|
||||
providerStats = (try? DatabaseService.shared.getUsageEventsByProvider(from: range.start, to: range.end)) ?? []
|
||||
|
||||
let dailyFrom = range.start ?? overallStats.firstMessageDate ?? range.end
|
||||
dailyStats = (try? DatabaseService.shared.getDailyUsage(from: dailyFrom, to: range.end)) ?? []
|
||||
dailyStats = (try? DatabaseService.shared.getDailyUsageEvents(from: dailyFrom, to: range.end)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,17 @@ struct DatabaseServiceMigrationTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test("v13 adds the usage_events table with the expected columns")
|
||||
func v13AddsUsageEventsTable() {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
#expect(db.tableExists("usage_events"))
|
||||
let columns = Set(db.columnNames(in: "usage_events"))
|
||||
let expected: Set<String> = [
|
||||
"id", "timestamp", "provider", "modelId", "promptTokens", "completionTokens", "cost", "conversationId",
|
||||
]
|
||||
#expect(expected.isSubset(of: columns))
|
||||
}
|
||||
|
||||
@Test("v4 adds modelId to messages and primaryModel to conversations")
|
||||
func v4AddsModelColumns() {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
@@ -299,6 +310,80 @@ struct DatabaseServiceUsageStatsTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("DatabaseService usage events, against a throwaway in-memory queue")
|
||||
struct DatabaseServiceUsageEventsTests {
|
||||
|
||||
@Test("Logged usage events are readable regardless of any saved conversation")
|
||||
func logUsageEventPersistsWithNoConversation() throws {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
try db.logUsageEvent(
|
||||
provider: "openrouter", modelId: "gpt-5", promptTokens: 100, completionTokens: 50,
|
||||
cost: 0.02, conversationId: nil
|
||||
)
|
||||
|
||||
let totals = try db.getUsageEventTotals()
|
||||
#expect(totals.totalQuestions == 1)
|
||||
#expect(totals.totalTokens == 150)
|
||||
#expect(totals.hasCostData == true)
|
||||
#expect(abs(totals.totalCost - 0.02) < 0.0001)
|
||||
}
|
||||
|
||||
@Test("Usage events respect an optional date range")
|
||||
func usageEventTotalsRespectsDateRange() throws {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
let now = Date()
|
||||
let old = now.addingTimeInterval(-10 * 24 * 3600)
|
||||
|
||||
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
|
||||
// Directly insert an old-dated row via the private record path isn't exposed, so approximate
|
||||
// "old" coverage by asserting the fresh row is excluded when the range is set entirely in the past.
|
||||
let rangeExcludingNow = try db.getUsageEventTotals(from: old, to: old.addingTimeInterval(3600))
|
||||
#expect(rangeExcludingNow.totalQuestions == 0)
|
||||
|
||||
let rangeIncludingNow = try db.getUsageEventTotals(from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600))
|
||||
#expect(rangeIncludingNow.totalQuestions == 1)
|
||||
}
|
||||
|
||||
@Test("Usage events group by model and by provider independently")
|
||||
func usageEventsGroupByModelAndProvider() throws {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
|
||||
try db.logUsageEvent(provider: "openrouter", modelId: "claude-sonnet", promptTokens: 5, completionTokens: 5, cost: 0.02, conversationId: nil)
|
||||
try db.logUsageEvent(provider: "anthropic", modelId: "claude-sonnet", promptTokens: 20, completionTokens: 20, cost: 0.03, conversationId: nil)
|
||||
|
||||
let byModel = try db.getUsageEventsByModel()
|
||||
#expect(byModel.count == 2)
|
||||
let sonnet = byModel.first { $0.modelId == "claude-sonnet" }
|
||||
#expect(sonnet?.questionCount == 2)
|
||||
#expect(sonnet?.totalTokens == 50)
|
||||
|
||||
let byProvider = try db.getUsageEventsByProvider()
|
||||
#expect(byProvider.count == 2)
|
||||
let openrouter = byProvider.first { $0.provider == "openrouter" }
|
||||
#expect(openrouter?.questionCount == 2)
|
||||
#expect(openrouter?.totalTokens == 30)
|
||||
let anthropic = byProvider.first { $0.provider == "anthropic" }
|
||||
#expect(anthropic?.questionCount == 1)
|
||||
#expect(anthropic?.totalTokens == 40)
|
||||
}
|
||||
|
||||
@Test("Daily usage events bucket by calendar day (GMT, matching stored timestamps)")
|
||||
func dailyUsageEventsBucketByDay() throws {
|
||||
let db = DatabaseService.makeInMemory()
|
||||
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
|
||||
|
||||
var gmtCalendar = Calendar(identifier: .gregorian)
|
||||
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
|
||||
let farPast = gmtCalendar.date(from: DateComponents(year: 2020, month: 1, day: 1))!
|
||||
let farFuture = gmtCalendar.date(from: DateComponents(year: 2030, month: 1, day: 1))!
|
||||
|
||||
let daily = try db.getDailyUsageEvents(from: farPast, to: farFuture)
|
||||
#expect(daily.count == 1)
|
||||
#expect(daily.first?.totalTokens == 20)
|
||||
#expect(daily.first?.questionCount == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("DatabaseService folders, against a throwaway in-memory queue")
|
||||
struct DatabaseServiceFolderTests {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user