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:
2026-08-12 13:49:12 +02:00
parent d460230158
commit 30e18f92a5
7 changed files with 514 additions and 17 deletions
+23
View File
@@ -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),