Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/main/java/com/zhousl/aether/data/ChatRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,8 @@ private fun parseUsageStatistics(json: JSONObject?): ChatUsageStatistics? {
totalTokens = json.optionalLong("totalTokens"),
reasoningTokens = json.optionalLong("reasoningTokens"),
cachedInputTokens = json.optionalLong("cachedInputTokens"),
cacheWriteTokens = json.optionalLong("cacheWriteTokens"),
outputDurationMillis = json.optionalLong("outputDurationMillis"),
requestCount = json.optInt("requestCount", 1).coerceAtLeast(1),
tokenUsageSource = json.optString("tokenUsageSource").ifBlank { "unavailable" },
startedAtMillis = json.optLong("startedAtMillis"),
Expand All @@ -1269,6 +1271,8 @@ private fun ChatUsageStatistics.toJson(): JSONObject = JSONObject().apply {
totalTokens?.let { put("totalTokens", it) }
reasoningTokens?.let { put("reasoningTokens", it) }
cachedInputTokens?.let { put("cachedInputTokens", it) }
cacheWriteTokens?.let { put("cacheWriteTokens", it) }
outputDurationMillis?.let { put("outputDurationMillis", it) }
put("requestCount", requestCount)
put("tokenUsageSource", tokenUsageSource)
if (startedAtMillis > 0L) put("startedAtMillis", startedAtMillis)
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/zhousl/aether/data/LlmModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ data class LlmTokenUsage(
val totalTokens: Long? = null,
val reasoningTokens: Long? = null,
val cachedInputTokens: Long? = null,
val cacheWriteTokens: Long? = null,
val outputDurationMillis: Long? = null,
val requestCount: Int = 1,
) {
operator fun plus(other: LlmTokenUsage): LlmTokenUsage = LlmTokenUsage(
Expand All @@ -16,6 +18,8 @@ data class LlmTokenUsage(
totalTokens = sumNullable(totalTokens, other.totalTokens),
reasoningTokens = sumNullable(reasoningTokens, other.reasoningTokens),
cachedInputTokens = sumNullable(cachedInputTokens, other.cachedInputTokens),
cacheWriteTokens = sumNullable(cacheWriteTokens, other.cacheWriteTokens),
outputDurationMillis = sumNullable(outputDurationMillis, other.outputDurationMillis),
requestCount = requestCount + other.requestCount,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1465,6 +1465,8 @@ class SessionExecutionManager(
totalTokens = tokenUsage?.withMissingTotalResolved()?.totalTokens,
reasoningTokens = tokenUsage?.reasoningTokens,
cachedInputTokens = tokenUsage?.cachedInputTokens,
cacheWriteTokens = tokenUsage?.cacheWriteTokens,
outputDurationMillis = tokenUsage?.outputDurationMillis,
requestCount = tokenUsage?.requestCount ?: 1,
tokenUsageSource = tokenUsageSource,
startedAtMillis = turnStartedAtMillis ?: 0L,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ fun PiCompletionResult.toProviderPayloadJson(): String = JSONObject().apply {
tokenUsage.totalTokens?.let { put("total_tokens", it) }
tokenUsage.reasoningTokens?.let { put("reasoning_tokens", it) }
tokenUsage.cachedInputTokens?.let { put("cached_input_tokens", it) }
tokenUsage.cacheWriteTokens?.let { put("cache_write_tokens", it) }
tokenUsage.outputDurationMillis?.let { put("output_duration_ms", it) }
put("request_count", tokenUsage.requestCount)
},
)
Expand Down Expand Up @@ -266,13 +268,17 @@ private fun JSONObject.toLlmTokenUsage(): LlmTokenUsage? {
totalTokens = optPositiveLong("total_tokens"),
reasoningTokens = optPositiveLong("reasoning_tokens"),
cachedInputTokens = optPositiveLong("cached_input_tokens"),
cacheWriteTokens = optPositiveLong("cache_write_tokens"),
outputDurationMillis = optPositiveLong("output_duration_ms"),
requestCount = (optPositiveLong("request_count") ?: 1L).coerceAtLeast(1L).toInt(),
).withMissingTotalResolved()
return usage.takeIf {
it.inputTokens != null ||
it.outputTokens != null ||
it.totalTokens != null ||
it.reasoningTokens != null ||
it.cachedInputTokens != null
it.cachedInputTokens != null ||
it.cacheWriteTokens != null
}
}

Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/com/zhousl/aether/ui/AetherUiState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ data class ChatUsageStatistics(
val totalTokens: Long? = null,
val reasoningTokens: Long? = null,
val cachedInputTokens: Long? = null,
val cacheWriteTokens: Long? = null,
val outputDurationMillis: Long? = null,
val requestCount: Int = 1,
val tokenUsageSource: String = "unavailable",
val startedAtMillis: Long = 0L,
Expand All @@ -125,6 +127,10 @@ data class ChatUsageStatistics(
val outputTokensPerSecond: Double?
get() {
val output = outputTokens ?: return null
val outputDuration = outputDurationMillis?.takeIf { it > 0L }
if (outputDuration != null) {
return output * 1_000.0 / outputDuration
}
val outputStartedAt = firstTokenAtMillis ?: startedAtMillis.takeIf { it > 0L } ?: return null
if (completedAtMillis <= outputStartedAt) return null
val seconds = (completedAtMillis - outputStartedAt) / 1000.0
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/zhousl/aether/ui/AetherViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5058,6 +5058,7 @@ class AetherViewModel(
put("total_tokens", totalTokens)
usage?.reasoningTokens?.let { put("reasoning_tokens", it) }
usage?.cachedInputTokens?.let { put("cached_input_tokens", it) }
usage?.cacheWriteTokens?.let { put("cache_write_tokens", it) }
put(
"average_tokens_per_input_message",
if (inputMessageCount > 0) totalTokens.toDouble() / inputMessageCount else 0.0,
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/com/zhousl/aether/ui/ConversationMessages.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3413,6 +3413,16 @@ private fun MessageStatisticsPopup(
value = usageStatistics?.reasoningTokens?.let(::formatTokenCount)
?: stringResource(R.string.statistics_unavailable),
)
MessageStatisticRow(
label = stringResource(R.string.statistics_cached_input),
value = usageStatistics?.cachedInputTokens?.let(::formatTokenCount)
?: stringResource(R.string.statistics_unavailable),
)
MessageStatisticRow(
label = stringResource(R.string.statistics_cache_write),
value = usageStatistics?.cacheWriteTokens?.let(::formatTokenCount)
?: stringResource(R.string.statistics_unavailable),
)
MessageStatisticRow(
label = stringResource(R.string.statistics_output_rate),
value = usageStatistics?.outputTokensPerSecond?.let(::formatTokenRate)
Expand Down
27 changes: 27 additions & 0 deletions app/src/main/java/com/zhousl/aether/ui/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1704,6 +1704,29 @@ private fun StatisticsSettingsPage(

Spacer(Modifier.height(16.dp))

SettingsCardGroup {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = stringResource(R.string.statistics_cache_tokens),
style = MaterialTheme.typography.titleMedium,
color = AetherOnSurface,
)
HistoryPeakRow(
label = stringResource(R.string.statistics_cached_input),
value = formatSettingsTokenCount(report.cachedInputTokens),
)
HistoryPeakRow(
label = stringResource(R.string.statistics_cache_write),
value = formatSettingsTokenCount(report.cacheWriteTokens),
)
}
}

Spacer(Modifier.height(16.dp))

SettingsCardGroup {
StatisticsChartSection(
title = stringResource(R.string.statistics_speed),
Expand Down Expand Up @@ -2130,6 +2153,8 @@ private fun buildUsageStatisticsReport(
inputTokens = stats.sumOf { it.inputTokens ?: 0L },
outputTokens = stats.sumOf { it.outputTokens ?: 0L },
reasoningTokens = stats.sumOf { it.reasoningTokens ?: 0L },
cachedInputTokens = stats.sumOf { it.cachedInputTokens ?: 0L },
cacheWriteTokens = stats.sumOf { it.cacheWriteTokens ?: 0L },
sessionCount = usageStatisticsSnapshots.map { it.sessionId }.distinct().size,
turnCount = stats.size,
dailyTokenUsage = daily,
Expand Down Expand Up @@ -2162,6 +2187,8 @@ private data class UsageStatisticsReport(
val inputTokens: Long,
val outputTokens: Long,
val reasoningTokens: Long,
val cachedInputTokens: Long,
val cacheWriteTokens: Long,
val sessionCount: Int,
val turnCount: Int,
val dailyTokenUsage: List<DailyTokenUsage>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,14 @@ class PiProviderMapperTest {
val payload = PiCompletionResult(
assistantText = "done",
assistantMessage = assistantMessage,
usage = LlmTokenUsage(inputTokens = 3, outputTokens = 2, totalTokens = 5),
usage = LlmTokenUsage(
inputTokens = 3,
outputTokens = 2,
totalTokens = 5,
cachedInputTokens = 1,
cacheWriteTokens = 4,
outputDurationMillis = 2_500,
),
provider = "openai",
model = "gpt-5.4",
responseId = "resp-1",
Expand All @@ -263,6 +270,40 @@ class PiProviderMapperTest {
assertEquals("openai", wrapped.getString("provider"))
assertEquals("resp-1", wrapped.getString("responseId"))
assertEquals(5L, wrapped.getJSONObject("usage").getLong("total_tokens"))
assertEquals(4L, wrapped.getJSONObject("usage").getLong("cache_write_tokens"))
assertEquals(2_500L, wrapped.getJSONObject("usage").getLong("output_duration_ms"))
}

@Test
fun piUsageKeepsCacheReadAndWriteTokens() {
val usage = JSONObject()
.put(
"usage",
JSONObject()
.put("input_tokens", 10)
.put("output_tokens", 4)
.put("total_tokens", 14)
.put("cached_input_tokens", 6)
.put("cache_write_tokens", 2)
.put("output_duration_ms", 2_500)
.put("request_count", 3),
)
.toPiCompletionResult()
.usage!!

assertEquals(6L, usage.cachedInputTokens)
assertEquals(2L, usage.cacheWriteTokens)
assertEquals(2_500L, usage.outputDurationMillis)
assertEquals(3, usage.requestCount)

val replayed = JSONObject(
PiCompletionResult(assistantText = "", usage = usage).toProviderPayloadJson(),
)
.getJSONObject("usage")
assertEquals(6L, replayed.getLong("cached_input_tokens"))
assertEquals(2L, replayed.getLong("cache_write_tokens"))
assertEquals(2_500L, replayed.getLong("output_duration_ms"))
assertEquals(3, replayed.getInt("request_count"))
}

@Test
Expand Down
7 changes: 7 additions & 0 deletions iosApp/App/Settings/NativeSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ private let nativeSettingsPersianTranslations: [String: String] = [
"Back up or restore settings, providers, skills, and conversations.": "از تنظیمات، ارائه‌دهندگان، مهارت‌ها و گفتگوها پشتیبان بگیرید یا آن‌ها را بازیابی کنید.",
"Base URL": "نشانی پایه",
"Browser login": "ورود با مرورگر",
"Cache tokens": "توکن‌های کش",
"Cached read": "خواندن از کش",
"Cached write": "نوشتن در کش",
"Cancel": "لغو",
"Check your connection and try again.": "اتصال خود را بررسی کرده و دوباره تلاش کنید.",
"Choose Folder": "انتخاب پوشه",
Expand Down Expand Up @@ -2197,6 +2200,10 @@ private struct NativeStatisticsView: View {
tokenLine(model.text("Output", "输出"), value: long("outputTokens"), color: .green)
tokenLine(model.text("Reasoning", "推理"), value: long("reasoningTokens"), color: .orange)
}
Section(model.text("Cache tokens", "缓存 token")) {
LabeledContent(model.text("Cached read", "缓存读取"), value: optionalTokens("cachedInputTokens"))
LabeledContent(model.text("Cached write", "缓存写入"), value: optionalTokens("cacheWriteTokens"))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Section(model.text("History", "历史")) {
LabeledContent(model.text("Peak day", "峰值日期"), value: peakDay)
LabeledContent(model.text("Largest turn", "最大单轮"), value: optionalTokens("largestTurnTokens"))
Expand Down
Loading
Loading