git.iko.soy/opencode-android
on 2794fda help sign in
151 lines 5.7 kB history raw
1package soy.iko.opencode.ui.usage
3import androidx.compose.runtime.Immutable
4import soy.iko.opencode.data.model.AssistantMessage
5import soy.iko.opencode.data.model.MessageWithParts
6import soy.iko.opencode.data.model.Tokens
8/** Aggregated usage for one model across the scanned sessions. */
9@Immutable
10data class ModelUsage(
11 val provider: String,
12 val model: String,
13 val messages: Int,
14 val cost: Double,
15 val tokens: Tokens,
18/** Aggregated usage for one session. */
19@Immutable
20data class SessionUsage(
21 val sessionId: String,
22 val title: String,
23 val messages: Int,
24 val cost: Double,
25 val tokens: Tokens,
28/** One session's inputs to [aggregateUsage]: its id, title, last-modified timestamp (epoch
29 * millis, or null when unknown), and messages. The timestamp lets a [UsageTimeRange] filter
30 * exclude sessions outside the window before they contribute to the totals. */
31@Immutable
32data class UsageSessionInput(
33 val sessionId: String,
34 val title: String,
35 val modifiedAt: Long?,
36 val messages: List<MessageWithParts>,
39/** Time-range filter for the usage dashboard. [cutoffMs] returns the epoch-millis threshold
40 * before which a session is excluded (or null for "all time"), computed against [now]. */
41enum class UsageTimeRange {
42 TWENTY_FOUR_HOURS,
43 SEVEN_DAYS,
44 THIRTY_DAYS,
45 ALL_TIME;
47 fun cutoffMs(now: Long = System.currentTimeMillis()): Long? = when (this) {
48 TWENTY_FOUR_HOURS -> now - 24L * 60 * 60 * 1000
49 SEVEN_DAYS -> now - 7L * 24 * 60 * 60 * 1000
50 THIRTY_DAYS -> now - 30L * 24 * 60 * 60 * 1000
51 ALL_TIME -> null
52 }
55/** A full usage report: overall totals plus per-model and per-session breakdowns. */
56@Immutable
57data class UsageReport(
58 val totalCost: Double = 0.0,
59 val totalTokens: Tokens = Tokens(),
60 val messageCount: Int = 0,
61 val sessionCount: Int = 0,
62 val byModel: List<ModelUsage> = emptyList(),
63 val bySession: List<SessionUsage> = emptyList(),
64) {
65 val isEmpty: Boolean get() = messageCount == 0
68/** Sum of two token counters (including the cache read/write sub-counters). */
69internal operator fun Tokens.plus(other: Tokens): Tokens = Tokens(
70 input = input + other.input,
71 output = output + other.output,
72 reasoning = reasoning + other.reasoning,
73 cache = Tokens.Cache(read = cache.read + other.cache.read, write = cache.write + other.cache.write),
76/** Total tokens across all sub-counters, for a single "N tokens" headline. */
77internal val Tokens.total: Long get() = input + output + reasoning + cache.read + cache.write
79private class MutableAgg(val provider: String, val model: String) {
80 var messages = 0
81 var cost = 0.0
82 var tokens = Tokens()
85/**
86 * Aggregate cost/token usage across sessions. Each entry carries its messages plus an optional
87 * last-modified timestamp; when [cutoffMs] is non-null, sessions whose [UsageSessionInput.modifiedAt]
88 * is older than the cutoff (or unknown) are skipped, so a time-range filter narrows the totals
89 * AND the per-model/per-session breakdowns rather than just the session list.
90 *
91 * Only [AssistantMessage]s that reported cost or tokens contribute — user prompts and
92 * zero-usage messages are ignored so the counts reflect real model spend. Pure and
93 * synchronous so it's unit-testable and can run off the main thread.
94 */
95fun aggregateUsage(
96 sessions: List<UsageSessionInput>,
97 cutoffMs: Long? = null,
98): UsageReport {
99 var totalCost = 0.0
100 var totalTokens = Tokens()
101 var messageCount = 0
102 val byModel = LinkedHashMap<String, MutableAgg>()
103 val bySession = ArrayList<SessionUsage>()
105 for (entry in sessions) {
106 // Apply the time-range filter at the session level: a session modified before the
107 // cutoff (or with no known timestamp under a finite range) doesn't contribute.
108 if (cutoffMs != null && (entry.modifiedAt == null || entry.modifiedAt < cutoffMs)) continue
109 val id = entry.sessionId
110 val title = entry.title
111 val messages = entry.messages
112 var sessionCost = 0.0
113 var sessionTokens = Tokens()
114 var sessionMessages = 0
115 for (m in messages) {
116 val info = m.info as? AssistantMessage ?: continue
117 val cost = info.cost ?: 0.0
118 val tokens = info.tokens ?: Tokens()
119 if (cost == 0.0 && tokens.total == 0L) continue
120 messageCount++
121 sessionMessages++
122 totalCost += cost
123 totalTokens += tokens
124 sessionCost += cost
125 sessionTokens += tokens
126 val model = info.modelID?.takeIf { it.isNotBlank() } ?: "unknown"
127 val provider = info.providerID?.takeIf { it.isNotBlank() } ?: "unknown"
128 // Key by provider+model so the same model id served by different providers
129 // stays two rows instead of collapsing into one ambiguous total.
130 val key = "$provider/$model"
131 val agg = byModel.getOrPut(key) { MutableAgg(provider, model) }
132 agg.messages++
133 agg.cost += cost
134 agg.tokens += tokens
135 }
136 if (sessionMessages > 0) {
137 bySession.add(SessionUsage(id, title, sessionMessages, sessionCost, sessionTokens))
138 }
139 }
141 return UsageReport(
142 totalCost = totalCost,
143 totalTokens = totalTokens,
144 messageCount = messageCount,
145 sessionCount = bySession.size,
146 byModel = byModel.entries
147 .map { ModelUsage(it.value.provider, it.value.model, it.value.messages, it.value.cost, it.value.tokens) }
148 .sortedByDescending { it.cost },
149 bySession = bySession.sortedByDescending { it.cost },
150 )