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
+10 -6
View File
@@ -689,7 +689,8 @@ object JChatGPT : KotlinPlugin(
do { do {
try { try {
val startedAt = OffsetDateTime.now().toEpochSecond().toInt() val startedAt = OffsetDateTime.now().toEpochSecond().toInt()
val responseFlow = chatCompletions(history) var lastCacheUsage: ModelService.CacheUsage? = null
val responseFlow = chatCompletions(history) { lastCacheUsage = it }
var responseMessageBuilder: StringBuilder? = null var responseMessageBuilder: StringBuilder? = null
var reasoningContentBuilder: StringBuilder? = null var reasoningContentBuilder: StringBuilder? = null
val responseToolCalls = mutableListOf<ToolCall.Function>() val responseToolCalls = mutableListOf<ToolCall.Function>()
@@ -789,15 +790,17 @@ object JChatGPT : KotlinPlugin(
// 记录token使用量(按日聚合,独立JSON文件) // 记录token使用量(按日聚合,独立JSON文件)
lastTokenUsage?.let { usage -> lastTokenUsage?.let { usage ->
val now = OffsetDateTime.now().toEpochSecond() val now = OffsetDateTime.now().toEpochSecond()
val groupId = if (event is GroupMessageEvent) event.subject.id else null val group = if (event is GroupMessageEvent) event.group else null
TokenUsageStore.record( TokenUsageStore.record(
timestamp = now, timestamp = now,
userId = event.sender.id, userId = event.sender.id,
userNickname = event.senderName, userNickname = event.senderName,
groupId = groupId, groupId = group?.id,
groupName = group?.name,
promptTokens = usage.promptTokens ?: 0, promptTokens = usage.promptTokens ?: 0,
completionTokens = usage.completionTokens ?: 0, completionTokens = usage.completionTokens ?: 0,
totalTokens = usage.totalTokens ?: 0 totalTokens = usage.totalTokens ?: 0,
cachedTokens = lastCacheUsage?.hitTokens ?: 0
) )
} }
@@ -1028,7 +1031,8 @@ object JChatGPT : KotlinPlugin(
private fun chatCompletions( private fun chatCompletions(
chatMessages: List<ChatMessage>, chatMessages: List<ChatMessage>,
hasTools: Boolean = true hasTools: Boolean = true,
onCacheUsage: ((ModelService.CacheUsage) -> Unit)? = null
): Flow<ChatCompletionChunk> { ): Flow<ChatCompletionChunk> {
val llm = LargeLanguageModels.chat ?: throw NullPointerException("OpenAI Token 未设置,无法开始") val llm = LargeLanguageModels.chat ?: throw NullPointerException("OpenAI Token 未设置,无法开始")
val availableTools = if (hasTools) { val availableTools = if (hasTools) {
@@ -1041,7 +1045,7 @@ object JChatGPT : KotlinPlugin(
tools = availableTools, tools = availableTools,
) )
logger.info("API Requesting... Model=${PluginConfig.chatModel}") logger.info("API Requesting... Model=${PluginConfig.chatModel}")
return llm.chatCompletions(request) return llm.chatCompletions(request, onCacheUsage)
} }
private fun getNameCard(member: Member): String { private fun getNameCard(member: Member): String {
+28 -3
View File
@@ -48,7 +48,28 @@ class ModelService(
explicitNulls = false 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) val requestJson = json.encodeToJsonElement(ChatCompletionRequest.serializer(), request)
.jsonObject.toMutableMap() .jsonObject.toMutableMap()
requestJson["stream"] = JsonPrimitive(true) requestJson["stream"] = JsonPrimitive(true)
@@ -91,7 +112,9 @@ class ModelService(
} }
if (firstDataLine != null && !firstDataLine.startsWith("data: [DONE]")) { 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!! val ch = channel!!
while (currentCoroutineContext().isActive && !ch.isClosedForRead) { while (currentCoroutineContext().isActive && !ch.isClosedForRead) {
@@ -101,7 +124,9 @@ class ModelService(
when { when {
line.startsWith("data: [DONE]") -> break line.startsWith("data: [DONE]") -> break
line.startsWith("data: ") -> { 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 else -> continue
} }
+77 -190
View File
@@ -101,217 +101,85 @@ object PluginCommands : CompositeCommand(
val cutoff = calculateCutoffDate(days) val cutoff = calculateCutoffDate(days)
val today = LocalDate.now().toString() val today = LocalDate.now().toString()
data class Statistics( val windowed = TokenUsageStore.all.filter { it.date >= cutoff }
var totalTokens: Long = 0, if (windowed.isEmpty()) {
var todayTokens: Long = 0, sendMessage("最近 $days 天无 Token 使用记录")
val userTotals: MutableMap<Long, Pair<String, Long>> = mutableMapOf(), return
val groupTotals: MutableMap<Long, Long> = mutableMapOf(),
val users: MutableSet<Long> = mutableSetOf()
)
val stats = TokenUsageStore.all.fold(Statistics()) { acc, record ->
if (record.date >= cutoff) {
acc.totalTokens += record.totalTokens
acc.users.add(record.userId)
val existing = acc.userTotals[record.userId]
if (existing == null) {
acc.userTotals[record.userId] = record.userNickname to record.totalTokens
} else {
acc.userTotals[record.userId] = existing.first to (existing.second + record.totalTokens)
} }
record.groupId?.let { groupId -> // 窗口汇总
acc.groupTotals[groupId] = acc.groupTotals.getOrDefault(groupId, 0L) + record.totalTokens var prompt = 0L; var completion = 0L; var total = 0L; var cached = 0L
} var calls = 0; var todayTotal = 0L
val users = HashSet<Long>()
for (r in windowed) {
prompt += r.promptTokens
completion += r.completionTokens
total += r.totalTokens
cached += r.cachedTokens
calls += r.callCount
users.add(r.userId)
if (r.date == today) todayTotal += r.totalTokens
} }
val hitRate = if (prompt > 0) cached * 100.0 / prompt else 0.0
if (record.date == today) { // 每日趋势
acc.todayTokens += record.totalTokens val daily = windowed.groupBy { it.date }
} .mapValues { (_, rs) -> rs.sumOf { it.totalTokens } }
acc
}
val topUser = stats.userTotals.entries.maxByOrNull { it.value.second }
val topGroup = stats.groupTotals.entries.maxByOrNull { it.value }
val response = buildString {
appendLine("📊 Token 使用简报(最近 $days 天)")
appendLine()
appendLine("总计: ${formatNumber(stats.totalTokens)} tokens")
appendLine("今日: ${formatNumber(stats.todayTokens)} tokens")
appendLine("活跃用户: ${stats.users.size}")
topUser?.let {
appendLine()
appendLine("👤 最活跃用户:")
appendLine(" ${it.value.first} - ${formatNumber(it.value.second)} tokens")
}
topGroup?.let {
appendLine()
appendLine("👥 最活跃群组:")
appendLine(" ${it.key} - ${formatNumber(it.value)} tokens")
}
appendLine()
appendLine("📋 详细查询:")
appendLine(" /jgpt tokensDaily [days] - 每日统计")
appendLine(" /jgpt tokensUsers [limit] - 用户排名")
appendLine(" /jgpt tokensGroups [limit] - 群组排名")
appendLine(" /jgpt tokensQuery [userId] [days] - 每日逐人记录")
appendLine(" /jgpt tokensUserDaily <userId> [days] - 用户日统计")
}
sendMessage(response)
}
@SubCommand
suspend fun CommandSender.tokensDaily(days: Int = 7) {
validateDays(days)
val cutoff = calculateCutoffDate(days)
val dailyStats = TokenUsageStore.all
.filter { it.date >= cutoff }
.groupBy { it.date }
.mapValues { (_, records) -> records.sumOf { it.totalTokens } }
.toSortedMap() .toSortedMap()
if (dailyStats.isEmpty()) { // Top 用户
sendMessage("指定时间范围内无使用记录") val topUsers = windowed.groupBy { it.userId }
return .map { (_, rs) ->
val name = rs.maxByOrNull { it.date }!!.userNickname
name to rs.sumOf { it.totalTokens }
} }
val response = buildString {
appendLine("最近 $days 天 Token 使用统计:")
appendLine()
dailyStats.forEach { (date, total) ->
appendLine("$date: ${formatNumber(total)} tokens")
}
}
sendMessage(response)
}
@SubCommand
suspend fun CommandSender.tokensUsers(limit: Int = 10) {
require(limit > 0) { "limit must be positive: $limit" }
val userStats = TokenUsageStore.all
.groupBy { it.userId }
.mapValues { (_, records) ->
val latest = records.maxByOrNull { it.date }!!
Pair(latest.userNickname, records.sumOf { it.totalTokens })
}
.toList()
.sortedByDescending { it.second.second }
.take(limit)
if (userStats.isEmpty()) {
sendMessage("暂无使用记录")
return
}
val response = buildString {
appendLine("Token 使用排名 Top $limit")
appendLine()
userStats.forEach {
appendLine("- ${it.second.first}(${it.first}): ${formatNumber(it.second.second)} tokens")
}
}
sendMessage(response)
}
@SubCommand
suspend fun CommandSender.tokensGroups(limit: Int = 10) {
require(limit > 0) { "limit must be positive: $limit" }
val groupStats = TokenUsageStore.all
.filter { it.groupId != null }
.groupBy { it.groupId!! }
.mapValues { (_, records) -> records.sumOf { it.totalTokens } }
.toList()
.sortedByDescending { it.second } .sortedByDescending { it.second }
.take(limit) .take(TOP_LIMIT)
if (groupStats.isEmpty()) { // Top 群组:只显示群名,绝不暴露群号(避免被误判宣群)
sendMessage("暂无群组使用记录") val topGroups = windowed.filter { it.groupId != null }
return .groupBy { it.groupId!! }
.map { (gid, rs) ->
val name = rs.firstNotNullOfOrNull { r -> r.groupName?.takeIf { it.isNotBlank() } }
?: resolveGroupName(gid)
name to rs.sumOf { it.totalTokens }
} }
.sortedByDescending { it.second }
.take(TOP_LIMIT)
val response = buildString { val response = buildString {
appendLine("群组 Token 使用排名 Top $limit") appendLine("📊 Token 简报 · 最近 $days")
appendLine() appendLine()
groupStats.forEach { (groupId, total) -> appendLine("输入 ${formatCompact(prompt)}(缓存命中 ${"%.1f".format(hitRate)}%,省 ${formatCompact(cached)}")
appendLine("- $groupId: ${formatNumber(total)} tokens") appendLine("输出 ${formatCompact(completion)}")
} appendLine("总计 ${formatCompact(total)} 调用 ${formatNumber(calls)} 活跃 ${users.size}")
} appendLine("今日 ${formatCompact(todayTotal)}")
sendMessage(response)
}
@SubCommand if (daily.size > 1) {
suspend fun CommandSender.tokensQuery(userId: Long?, days: Int = 7) {
validateDays(days)
val cutoff = calculateCutoffDate(days)
val filtered = TokenUsageStore.all
.filter { it.date >= cutoff }
.filter { userId == null || it.userId == userId }
.sortedWith(compareByDescending<TokenUsageDailyRecord> { it.date }.thenByDescending { it.totalTokens })
.take(DEFAULT_QUERY_LIMIT)
if (filtered.isEmpty()) {
sendMessage("指定时间范围内无使用记录")
return
}
val response = buildString {
appendLine("最近 $days 天使用记录(最多显示${DEFAULT_QUERY_LIMIT}条,按日聚合):")
appendLine() appendLine()
filtered.forEach { record -> appendLine("📈 每日趋势")
val location = if (record.groupId != null) "${record.groupId}" else "私聊" daily.forEach { (date, t) ->
appendLine("[${record.date}] $location - ${record.userNickname}") appendLine(" ${date.substring(5)} ${formatCompact(t)}")
appendLine(" 调用 ${record.callCount} 次, Tokens: ${formatNumber(record.totalTokens)} " + }
"(输入: ${formatNumber(record.promptTokens)}, 输出: ${formatNumber(record.completionTokens)})") }
if (topUsers.isNotEmpty()) {
appendLine() appendLine()
appendLine("👤 Top 用户")
topUsers.forEachIndexed { i, (name, t) ->
appendLine(" ${i + 1}. $name ${formatCompact(t)}")
} }
} }
sendMessage(response)
}
@SubCommand if (topGroups.isNotEmpty()) {
suspend fun CommandSender.tokensUserDaily(userId: Long, days: Int = 7) {
validateDays(days)
val cutoff = calculateCutoffDate(days)
val userRecords = TokenUsageStore.all
.filter { it.date >= cutoff && it.userId == userId }
if (userRecords.isEmpty()) {
sendMessage("用户 $userId 在指定时间范围内无使用记录")
return
}
val userNickname = userRecords.maxByOrNull { it.date }!!.userNickname
val userDailyStats = userRecords
.groupBy { it.date }
.mapValues { (_, records) -> records.sumOf { it.totalTokens } }
.toSortedMap()
val response = buildString {
appendLine("用户 $userNickname 最近 $days 天 Token 使用统计:")
appendLine() appendLine()
userDailyStats.forEach { (date, total) -> appendLine("👥 Top 群组")
appendLine("$date: ${formatNumber(total)} tokens") topGroups.forEachIndexed { i, (name, t) ->
appendLine(" ${i + 1}. $name ${formatCompact(t)}")
} }
appendLine()
appendLine("总计: ${formatNumber(userDailyStats.values.sum())} tokens")
} }
sendMessage(response) }
sendMessage(response.trim())
} }
// ==================== 辅助函数 ==================== // ==================== 辅助函数 ====================
@@ -330,6 +198,25 @@ object PluginCommands : CompositeCommand(
return String.format("%,d", number.toLong()) return String.format("%,d", number.toLong())
} }
/**
* 大数压缩为 K/M,简报用,避免一屏全是逗号长串。
*/
private fun formatCompact(n: Long): String = when {
n >= 1_000_000 -> "%.2fM".format(n / 1_000_000.0)
n >= 1_000 -> "%.1fK".format(n / 1_000.0)
else -> n.toString()
}
/**
* 解析群名:记录里没存到群名时(旧数据)才回退到在线 Bot 查询,
* 仍查不到则用占位文案,绝不直接展示群号。
*/
private fun resolveGroupName(groupId: Long): String {
return net.mamoe.mirai.Bot.instances
.firstNotNullOfOrNull { it.getGroup(groupId)?.name }
?: "未知群聊"
}
/** /**
* 验证天数参数 * 验证天数参数
*/ */
@@ -339,4 +226,4 @@ object PluginCommands : CompositeCommand(
} }
// 常量定义 // 常量定义
private const val DEFAULT_QUERY_LIMIT = 20 private const val TOP_LIMIT = 5
+4
View File
@@ -56,9 +56,13 @@ data class TokenUsageDailyRecord(
val userId: Long, val userId: Long,
val userNickname: String, val userNickname: String,
val groupId: Long?, val groupId: Long?,
/** 群名称,记录时捕获。展示时优先用它,避免暴露群号(被误判宣群)。私聊为 null。 */
val groupName: String? = null,
val promptTokens: Long = 0, val promptTokens: Long = 0,
val completionTokens: Long = 0, val completionTokens: Long = 0,
val totalTokens: Long = 0, val totalTokens: Long = 0,
/** 命中缓存的输入 token 数(DeepSeek: prompt_cache_hit_tokens)。缓存命中率 = cachedTokens / promptTokens */
val cachedTokens: Long = 0,
val callCount: Int = 0 val callCount: Int = 0
) )
+8 -1
View File
@@ -52,13 +52,16 @@ object TokenUsageStore {
userId: Long, userId: Long,
userNickname: String, userNickname: String,
groupId: Long?, groupId: Long?,
groupName: String?,
promptTokens: Int, promptTokens: Int,
completionTokens: Int, completionTokens: Int,
totalTokens: Int totalTokens: Int,
cachedTokens: Int
) { ) {
val date = LocalDate.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault()) val date = LocalDate.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault())
.format(dateFmt) .format(dateFmt)
val nickname = sanitizeNickname(userNickname) val nickname = sanitizeNickname(userNickname)
val groupNameClean = groupName?.let { sanitizeNickname(it) }
val idx = records.indexOfFirst { val idx = records.indexOfFirst {
it.date == date && it.userId == userId && it.groupId == groupId it.date == date && it.userId == userId && it.groupId == groupId
} }
@@ -66,9 +69,11 @@ object TokenUsageStore {
val r = records[idx] val r = records[idx]
records[idx] = r.copy( records[idx] = r.copy(
userNickname = nickname.ifEmpty { r.userNickname }, userNickname = nickname.ifEmpty { r.userNickname },
groupName = groupNameClean?.ifEmpty { null } ?: r.groupName,
promptTokens = r.promptTokens + promptTokens, promptTokens = r.promptTokens + promptTokens,
completionTokens = r.completionTokens + completionTokens, completionTokens = r.completionTokens + completionTokens,
totalTokens = r.totalTokens + totalTokens, totalTokens = r.totalTokens + totalTokens,
cachedTokens = r.cachedTokens + cachedTokens,
callCount = r.callCount + 1 callCount = r.callCount + 1
) )
} else { } else {
@@ -78,9 +83,11 @@ object TokenUsageStore {
userId = userId, userId = userId,
userNickname = nickname, userNickname = nickname,
groupId = groupId, groupId = groupId,
groupName = groupNameClean?.ifEmpty { null },
promptTokens = promptTokens.toLong(), promptTokens = promptTokens.toLong(),
completionTokens = completionTokens.toLong(), completionTokens = completionTokens.toLong(),
totalTokens = totalTokens.toLong(), totalTokens = totalTokens.toLong(),
cachedTokens = cachedTokens.toLong(),
callCount = 1 callCount = 1
) )
) )