feat(chat): use backend usage deltas for assistant stats

This commit is contained in:
M57 committed 2026-09-16 01:34:05 +03:00
1 parent aed8ba4a6d
commit d16a2ea35c
7 files changed
+726 -78

No files matched your search

@@ -1,68 +1,187 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.ws.toAny
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
/**
* Minimal view of the `session.usage` WS RPC result or `session.usage` push event —
* only the fields the context widget consumes today. The backend sends many more keys
* (`input`/`output`/`total`/`calls`/`active_subagents`/`credits_lines` …);
* parsing is key-by-key, so unmodelled keys are simply ignored.
*
* Verified backend shape (`tui_gateway/server.py` `_get_usage`): `compressions`
* = `context_compressor.compression_count` (0 when never compressed, absent on
* sessions with no live agent), `context_used` = current window tokens,
* `context_max` = max context length.
*/
/** Complete cumulative `session.usage` snapshot returned by Hermes Agent. */
data class UsageSnapshotResponse(
/** How many times this session has been context-compressed. */
val compressions: Int? = null,
/** Current prompt / context tokens used in the active window. */
val contextUsed: Long? = null,
/** Max context length in tokens. */
val contextMax: Long? = null,
/** Cumulative session total tokens. */
val model: String? = null,
val inputTokens: Long? = null,
val outputTokens: Long? = null,
val reasoningTokens: Long? = null,
val promptTokens: Long? = null,
val completionTokens: Long? = null,
val totalTokens: Long? = null,
/** Rolling tokens-per-second generation speed. */
val apiCalls: Long? = null,
val cacheReadTokens: Long? = null,
val cacheWriteTokens: Long? = null,
val compressions: Int? = null,
val contextUsed: Long? = null,
val contextMax: Long? = null,
val contextPercent: Int? = null,
val contextSource: String? = null,
val contextEstimated: Boolean? = null,
val cacheHitPercent: Int? = null,
val avgLatencySeconds: Double? = null,
val avgTps: Double? = null,
val activeSubagents: Int? = null,
val devCreditsSpentMicros: Long? = null,
val costUsd: Double? = null,
val costStatus: String? = null,
val creditsLines: List<String>? = null,
)
/** Merge an incremental snapshot without erasing values omitted by the backend. */
fun UsageSnapshotResponse.mergeWith(previous: UsageSnapshotResponse?): UsageSnapshotResponse =
if (previous == null) {
this
} else {
copy(
model = model ?: previous.model,
inputTokens = inputTokens ?: previous.inputTokens,
outputTokens = outputTokens ?: previous.outputTokens,
reasoningTokens = reasoningTokens ?: previous.reasoningTokens,
promptTokens = promptTokens ?: previous.promptTokens,
completionTokens = completionTokens ?: previous.completionTokens,
totalTokens = totalTokens ?: previous.totalTokens,
apiCalls = apiCalls ?: previous.apiCalls,
cacheReadTokens = cacheReadTokens ?: previous.cacheReadTokens,
cacheWriteTokens = cacheWriteTokens ?: previous.cacheWriteTokens,
compressions = compressions ?: previous.compressions,
contextUsed = contextUsed ?: previous.contextUsed,
contextMax = contextMax ?: previous.contextMax,
contextPercent = contextPercent ?: previous.contextPercent,
contextSource = contextSource ?: previous.contextSource,
contextEstimated = contextEstimated ?: previous.contextEstimated,
cacheHitPercent = cacheHitPercent ?: previous.cacheHitPercent,
avgLatencySeconds = avgLatencySeconds ?: previous.avgLatencySeconds,
avgTps = avgTps ?: previous.avgTps,
activeSubagents = activeSubagents ?: previous.activeSubagents,
devCreditsSpentMicros = devCreditsSpentMicros ?: previous.devCreditsSpentMicros,
costUsd = costUsd ?: previous.costUsd,
costStatus = costStatus ?: previous.costStatus,
creditsLines = creditsLines ?: previous.creditsLines,
)
}
/**
* Parse the decoded JSON-RPC result of `session.usage` or `session.usage` push event payload.
* Usage attributable to one completed turn, derived from two cumulative snapshots.
* Rolling/context fields are copied from the final snapshot rather than differenced.
*/
data class TurnUsage(
val model: String? = null,
val inputTokens: Long? = null,
val outputTokens: Long? = null,
val reasoningTokens: Long? = null,
val promptTokens: Long? = null,
val completionTokens: Long? = null,
val totalTokens: Long? = null,
val apiCalls: Long? = null,
val cacheReadTokens: Long? = null,
val cacheWriteTokens: Long? = null,
val devCreditsSpentMicros: Long? = null,
val costUsd: Double? = null,
val compressionCountDelta: Int? = null,
val avgTps: Double? = null,
val avgLatencySeconds: Double? = null,
)
private fun Long?.deltaFrom(
baseline: Long?,
final: Long?,
): Long? = if (baseline != null && final != null && final >= baseline) final - baseline else null
private fun Double?.deltaFrom(
baseline: Double?,
final: Double?,
): Double? = if (baseline != null && final != null && final >= baseline) final - baseline else null
/** Derive per-turn cumulative counters without ever clamping counter resets. */
fun UsageSnapshotResponse.deltaFrom(baseline: UsageSnapshotResponse?): TurnUsage =
TurnUsage(
model = model,
inputTokens = inputTokens.deltaFrom(baseline?.inputTokens, inputTokens),
outputTokens = outputTokens.deltaFrom(baseline?.outputTokens, outputTokens),
reasoningTokens = reasoningTokens.deltaFrom(baseline?.reasoningTokens, reasoningTokens),
promptTokens = promptTokens.deltaFrom(baseline?.promptTokens, promptTokens),
completionTokens = completionTokens.deltaFrom(baseline?.completionTokens, completionTokens),
totalTokens = totalTokens.deltaFrom(baseline?.totalTokens, totalTokens),
apiCalls = apiCalls.deltaFrom(baseline?.apiCalls, apiCalls),
cacheReadTokens = cacheReadTokens.deltaFrom(baseline?.cacheReadTokens, cacheReadTokens),
cacheWriteTokens = cacheWriteTokens.deltaFrom(baseline?.cacheWriteTokens, cacheWriteTokens),
devCreditsSpentMicros = devCreditsSpentMicros.deltaFrom(baseline?.devCreditsSpentMicros, devCreditsSpentMicros),
costUsd = costUsd.deltaFrom(baseline?.costUsd, costUsd),
compressionCountDelta =
compressions
?.let { final -> baseline?.compressions?.let { base -> if (final >= base) final - base else null } },
avgTps = avgTps,
avgLatencySeconds = avgLatencySeconds,
)
/**
* Parse the decoded JSON-RPC result of `session.usage`, push events, or message.complete.
*
* Handles both direct usage dict (from RPC response) and nested `{ "usage": { ... } }`
* (from push event payload). The WS event parser decodes JSON numbers as [Double]
* (`JsonRpcModels.toAny`), so every count is read through [Number]. Also supports raw [JsonObject].
* Returns null when the payload is not a map (error/malformed response) — callers keep the last known value.
* Handles direct snapshots and nested `{ "usage": { ... } }` envelopes. JSON numbers may
* arrive as [Double] through [JsonRpcModels.toAny], or as [JsonPrimitive] from an RPC result.
* Unknown and malformed optional fields are ignored; a non-map root returns null.
*/
fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
val rawMap =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
fun asMap(value: Any?): Map<*, *>? =
when (value) {
is Map<*, *> -> value
is JsonElement -> value.toAny() as? Map<*, *>
else -> null
} ?: return null
val usageMap = (rawMap["usage"] as? Map<*, *>) ?: rawMap
}
val rawMap = asMap(result) ?: return null
val usageMap = asMap(rawMap["usage"]) ?: rawMap
fun num(key: String): Long? =
when (val value = usageMap[key]) {
is Number -> value.toLong()
is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong()
is String -> value.toLongOrNull()
else -> null
is Byte, is Short, is Int, is Long -> {
(value as Number).toLong()
}
is Float, is Double -> {
(value as Number)
.toDouble()
.takeIf { it.isFinite() && it % 1.0 == 0.0 && it >= Long.MIN_VALUE && it <= Long.MAX_VALUE }
?.toLong()
}
is Number -> {
value
.toDouble()
.takeIf { it.isFinite() && it % 1.0 == 0.0 && it >= Long.MIN_VALUE && it <= Long.MAX_VALUE }
?.toLong()
}
is JsonPrimitive -> {
value.longOrNull
?: value.doubleOrNull
?.takeIf { it.isFinite() && it % 1.0 == 0.0 && it >= Long.MIN_VALUE && it <= Long.MAX_VALUE }
?.toLong()
}
is String -> {
value.toLongOrNull()
?: value
.toDoubleOrNull()
?.takeIf { it.isFinite() && it % 1.0 == 0.0 && it >= Long.MIN_VALUE && it <= Long.MAX_VALUE }
?.toLong()
}
else -> {
null
}
}
fun intNum(key: String): Int? =
when (val value = usageMap[key]) {
is Number -> value.toInt()
is JsonPrimitive -> value.intOrNull
is String -> value.toIntOrNull()
else -> null
}
fun intNum(key: String): Int? = num(key)?.takeIf { it in Int.MIN_VALUE..Int.MAX_VALUE }?.toInt()
fun doubleNum(key: String): Double? =
when (val value = usageMap[key]) {
@@ -70,13 +189,55 @@ fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
is JsonPrimitive -> value.doubleOrNull ?: value.longOrNull?.toDouble()
is String -> value.toDoubleOrNull()
else -> null
}?.takeIf { it.isFinite() }
fun string(key: String): String? =
when (val value = usageMap[key]) {
is String -> value
is JsonPrimitive -> value.content
else -> null
}
fun bool(key: String): Boolean? =
when (val value = usageMap[key]) {
is Boolean -> value
is JsonPrimitive -> value.booleanOrNull
is String -> value.toBooleanStrictOrNull()
else -> null
}
fun strings(key: String): List<String>? =
when (val value = usageMap[key]) {
is JsonArray -> value.mapNotNull { (it as? JsonPrimitive)?.content }
is List<*> -> value.filterIsInstance<String>()
is JsonElement -> (value.toAny() as? List<*>)?.filterIsInstance<String>()
else -> null
}
return UsageSnapshotResponse(
model = string("model"),
inputTokens = num("input"),
outputTokens = num("output"),
reasoningTokens = num("reasoning"),
promptTokens = num("prompt"),
completionTokens = num("completion"),
totalTokens = num("total"),
apiCalls = num("calls"),
cacheReadTokens = num("cache_read"),
cacheWriteTokens = num("cache_write"),
compressions = intNum("compressions"),
contextUsed = num("context_used"),
contextMax = num("context_max"),
totalTokens = num("total"),
contextPercent = intNum("context_percent"),
contextSource = string("context_source"),
contextEstimated = bool("context_estimated"),
cacheHitPercent = intNum("cache_hit_pct"),
avgLatencySeconds = doubleNum("avg_latency_s"),
avgTps = doubleNum("avg_tps"),
activeSubagents = intNum("active_subagents"),
devCreditsSpentMicros = num("dev_credits_spent_micros"),
costUsd = doubleNum("cost_usd"),
costStatus = string("cost_status"),
creditsLines = strings("credits_lines"),
)
}