feat(chat): use backend usage deltas for assistant stats
This commit is contained in:
1 parent
aed8ba4a6d
commit
d16a2ea35c
7 files changed
+726
-78
No files matched your search
@@ -1,5 +1,10 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
@@ -12,6 +17,120 @@ import org.junit.Test
|
||||
* mirror the real wire shape.
|
||||
*/
|
||||
class UsageSnapshotResponseTest {
|
||||
private fun completePayload(): Map<String, Any?> =
|
||||
mapOf(
|
||||
"model" to "provider/model",
|
||||
"input" to 13_000L,
|
||||
"output" to 2_700L,
|
||||
"reasoning" to 1_100L,
|
||||
"prompt" to 48_000L,
|
||||
"completion" to 2_700L,
|
||||
"total" to 50_700L,
|
||||
"calls" to 5L,
|
||||
"compressions" to 2,
|
||||
"context_used" to 12_345L,
|
||||
"context_max" to 200_000L,
|
||||
"context_percent" to 6,
|
||||
"context_source" to "compressor",
|
||||
"context_estimated" to true,
|
||||
"cache_hit_pct" to 42,
|
||||
"cache_read" to 1_200L,
|
||||
"cache_write" to 300L,
|
||||
"avg_latency_s" to 0.75,
|
||||
"avg_tps" to 42.5,
|
||||
"active_subagents" to 1,
|
||||
"dev_credits_spent_micros" to 9_000L,
|
||||
"cost_usd" to 0.0125,
|
||||
"cost_status" to "estimated",
|
||||
"credits_lines" to listOf("line one", "line two"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `complete direct payload maps every usage field`() {
|
||||
val parsed = parseUsageSnapshot(completePayload())
|
||||
|
||||
assertEquals("provider/model", parsed?.model)
|
||||
assertEquals(13_000L, parsed?.inputTokens)
|
||||
assertEquals(2_700L, parsed?.outputTokens)
|
||||
assertEquals(1_100L, parsed?.reasoningTokens)
|
||||
assertEquals(48_000L, parsed?.promptTokens)
|
||||
assertEquals(2_700L, parsed?.completionTokens)
|
||||
assertEquals(50_700L, parsed?.totalTokens)
|
||||
assertEquals(5L, parsed?.apiCalls)
|
||||
assertEquals(2, parsed?.compressions)
|
||||
assertEquals(12_345L, parsed?.contextUsed)
|
||||
assertEquals(200_000L, parsed?.contextMax)
|
||||
assertEquals(6, parsed?.contextPercent)
|
||||
assertEquals("compressor", parsed?.contextSource)
|
||||
assertEquals(true, parsed?.contextEstimated)
|
||||
assertEquals(42, parsed?.cacheHitPercent)
|
||||
assertEquals(1_200L, parsed?.cacheReadTokens)
|
||||
assertEquals(300L, parsed?.cacheWriteTokens)
|
||||
assertEquals(0.75, parsed?.avgLatencySeconds ?: 0.0, 0.001)
|
||||
assertEquals(42.5, parsed?.avgTps ?: 0.0, 0.001)
|
||||
assertEquals(1, parsed?.activeSubagents)
|
||||
assertEquals(9_000L, parsed?.devCreditsSpentMicros)
|
||||
assertEquals(0.0125, parsed?.costUsd ?: 0.0, 0.0001)
|
||||
assertEquals("estimated", parsed?.costStatus)
|
||||
assertEquals(listOf("line one", "line two"), parsed?.creditsLines)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nested usage envelope and JsonObject map correctly`() {
|
||||
val json =
|
||||
Json.parseToJsonElement(
|
||||
"""{"usage":{"model":"m","input":100,"output":200,"reasoning":50,"prompt":300,"completion":200,"total":300,"calls":2,"context_used":400,"context_max":1000,"avg_tps":12.5,"context_estimated":false,"credits_lines":["credit"]}}""",
|
||||
) as JsonObject
|
||||
|
||||
val parsed = parseUsageSnapshot(json)
|
||||
|
||||
assertEquals("m", parsed?.model)
|
||||
assertEquals(100L, parsed?.inputTokens)
|
||||
assertEquals(200L, parsed?.outputTokens)
|
||||
assertEquals(50L, parsed?.reasoningTokens)
|
||||
assertEquals(2L, parsed?.apiCalls)
|
||||
assertEquals(12.5, parsed?.avgTps ?: 0.0, 0.001)
|
||||
assertEquals(false, parsed?.contextEstimated)
|
||||
assertEquals(listOf("credit"), parsed?.creditsLines)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numeric strings booleans and large Long values are preserved`() {
|
||||
val parsed =
|
||||
parseUsageSnapshot(
|
||||
mapOf(
|
||||
"input" to "2147483648",
|
||||
"output" to JsonPrimitive("9223372036854770000"),
|
||||
"context_estimated" to "true",
|
||||
"avg_tps" to "42.25",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(2_147_483_648L, parsed?.inputTokens)
|
||||
assertEquals(9_223_372_036_854_770_000L, parsed?.outputTokens)
|
||||
assertEquals(true, parsed?.contextEstimated)
|
||||
assertEquals(42.25, parsed?.avgTps ?: 0.0, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing and malformed optional fields remain unavailable`() {
|
||||
val parsed =
|
||||
parseUsageSnapshot(
|
||||
buildJsonObject {
|
||||
put("input", JsonPrimitive("not-a-number"))
|
||||
put("context_estimated", JsonPrimitive("maybe"))
|
||||
put("avg_tps", JsonPrimitive("NaN"))
|
||||
put("credits_lines", JsonPrimitive("not-a-list"))
|
||||
},
|
||||
)
|
||||
|
||||
assertNull(parsed?.inputTokens)
|
||||
assertNull(parsed?.outputTokens)
|
||||
assertNull(parsed?.contextEstimated)
|
||||
assertNull(parsed?.avgTps)
|
||||
assertNull(parsed?.creditsLines)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compressions parses from Double and Int`() {
|
||||
assertEquals(2, parseUsageSnapshot(mapOf("compressions" to 2.0))?.compressions)
|
||||
@@ -50,4 +169,63 @@ class UsageSnapshotResponseTest {
|
||||
assertEquals(50000L, parsed?.totalTokens)
|
||||
assertEquals(45.5, parsed?.avgTps ?: 0.0, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deltaFrom derives independent cumulative fields and copies rolling values`() {
|
||||
val baseline =
|
||||
UsageSnapshotResponse(
|
||||
inputTokens = 10_000,
|
||||
outputTokens = 2_000,
|
||||
reasoningTokens = 800,
|
||||
promptTokens = 40_000,
|
||||
completionTokens = 2_000,
|
||||
totalTokens = 42_000,
|
||||
apiCalls = 3,
|
||||
cacheReadTokens = 100,
|
||||
cacheWriteTokens = 50,
|
||||
costUsd = 1.0,
|
||||
)
|
||||
val final =
|
||||
UsageSnapshotResponse(
|
||||
inputTokens = 13_000,
|
||||
outputTokens = 2_700,
|
||||
reasoningTokens = 1_100,
|
||||
promptTokens = 48_000,
|
||||
completionTokens = 2_700,
|
||||
totalTokens = 50_700,
|
||||
apiCalls = 5,
|
||||
cacheReadTokens = 180,
|
||||
cacheWriteTokens = 75,
|
||||
costUsd = 1.25,
|
||||
avgTps = 42.5,
|
||||
avgLatencySeconds = 0.75,
|
||||
)
|
||||
|
||||
val turn = final.deltaFrom(baseline)
|
||||
|
||||
assertEquals(3_000L, turn.inputTokens)
|
||||
assertEquals(700L, turn.outputTokens)
|
||||
assertEquals(300L, turn.reasoningTokens)
|
||||
assertEquals(8_000L, turn.promptTokens)
|
||||
assertEquals(700L, turn.completionTokens)
|
||||
assertEquals(8_700L, turn.totalTokens)
|
||||
assertEquals(2L, turn.apiCalls)
|
||||
assertEquals(80L, turn.cacheReadTokens)
|
||||
assertEquals(25L, turn.cacheWriteTokens)
|
||||
assertEquals(0.25, turn.costUsd ?: 0.0, 0.001)
|
||||
assertEquals(42.5, turn.avgTps ?: 0.0, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deltaFrom treats missing reset and equal counters correctly`() {
|
||||
val baseline = UsageSnapshotResponse(outputTokens = 5_000, inputTokens = 10, totalTokens = 10)
|
||||
val reset = UsageSnapshotResponse(outputTokens = 100, inputTokens = 20, totalTokens = 20)
|
||||
val equal = UsageSnapshotResponse(outputTokens = 5_000, inputTokens = 10, totalTokens = 10)
|
||||
|
||||
assertNull(reset.deltaFrom(baseline).outputTokens)
|
||||
assertNull(UsageSnapshotResponse(outputTokens = 6).deltaFrom(baseline).outputTokens)
|
||||
assertEquals(0L, equal.deltaFrom(baseline).outputTokens)
|
||||
assertEquals(0L, equal.deltaFrom(baseline).inputTokens)
|
||||
assertEquals(0L, equal.deltaFrom(baseline).totalTokens)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user