Track cache-hit tokens and consolidate token stats into one dashboard

Capture DeepSeek's prompt_cache_hit_tokens (dropped before by the
openai-kotlin Usage parser) via a raw-JSON extractor in ModelService,
and persist it plus the group name on each daily record.

Collapse the six /jgpt tokens* subcommands into a single /jgpt tokens
dashboard showing cache-hit rate, input/output split, daily trend and
top users/groups. Groups are shown by name only, never by group id.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-06-23 23:01:16 +08:00
co-authored by Claude Opus 4.8
parent cfc61c52ba
commit 13329b5fa3
5 changed files with 133 additions and 206 deletions
+28 -3
View File
@@ -48,7 +48,28 @@ class ModelService(
explicitNulls = false
}
fun chatCompletions(request: ChatCompletionRequest): Flow<ChatCompletionChunk> {
/**
* 一次响应的缓存命中用量。DeepSeek 在 usage 顶层返回的非标准字段,
* openai-kotlin 的 Usage 类不含这些字段,必须从原始 JSON 抠出来。
*/
data class CacheUsage(val hitTokens: Int, val missTokens: Int)
/** 从原始 data 行(已去掉 "data: " 前缀)解析缓存命中用量;无相关字段返回 null。 */
private fun extractCacheUsage(rawJson: String): CacheUsage? {
return try {
val usage = json.parseToJsonElement(rawJson).jsonObject["usage"]?.jsonObject ?: return null
val hit = usage["prompt_cache_hit_tokens"]?.jsonPrimitive?.intOrNull
val miss = usage["prompt_cache_miss_tokens"]?.jsonPrimitive?.intOrNull
if (hit == null && miss == null) null else CacheUsage(hit ?: 0, miss ?: 0)
} catch (_: Exception) {
null
}
}
fun chatCompletions(
request: ChatCompletionRequest,
onCacheUsage: ((CacheUsage) -> Unit)? = null
): Flow<ChatCompletionChunk> {
val requestJson = json.encodeToJsonElement(ChatCompletionRequest.serializer(), request)
.jsonObject.toMutableMap()
requestJson["stream"] = JsonPrimitive(true)
@@ -91,7 +112,9 @@ class ModelService(
}
if (firstDataLine != null && !firstDataLine.startsWith("data: [DONE]")) {
emit(json.decodeFromString(firstDataLine.removePrefix("data: ")))
val firstRaw = firstDataLine.removePrefix("data: ")
emit(json.decodeFromString(firstRaw))
onCacheUsage?.let { cb -> extractCacheUsage(firstRaw)?.let(cb) }
val ch = channel!!
while (currentCoroutineContext().isActive && !ch.isClosedForRead) {
@@ -101,7 +124,9 @@ class ModelService(
when {
line.startsWith("data: [DONE]") -> break
line.startsWith("data: ") -> {
emit(json.decodeFromString(line.removePrefix("data: ")))
val raw = line.removePrefix("data: ")
emit(json.decodeFromString(raw))
onCacheUsage?.let { cb -> extractCacheUsage(raw)?.let(cb) }
}
else -> continue
}