feat(chat): per-turn token usage breakdown with cache hit/miss/write and reasoning split (#474)

This commit is contained in:
matevip 2026-07-03 11:28:00 +08:00
parent 6741920446
commit 25737495e5
29 changed files with 706 additions and 72 deletions

View File

@ -648,6 +648,9 @@ public class AgentGraphBuilder {
// Token Usage // Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
@ -936,6 +939,9 @@ public class AgentGraphBuilder {
// Token Usage // Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的 // SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的

View File

@ -954,9 +954,10 @@ public class NodeStreamingChatHelper {
AtomicReference<Throwable> errorRef = new AtomicReference<>(); AtomicReference<Throwable> errorRef = new AtomicReference<>();
AtomicInteger promptTokens = new AtomicInteger(0); AtomicInteger promptTokens = new AtomicInteger(0);
AtomicInteger completionTokens = new AtomicInteger(0); AtomicInteger completionTokens = new AtomicInteger(0);
// RFC-014: Anthropic prompt cache 计数其它 provider 永远为 0 // Prompt cache / reasoning counters; providers that don't report them stay 0.
AtomicInteger cacheReadTokens = new AtomicInteger(0); AtomicInteger cacheReadTokens = new AtomicInteger(0);
AtomicInteger cacheWriteTokens = new AtomicInteger(0); AtomicInteger cacheWriteTokens = new AtomicInteger(0);
AtomicInteger reasoningTokens = new AtomicInteger(0);
// thinking-only soft cap 触发后设为 true外层轮询线程据此 dispose 订阅 // thinking-only soft cap 触发后设为 true外层轮询线程据此 dispose 订阅
// 注意内容流的字符级 / 句子级重复检测已整体移除设计取舍 // 注意内容流的字符级 / 句子级重复检测已整体移除设计取舍
@ -1142,10 +1143,12 @@ public class NodeStreamingChatHelper {
if (usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) { if (usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) {
completionTokens.set(usage.getCompletionTokens().intValue()); completionTokens.set(usage.getCompletionTokens().intValue());
} }
// RFC-014: 反射抽取 Anthropic prompt cache 字段DashScope/OpenAI 自然返回 0 // Reflective extraction of provider-native cache / reasoning
// counters (Anthropic / OpenAI-compatible / DashScope).
var cache = vip.mate.llm.cache.CacheUsageExtractor.extract(usage); var cache = vip.mate.llm.cache.CacheUsageExtractor.extract(usage);
if (cache.cacheReadTokens() > 0) cacheReadTokens.set(cache.cacheReadTokens()); if (cache.cacheReadTokens() > 0) cacheReadTokens.set(cache.cacheReadTokens());
if (cache.cacheWriteTokens() > 0) cacheWriteTokens.set(cache.cacheWriteTokens()); if (cache.cacheWriteTokens() > 0) cacheWriteTokens.set(cache.cacheWriteTokens());
if (cache.reasoningTokens() > 0) reasoningTokens.set(cache.reasoningTokens());
} }
}) })
.subscribe( .subscribe(
@ -1197,7 +1200,8 @@ public class NodeStreamingChatHelper {
toolCallAccumulators.size(), conversationId); toolCallAccumulators.size(), conversationId);
return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators, return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(), promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(), phase); cacheReadTokens.get(), cacheWriteTokens.get(),
reasoningTokens.get(), phase);
} }
log.info("[{}] Stop requested during LLM call, no content accumulated, aborting: conversationId={}", log.info("[{}] Stop requested during LLM call, no content accumulated, aborting: conversationId={}",
phase, conversationId); phase, conversationId);
@ -1231,6 +1235,7 @@ public class NodeStreamingChatHelper {
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators, return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(), promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(), cacheReadTokens.get(), cacheWriteTokens.get(),
reasoningTokens.get(),
phase, true, error.getMessage()); phase, true, error.getMessage());
} }
@ -1319,7 +1324,8 @@ public class NodeStreamingChatHelper {
: null; : null;
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators, return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(), promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(), phase, cacheReadTokens.get(), cacheWriteTokens.get(),
reasoningTokens.get(), phase,
truncated, truncated,
truncationReason); truncationReason);
} }
@ -1328,7 +1334,8 @@ public class NodeStreamingChatHelper {
private StreamResult assembleStoppedResult(StringBuilder contentAccum, StringBuilder thinkingAccum, private StreamResult assembleStoppedResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
List<ToolCallAccumulator> toolCallAccumulators, List<ToolCallAccumulator> toolCallAccumulators,
int promptTok, int completionTok, int promptTok, int completionTok,
int cacheReadTok, int cacheWriteTok, String phase) { int cacheReadTok, int cacheWriteTok,
int reasoningTok, String phase) {
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators); List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString(); String fullContent = contentAccum.toString();
String fullThinking = thinkingAccum.toString(); String fullThinking = thinkingAccum.toString();
@ -1351,14 +1358,14 @@ public class NodeStreamingChatHelper {
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok); recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage, return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok); true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok, reasoningTok);
} }
/** 组装最终 StreamResult成功或 partial */ /** 组装最终 StreamResult成功或 partial */
private StreamResult assembleResult(StringBuilder contentAccum, StringBuilder thinkingAccum, private StreamResult assembleResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
List<ToolCallAccumulator> toolCallAccumulators, List<ToolCallAccumulator> toolCallAccumulators,
int promptTok, int completionTok, int promptTok, int completionTok,
int cacheReadTok, int cacheWriteTok, int cacheReadTok, int cacheWriteTok, int reasoningTok,
String phase, boolean partial, String errorMsg) { String phase, boolean partial, String errorMsg) {
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators); List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString(); String fullContent = contentAccum.toString();
@ -1384,7 +1391,7 @@ public class NodeStreamingChatHelper {
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok); recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage, return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok); partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok, reasoningTok);
} }
/** /**
@ -1822,17 +1829,19 @@ public class NodeStreamingChatHelper {
ErrorType errorType, ErrorType errorType,
/** 用户主动停止stopRequested导致的提前返回 */ /** 用户主动停止stopRequested导致的提前返回 */
boolean stopped, boolean stopped,
/** RFC-014: Anthropic prompt cache 命中字节数(其它 provider 为 0 */ /** Prompt cache 命中 tokensprovider 未上报时为 0 */
int cacheReadTokens, int cacheReadTokens,
/** RFC-014: Anthropic prompt cache 写入字节数(其它 provider 为 0 */ /** Prompt cache 写入 tokensprovider 未上报时为 0 */
int cacheWriteTokens int cacheWriteTokens,
/** 思考reasoning阶段消耗的 completion tokensprovider 未上报时为 0 */
int reasoningTokens
) { ) {
/** 兼容旧调用方 — 无 partial/error/stopped 的正常结果 */ /** 兼容旧调用方 — 无 partial/error/stopped 的正常结果 */
public StreamResult(String text, String thinking, AssistantMessage assistantMessage, public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls, List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
int promptTokens, int completionTokens) { int promptTokens, int completionTokens) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls, this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0); promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0, 0);
} }
/** 兼容 10-arg 调用点 */ /** 兼容 10-arg 调用点 */
@ -1841,17 +1850,17 @@ public class NodeStreamingChatHelper {
int promptTokens, int completionTokens, int promptTokens, int completionTokens,
boolean partial, String errorMessage, ErrorType errorType) { boolean partial, String errorMessage, ErrorType errorType) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls, this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0); promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0, 0);
} }
/** 兼容 12-arg 调用点pre-RFC-014 */ /** 兼容 11-arg 调用点(无 cache/reasoning 计数 */
public StreamResult(String text, String thinking, AssistantMessage assistantMessage, public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls, List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
int promptTokens, int completionTokens, int promptTokens, int completionTokens,
boolean partial, String errorMessage, ErrorType errorType, boolean partial, String errorMessage, ErrorType errorType,
boolean stopped) { boolean stopped) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls, this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0); promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0, 0);
} }
/** 是否有不可忽略的错误(无内容 + 有错误) */ /** 是否有不可忽略的错误(无内容 + 有错误) */

View File

@ -198,6 +198,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
AtomicInteger sentEventCount = new AtomicInteger(0); AtomicInteger sentEventCount = new AtomicInteger(0);
AtomicInteger finalPromptTokens = new AtomicInteger(0); AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = new AtomicInteger(0); AtomicInteger finalCompletionTokens = new AtomicInteger(0);
AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
AtomicInteger finalReasoningTokens = new AtomicInteger(0);
AtomicReference<String> finalModelName = new AtomicReference<>(""); AtomicReference<String> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>(""); AtomicReference<String> finalProviderId = new AtomicReference<>("");
// 防重保护 chatStructuredStream // 防重保护 chatStructuredStream
@ -269,6 +272,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0)); finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0)); finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
finalCacheReadTokens.set(output.state().value(CACHE_READ_TOKENS, 0));
finalCacheWriteTokens.set(output.state().value(CACHE_WRITE_TOKENS, 0));
finalReasoningTokens.set(output.state().value(REASONING_TOKENS, 0));
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, "")); finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, "")); finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
@ -295,6 +301,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
"completionTokens", completionTokens, "completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(), "delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(), "delegatedCompletionTokens", delegated.completionTokens(),
"cacheReadTokens", finalCacheReadTokens.get(),
"cacheWriteTokens", finalCacheWriteTokens.get(),
"reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(), "runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get() "runtimeProviderId", finalProviderId.get()
)); ));
@ -348,6 +357,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// Token usage 追踪每次 NodeOutput 更新最新累计值最后一次即最终值 // Token usage 追踪每次 NodeOutput 更新最新累计值最后一次即最终值
AtomicInteger finalPromptTokens = new AtomicInteger(0); AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = new AtomicInteger(0); AtomicInteger finalCompletionTokens = new AtomicInteger(0);
AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
AtomicInteger finalReasoningTokens = new AtomicInteger(0);
AtomicReference<String> finalModelName = new AtomicReference<>(""); AtomicReference<String> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>(""); AtomicReference<String> finalProviderId = new AtomicReference<>("");
// 防重保护StateGraph 对每个节点都 emit NodeOutputFINAL_ANSWER 一旦写入后续节点都携带 // 防重保护StateGraph 对每个节点都 emit NodeOutputFINAL_ANSWER 一旦写入后续节点都携带
@ -433,6 +445,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// 3. 更新最新累计 token usage // 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0)); finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0)); finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
finalCacheReadTokens.set(output.state().value(CACHE_READ_TOKENS, 0));
finalCacheWriteTokens.set(output.state().value(CACHE_WRITE_TOKENS, 0));
finalReasoningTokens.set(output.state().value(REASONING_TOKENS, 0));
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, "")); finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, "")); finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
@ -461,6 +476,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
"completionTokens", completionTokens, "completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(), "delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(), "delegatedCompletionTokens", delegated.completionTokens(),
"cacheReadTokens", finalCacheReadTokens.get(),
"cacheWriteTokens", finalCacheWriteTokens.get(),
"reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(), "runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get() "runtimeProviderId", finalProviderId.get()
)); ));
@ -551,6 +569,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(FORCED_TOOL_CALL, ""); inputs.put(FORCED_TOOL_CALL, "");
inputs.put(PROMPT_TOKENS, 0); inputs.put(PROMPT_TOKENS, 0);
inputs.put(COMPLETION_TOKENS, 0); inputs.put(COMPLETION_TOKENS, 0);
inputs.put(CACHE_READ_TOKENS, 0);
inputs.put(CACHE_WRITE_TOKENS, 0);
inputs.put(REASONING_TOKENS, 0);
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : ""); inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : ""); inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8)); inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));

View File

@ -143,6 +143,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
AtomicInteger sentEventCount = new AtomicInteger(0); AtomicInteger sentEventCount = new AtomicInteger(0);
AtomicInteger finalPromptTokens = new AtomicInteger(0); AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = new AtomicInteger(0); AtomicInteger finalCompletionTokens = new AtomicInteger(0);
AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
AtomicInteger finalReasoningTokens = new AtomicInteger(0);
AtomicReference<String> finalModelName = new AtomicReference<>(""); AtomicReference<String> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>(""); AtomicReference<String> finalProviderId = new AtomicReference<>("");
// Root conversation for this turn used to roll delegated sub-agent // Root conversation for this turn used to roll delegated sub-agent
@ -208,6 +211,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
// 3. 更新最新累计 token usage // 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0)); finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(MateClawStateKeys.COMPLETION_TOKENS, 0)); finalCompletionTokens.set(output.state().value(MateClawStateKeys.COMPLETION_TOKENS, 0));
finalCacheReadTokens.set(output.state().value(MateClawStateKeys.CACHE_READ_TOKENS, 0));
finalCacheWriteTokens.set(output.state().value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0));
finalReasoningTokens.set(output.state().value(MateClawStateKeys.REASONING_TOKENS, 0));
finalModelName.set(output.state().value(MateClawStateKeys.RUNTIME_MODEL_NAME, "")); finalModelName.set(output.state().value(MateClawStateKeys.RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "")); finalProviderId.set(output.state().value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""));
@ -229,6 +235,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
"completionTokens", completionTokens, "completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(), "delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(), "delegatedCompletionTokens", delegated.completionTokens(),
"cacheReadTokens", finalCacheReadTokens.get(),
"cacheWriteTokens", finalCacheWriteTokens.get(),
"reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(), "runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get() "runtimeProviderId", finalProviderId.get()
)); ));
@ -320,6 +329,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
inputs.put(MateClawStateKeys.REQUESTER_ID, ""); inputs.put(MateClawStateKeys.REQUESTER_ID, "");
inputs.put(MateClawStateKeys.PROMPT_TOKENS, 0); inputs.put(MateClawStateKeys.PROMPT_TOKENS, 0);
inputs.put(MateClawStateKeys.COMPLETION_TOKENS, 0); inputs.put(MateClawStateKeys.COMPLETION_TOKENS, 0);
inputs.put(MateClawStateKeys.CACHE_READ_TOKENS, 0);
inputs.put(MateClawStateKeys.CACHE_WRITE_TOKENS, 0);
inputs.put(MateClawStateKeys.REASONING_TOKENS, 0);
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : ""); inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : ""); inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8)); inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));

View File

@ -248,6 +248,9 @@ public class StepExecutionNode implements NodeAction {
String approvalToolName = null; String approvalToolName = null;
int stepPromptTokens = 0; int stepPromptTokens = 0;
int stepCompletionTokens = 0; int stepCompletionTokens = 0;
int stepCacheReadTokens = 0;
int stepCacheWriteTokens = 0;
int stepReasoningTokens = 0;
// RFC-052: any returnDirect tool that fires inside this step must // RFC-052: any returnDirect tool that fires inside this step must
// short-circuit the entire plan (not just this step). We accumulate // short-circuit the entire plan (not just this step). We accumulate
@ -322,6 +325,9 @@ public class StepExecutionNode implements NodeAction {
stepPromptTokens += result.promptTokens(); stepPromptTokens += result.promptTokens();
stepCompletionTokens += result.completionTokens(); stepCompletionTokens += result.completionTokens();
stepCacheReadTokens += result.cacheReadTokens();
stepCacheWriteTokens += result.cacheWriteTokens();
stepReasoningTokens += result.reasoningTokens();
if (!result.thinking().isEmpty()) { if (!result.thinking().isEmpty()) {
stepThinking = result.thinking(); stepThinking = result.thinking();
@ -444,8 +450,8 @@ public class StepExecutionNode implements NodeAction {
.currentPhase("awaiting_approval") .currentPhase("awaiting_approval")
.contentStreamed(true) .contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty()) .thinkingStreamed(!stepThinking.isEmpty())
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events) .events(events)
.build(); .build();
} }
@ -480,10 +486,8 @@ public class StepExecutionNode implements NodeAction {
.contentStreamed(false) // StateGraphPlanExecuteAgent finalSummary 推送 .contentStreamed(false) // StateGraphPlanExecuteAgent finalSummary 推送
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true) .put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs)) .put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
.put(MateClawStateKeys.PROMPT_TOKENS, .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS,
state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.events(events) .events(events)
.build(); .build();
} }
@ -532,8 +536,8 @@ public class StepExecutionNode implements NodeAction {
.currentStepTitle("") .currentStepTitle("")
.currentStepResult("") .currentStepResult("")
.contentStreamed(false) .contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events) .events(events)
.build(); .build();
} }
@ -590,8 +594,8 @@ public class StepExecutionNode implements NodeAction {
.currentStepTitle("") .currentStepTitle("")
.currentStepResult("") .currentStepResult("")
.contentStreamed(false) .contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events) .events(events)
.build(); .build();
} }
@ -602,8 +606,8 @@ public class StepExecutionNode implements NodeAction {
.currentStepResult(shortError) .currentStepResult(shortError)
.currentPhase("plan_aborted") .currentPhase("plan_aborted")
.contentStreamed(false) .contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events) .events(events)
.build(); .build();
} }
@ -646,8 +650,8 @@ public class StepExecutionNode implements NodeAction {
.currentPhase("step_completed") .currentPhase("step_completed")
.contentStreamed(true) .contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty()) .thinkingStreamed(!stepThinking.isEmpty())
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events) .events(events)
.build(); .build();
} }

View File

@ -258,10 +258,37 @@ public final class PlanStateAccessor {
int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0); int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0);
map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens()); map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens());
map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens()); map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens());
map.put(MateClawStateKeys.CACHE_READ_TOKENS,
currentState.value(MateClawStateKeys.CACHE_READ_TOKENS, 0) + result.cacheReadTokens());
map.put(MateClawStateKeys.CACHE_WRITE_TOKENS,
currentState.value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0) + result.cacheWriteTokens());
map.put(MateClawStateKeys.REASONING_TOKENS,
currentState.value(MateClawStateKeys.REASONING_TOKENS, 0) + result.reasoningTokens());
map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1); map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1);
return this; return this;
} }
/**
* 将一个 step 的累计 usage cache / reasoning 分项加到 state 已有值上
* StepExecutionNode 在多个出口路径上写回同一组键统一走这里避免漏项
*/
public OutputBuilder addStepUsage(OverAllState currentState,
int promptTokens, int completionTokens,
int cacheReadTokens, int cacheWriteTokens,
int reasoningTokens) {
map.put(MateClawStateKeys.PROMPT_TOKENS,
currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0) + promptTokens);
map.put(MateClawStateKeys.COMPLETION_TOKENS,
currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + completionTokens);
map.put(MateClawStateKeys.CACHE_READ_TOKENS,
currentState.value(MateClawStateKeys.CACHE_READ_TOKENS, 0) + cacheReadTokens);
map.put(MateClawStateKeys.CACHE_WRITE_TOKENS,
currentState.value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0) + cacheWriteTokens);
map.put(MateClawStateKeys.REASONING_TOKENS,
currentState.value(MateClawStateKeys.REASONING_TOKENS, 0) + reasoningTokens);
return this;
}
public Map<String, Object> build() { public Map<String, Object> build() {
return map; return map;
} }

View File

@ -537,6 +537,12 @@ public final class MateClawStateAccessor {
int existingCompletion = currentState.value(COMPLETION_TOKENS, 0); int existingCompletion = currentState.value(COMPLETION_TOKENS, 0);
map.put(PROMPT_TOKENS, existingPrompt + result.promptTokens()); map.put(PROMPT_TOKENS, existingPrompt + result.promptTokens());
map.put(COMPLETION_TOKENS, existingCompletion + result.completionTokens()); map.put(COMPLETION_TOKENS, existingCompletion + result.completionTokens());
map.put(CACHE_READ_TOKENS,
currentState.value(CACHE_READ_TOKENS, 0) + result.cacheReadTokens());
map.put(CACHE_WRITE_TOKENS,
currentState.value(CACHE_WRITE_TOKENS, 0) + result.cacheWriteTokens());
map.put(REASONING_TOKENS,
currentState.value(REASONING_TOKENS, 0) + result.reasoningTokens());
return this; return this;
} }

View File

@ -155,6 +155,12 @@ public final class MateClawStateKeys {
// ===== Token Usage 累计REPLACE 策略节点内累加后写回===== // ===== Token Usage 累计REPLACE 策略节点内累加后写回=====
public static final String PROMPT_TOKENS = "prompt_tokens"; public static final String PROMPT_TOKENS = "prompt_tokens";
public static final String COMPLETION_TOKENS = "completion_tokens"; public static final String COMPLETION_TOKENS = "completion_tokens";
/** Prompt cache 命中 tokens 累计provider 未上报时保持 0 */
public static final String CACHE_READ_TOKENS = "cache_read_tokens";
/** Prompt cache 写入 tokens 累计provider 未上报时保持 0 */
public static final String CACHE_WRITE_TOKENS = "cache_write_tokens";
/** 思考reasoningtokens 累计provider 未上报时保持 0 */
public static final String REASONING_TOKENS = "reasoning_tokens";
// ===== 运行时模型快照REPLACE 策略buildInitialState 注入===== // ===== 运行时模型快照REPLACE 策略buildInitialState 注入=====
public static final String RUNTIME_MODEL_NAME = "runtime_model_name"; public static final String RUNTIME_MODEL_NAME = "runtime_model_name";

View File

@ -790,7 +790,7 @@ public class ChannelMessageRouter {
StringBuilder replyAccumulator = new StringBuilder(); StringBuilder replyAccumulator = new StringBuilder();
final String channelType = adapter.getChannelType(); final String channelType = adapter.getChannelType();
// Token usage + model attribution: capture _usage_final event emitted at stream end // Token usage + model attribution: capture _usage_final event emitted at stream end
final int[] usage = {0, 0}; // [promptTokens, completionTokens] final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider] final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
agentService.chatStructuredStream(agentId, promptText, conversationId, agentService.chatStructuredStream(agentId, promptText, conversationId,
message.getSenderId(), chatOrigin) message.getSenderId(), chatOrigin)
@ -800,6 +800,9 @@ public class ChannelMessageRouter {
Map<String, Object> data = delta.eventData(); Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue(); usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName"); Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId"); Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString(); if (model != null) modelInfo[0] = model.toString();
@ -840,7 +843,7 @@ public class ChannelMessageRouter {
String status = isError ? "error" : "completed"; String status = isError ? "error" : "completed";
MessageEntity saved = conversationService.saveMessage( MessageEntity saved = conversationService.saveMessage(
conversationId, "assistant", reply, null, status, conversationId, "assistant", reply, null, status,
usage[0], usage[1], modelInfo[0], modelInfo[1]); usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
savedAssistantId = saved != null ? saved.getId() : null; savedAssistantId = saved != null ? saved.getId() : null;
if (!isError) { if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin); publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin);
@ -955,13 +958,16 @@ public class ChannelMessageRouter {
// plan_step_* events, leaving the Web Console mirror with no // plan_step_* events, leaving the Web Console mirror with no
// PlanStepsPanel for IM-routed conversations. // PlanStepsPanel for IM-routed conversations.
// Token usage + model attribution: capture _usage_final event emitted at stream end // Token usage + model attribution: capture _usage_final event emitted at stream end
final int[] usage = {0, 0}; // [promptTokens, completionTokens] final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider] final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta -> { Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) { if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData(); Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue(); usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName"); Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId"); Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString(); if (model != null) modelInfo[0] = model.toString();
@ -989,7 +995,7 @@ public class ChannelMessageRouter {
String status = isError ? "error" : "completed"; String status = isError ? "error" : "completed";
MessageEntity saved = conversationService.saveMessage( MessageEntity saved = conversationService.saveMessage(
conversationId, "assistant", finalContent, null, status, conversationId, "assistant", finalContent, null, status,
usage[0], usage[1], modelInfo[0], modelInfo[1]); usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
if (!isError) { if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin); publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin);
} }

View File

@ -357,6 +357,9 @@ public class ChatController {
persistStatus, persistStatus,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); // includes toolCalls metadata accumulator.toMetadataJson()); // includes toolCalls metadata
@ -436,6 +439,9 @@ public class ChatController {
errStatus, errStatus,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -629,6 +635,9 @@ public class ChatController {
persistStatus, persistStatus,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -747,6 +756,9 @@ public class ChatController {
status, status,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -849,6 +861,9 @@ public class ChatController {
status, status,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -1375,6 +1390,9 @@ public class ChatController {
persistStatus, persistStatus,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -1427,6 +1445,9 @@ public class ChatController {
"failed", "failed",
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -1550,6 +1571,9 @@ public class ChatController {
emptyAssistantPlaceholder(status), null, status, emptyAssistantPlaceholder(status), null, status,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -1597,6 +1621,19 @@ public class ChatController {
} }
if (promptTokens > 0) payload.put("promptTokens", promptTokens); if (promptTokens > 0) payload.put("promptTokens", promptTokens);
if (completionTokens > 0) payload.put("completionTokens", completionTokens); if (completionTokens > 0) payload.put("completionTokens", completionTokens);
// Cache / reasoning detail rides on the persisted row so the live bubble
// can render the usage breakdown without waiting for a history reload.
if (savedAssistant != null) {
if (savedAssistant.getCacheReadTokens() != null && savedAssistant.getCacheReadTokens() > 0) {
payload.put("cacheReadTokens", savedAssistant.getCacheReadTokens());
}
if (savedAssistant.getCacheWriteTokens() != null && savedAssistant.getCacheWriteTokens() > 0) {
payload.put("cacheWriteTokens", savedAssistant.getCacheWriteTokens());
}
if (savedAssistant.getReasoningTokens() != null && savedAssistant.getReasoningTokens() > 0) {
payload.put("reasoningTokens", savedAssistant.getReasoningTokens());
}
}
payload.put("persisted", persisted); payload.put("persisted", persisted);
if (messageCount != null) payload.put("messageCount", messageCount); if (messageCount != null) payload.put("messageCount", messageCount);
return payload; return payload;
@ -1651,6 +1688,9 @@ public class ChatController {
status, status,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -1806,6 +1846,9 @@ public class ChatController {
private int segCounter = 0; private int segCounter = 0;
private int promptTokens = 0; private int promptTokens = 0;
private int completionTokens = 0; private int completionTokens = 0;
private int cacheReadTokens = 0;
private int cacheWriteTokens = 0;
private int reasoningTokens = 0;
private String runtimeModelName = ""; private String runtimeModelName = "";
private String runtimeProviderId = ""; private String runtimeProviderId = "";
private boolean awaitingApproval = false; private boolean awaitingApproval = false;
@ -1852,6 +1895,9 @@ public class ChatController {
Map<String, Object> data = delta.eventData(); Map<String, Object> data = delta.eventData();
promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
completionTokens = ((Number) data.getOrDefault("completionTokens", 0)).intValue(); completionTokens = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
cacheReadTokens = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
cacheWriteTokens = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
reasoningTokens = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
runtimeModelName = String.valueOf(data.getOrDefault("runtimeModelName", "")); runtimeModelName = String.valueOf(data.getOrDefault("runtimeModelName", ""));
runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", "")); runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", ""));
return; return;
@ -2140,6 +2186,9 @@ public class ChatController {
String getThinking() { return thinking.toString().trim(); } String getThinking() { return thinking.toString().trim(); }
int getPromptTokens() { return promptTokens; } int getPromptTokens() { return promptTokens; }
int getCompletionTokens() { return completionTokens; } int getCompletionTokens() { return completionTokens; }
int getCacheReadTokens() { return cacheReadTokens; }
int getCacheWriteTokens() { return cacheWriteTokens; }
int getReasoningTokens() { return reasoningTokens; }
String getRuntimeModelName() { return runtimeModelName; } String getRuntimeModelName() { return runtimeModelName; }
String getRuntimeProviderId() { return runtimeProviderId; } String getRuntimeProviderId() { return runtimeProviderId; }
String getCurrentPhase() { return currentPhase; } String getCurrentPhase() { return currentPhase; }

View File

@ -212,7 +212,7 @@ public class WebChatController {
// delta is not a persistence-only echo of content already streamed by inner nodes. // delta is not a persistence-only echo of content already streamed by inner nodes.
StringBuilder assistantReply = new StringBuilder(); StringBuilder assistantReply = new StringBuilder();
// Token usage + model attribution: capture _usage_final event emitted at stream end // Token usage + model attribution: capture _usage_final event emitted at stream end
final int[] usage = {0, 0}; // [promptTokens, completionTokens] final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider] final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
// Attribute memory to this external visitor so each end-user // Attribute memory to this external visitor so each end-user
@ -230,6 +230,9 @@ public class WebChatController {
Map<String, Object> data = delta.eventData(); Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue(); usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName"); Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId"); Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString(); if (model != null) modelInfo[0] = model.toString();
@ -265,7 +268,7 @@ public class WebChatController {
if (!reply.isBlank()) { if (!reply.isBlank()) {
conversationService.saveMessage( conversationService.saveMessage(
conversationId, "assistant", reply, List.of(), conversationId, "assistant", reply, List.of(),
"completed", usage[0], usage[1], modelInfo[0], modelInfo[1]); "completed", usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
} }
completionPublisher.publish( completionPublisher.publish(
resolvedAgentId, conversationId, message, reply, "webchat", webchatOwnerKey); resolvedAgentId, conversationId, message, reply, "webchat", webchatOwnerKey);
@ -1288,6 +1291,9 @@ public class WebChatController {
Map<String, Object> data = delta.eventData(); Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue(); usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName"); Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId"); Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString(); if (model != null) modelInfo[0] = model.toString();
@ -1315,7 +1321,7 @@ public class WebChatController {
if (!reply.isBlank()) { if (!reply.isBlank()) {
conversationService.saveMessage( conversationService.saveMessage(
conversationId, "assistant", reply, List.of(), conversationId, "assistant", reply, List.of(),
"completed", usage[0], usage[1], modelInfo[0], modelInfo[1]); "completed", usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
} }
} catch (Exception persistErr) { } catch (Exception persistErr) {
log.warn("[WebChat] approve replay persist failed: {}", persistErr.getMessage()); log.warn("[WebChat] approve replay persist failed: {}", persistErr.getMessage());

View File

@ -7,17 +7,26 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
/** /**
* Spring AI {@link Usage} 中提取 Anthropic prompt cache token 计数 * Spring AI {@link Usage} 中提取 provider prompt cache / reasoning token 计数
* *
* <p>spring-ai 的高层 {@code Usage} 接口只暴露 {@code promptTokens} / {@code completionTokens} * <p>spring-ai 的高层 {@code Usage} 接口只暴露 {@code promptTokens} / {@code completionTokens}
* 没有 cache 维度 {@link Usage#getNativeUsage()} 会返回 provider 的原生 usage 对象 * 没有 cache / reasoning 维度 {@link Usage#getNativeUsage()} 会返回 provider 的原生
* Anthropic 而言是 {@code AnthropicApi.Usage} record {@code cacheCreationInputTokens} * usage 对象 provider 的字段位置</p>
* {@code cacheReadInputTokens}</p> * <ul>
* <li><b>Anthropic</b>{@code AnthropicApi.Usage}顶层 {@code cacheReadInputTokens} /
* {@code cacheCreationInputTokens}注意其 {@code inputTokens} <b>不含</b>缓存部分
* 加法口径 reasoning 计数</li>
* <li><b>OpenAI 兼容</b>{@code OpenAiApi.Usage}嵌套 {@code promptTokensDetails.cachedTokens}
* {@code completionTokenDetails.reasoningTokens}{@code promptTokens} <b>已含</b>
* 缓存命中部分包含口径 cache 写入计数</li>
* <li><b>DashScope</b>{@code DashScopeApi.TokenUsage}嵌套
* {@code promptTokenDetailed.cachedTokens}包含口径无写入/reasoning 计数</li>
* </ul>
* *
* <p>采用反射调用以避免 * <p>采用反射调用以避免
* <ul> * <ul>
* <li> spring-ai 内部 record 形态的硬编码未来字段重命名风险小</li> * <li> spring-ai 内部 record 形态的硬编码未来字段重命名风险小</li>
* <li>对其它 providerOpenAI 兼容DashScope ClassCastException</li> * <li>对其它 provider 原生类型的编译期依赖与 ClassCastException</li>
* </ul> * </ul>
* 反射结果按类缓存热路径性能可接受</p> * 反射结果按类缓存热路径性能可接受</p>
* *
@ -25,11 +34,13 @@ import java.util.concurrent.ConcurrentMap;
*/ */
public final class CacheUsageExtractor { public final class CacheUsageExtractor {
/** {@code (cacheReadTokens, cacheWriteTokens)};任一字段不可得时为 0。 */ /** {@code (cacheReadTokens, cacheWriteTokens, reasoningTokens)};任一字段不可得时为 0。 */
public record CacheTokens(int cacheReadTokens, int cacheWriteTokens) { public record CacheTokens(int cacheReadTokens, int cacheWriteTokens, int reasoningTokens) {
public static final CacheTokens EMPTY = new CacheTokens(0, 0); public static final CacheTokens EMPTY = new CacheTokens(0, 0, 0);
public boolean isEmpty() { return cacheReadTokens == 0 && cacheWriteTokens == 0; } public boolean isEmpty() {
return cacheReadTokens == 0 && cacheWriteTokens == 0 && reasoningTokens == 0;
}
} }
/** 缓存 (Class, methodName) → reflected Method命中失败时为标记 NULL_METHOD。 */ /** 缓存 (Class, methodName) → reflected Method命中失败时为标记 NULL_METHOD。 */
@ -45,28 +56,58 @@ public final class CacheUsageExtractor {
private CacheUsageExtractor() {} private CacheUsageExtractor() {}
/** 从 spring-ai Usage 中尽力抽取 cache token不支持的 provider 返回 EMPTY。 */ /** 从 spring-ai Usage 中尽力抽取 cache / reasoning token不支持的 provider 返回 EMPTY。 */
public static CacheTokens extract(Usage usage) { public static CacheTokens extract(Usage usage) {
if (usage == null) return CacheTokens.EMPTY; if (usage == null) return CacheTokens.EMPTY;
Object native_ = usage.getNativeUsage(); Object native_ = usage.getNativeUsage();
if (native_ == null) return CacheTokens.EMPTY; if (native_ == null) return CacheTokens.EMPTY;
// Anthropic: top-level accessors on AnthropicApi.Usage
int read = invokeIntAccessor(native_, "cacheReadInputTokens"); int read = invokeIntAccessor(native_, "cacheReadInputTokens");
int write = invokeIntAccessor(native_, "cacheCreationInputTokens"); int write = invokeIntAccessor(native_, "cacheCreationInputTokens");
return (read == 0 && write == 0) ? CacheTokens.EMPTY : new CacheTokens(read, write);
// OpenAI-compatible: promptTokensDetails.cachedTokens
if (read == 0) {
read = invokeNestedIntAccessor(native_, "promptTokensDetails", "cachedTokens");
}
// DashScope: promptTokenDetailed.cachedTokens
if (read == 0) {
read = invokeNestedIntAccessor(native_, "promptTokenDetailed", "cachedTokens");
}
// OpenAI-compatible: completionTokenDetails.reasoningTokens
int reasoning = invokeNestedIntAccessor(native_, "completionTokenDetails", "reasoningTokens");
if (reasoning == 0) {
// Some OpenAI-compatible gateways pluralize the field name.
reasoning = invokeNestedIntAccessor(native_, "completionTokensDetails", "reasoningTokens");
}
return (read == 0 && write == 0 && reasoning == 0)
? CacheTokens.EMPTY
: new CacheTokens(read, write, reasoning);
}
/** 两级访问:先取嵌套 detail 对象,再取其 int 字段;任一级缺失返回 0。 */
private static int invokeNestedIntAccessor(Object target, String detailAccessor, String intAccessor) {
Object detail = invokeAccessor(target, detailAccessor);
if (detail == null) return 0;
return invokeIntAccessor(detail, intAccessor);
} }
private static int invokeIntAccessor(Object target, String accessor) { private static int invokeIntAccessor(Object target, String accessor) {
Object v = invokeAccessor(target, accessor);
return v instanceof Number n ? n.intValue() : 0;
}
private static Object invokeAccessor(Object target, String accessor) {
Class<?> cls = target.getClass(); Class<?> cls = target.getClass();
String key = cls.getName() + "#" + accessor; String key = cls.getName() + "#" + accessor;
Method m = METHOD_CACHE.computeIfAbsent(key, k -> resolveAccessor(cls, accessor)); Method m = METHOD_CACHE.computeIfAbsent(key, k -> resolveAccessor(cls, accessor));
if (m == NULL_METHOD) return 0; if (m == NULL_METHOD) return null;
try { try {
Object v = m.invoke(target); return m.invoke(target);
if (v instanceof Number n) return n.intValue();
return 0;
} catch (ReflectiveOperationException ignored) { } catch (ReflectiveOperationException ignored) {
return 0; return null;
} }
} }

View File

@ -609,6 +609,16 @@ public class ConversationService {
List<MessageContentPart> parts, String status, List<MessageContentPart> parts, String status,
int promptTokens, int completionTokens, int promptTokens, int completionTokens,
String runtimeModel, String runtimeProvider, String metadata) { String runtimeModel, String runtimeProvider, String metadata) {
return saveMessage(conversationId, role, content, parts, status,
promptTokens, completionTokens, 0, 0, 0, runtimeModel, runtimeProvider, metadata);
}
@Transactional
public MessageEntity saveMessage(String conversationId, String role, String content,
List<MessageContentPart> parts, String status,
int promptTokens, int completionTokens,
int cacheReadTokens, int cacheWriteTokens, int reasoningTokens,
String runtimeModel, String runtimeProvider, String metadata) {
MessageEntity message = new MessageEntity(); MessageEntity message = new MessageEntity();
message.setConversationId(conversationId); message.setConversationId(conversationId);
message.setRole(role); message.setRole(role);
@ -618,6 +628,9 @@ public class ConversationService {
message.setTokenUsage(promptTokens + completionTokens); message.setTokenUsage(promptTokens + completionTokens);
message.setPromptTokens(promptTokens); message.setPromptTokens(promptTokens);
message.setCompletionTokens(completionTokens); message.setCompletionTokens(completionTokens);
message.setCacheReadTokens(cacheReadTokens);
message.setCacheWriteTokens(cacheWriteTokens);
message.setReasoningTokens(reasoningTokens);
message.setRuntimeModel(runtimeModel); message.setRuntimeModel(runtimeModel);
message.setRuntimeProvider(runtimeProvider); message.setRuntimeProvider(runtimeProvider);
message.setMetadata(metadata != null ? metadata : "{}"); // Initialize as empty JSON object / 初始化为空对象 message.setMetadata(metadata != null ? metadata : "{}"); // Initialize as empty JSON object / 初始化为空对象

View File

@ -72,6 +72,9 @@ public class TokenUsageService {
wrapper.select( wrapper.select(
MessageEntity::getPromptTokens, MessageEntity::getPromptTokens,
MessageEntity::getCompletionTokens, MessageEntity::getCompletionTokens,
MessageEntity::getCacheReadTokens,
MessageEntity::getCacheWriteTokens,
MessageEntity::getReasoningTokens,
MessageEntity::getRuntimeModel, MessageEntity::getRuntimeModel,
MessageEntity::getRuntimeProvider, MessageEntity::getRuntimeProvider,
MessageEntity::getCreateTime MessageEntity::getCreateTime
@ -87,6 +90,9 @@ public class TokenUsageService {
long totalPrompt = 0; long totalPrompt = 0;
long totalCompletion = 0; long totalCompletion = 0;
long totalCacheRead = 0;
long totalCacheWrite = 0;
long totalReasoning = 0;
// 按模型聚合 // 按模型聚合
Map<String, long[]> modelMap = new LinkedHashMap<>(); Map<String, long[]> modelMap = new LinkedHashMap<>();
@ -100,6 +106,9 @@ public class TokenUsageService {
int completion = msg.getCompletionTokens() != null ? msg.getCompletionTokens() : 0; int completion = msg.getCompletionTokens() != null ? msg.getCompletionTokens() : 0;
totalPrompt += prompt; totalPrompt += prompt;
totalCompletion += completion; totalCompletion += completion;
totalCacheRead += msg.getCacheReadTokens() != null ? msg.getCacheReadTokens() : 0;
totalCacheWrite += msg.getCacheWriteTokens() != null ? msg.getCacheWriteTokens() : 0;
totalReasoning += msg.getReasoningTokens() != null ? msg.getReasoningTokens() : 0;
// 模型维度 // 模型维度
String model = msg.getRuntimeModel() != null ? msg.getRuntimeModel() : "unknown"; String model = msg.getRuntimeModel() != null ? msg.getRuntimeModel() : "unknown";
@ -124,6 +133,9 @@ public class TokenUsageService {
vo.setTotalPromptTokens(totalPrompt); vo.setTotalPromptTokens(totalPrompt);
vo.setTotalCompletionTokens(totalCompletion); vo.setTotalCompletionTokens(totalCompletion);
vo.setTotalCacheReadTokens(totalCacheRead);
vo.setTotalCacheWriteTokens(totalCacheWrite);
vo.setTotalReasoningTokens(totalReasoning);
vo.setTotalMessages(messages.size()); vo.setTotalMessages(messages.size());
// 转换 byModel // 转换 byModel

View File

@ -43,6 +43,15 @@ public class MessageEntity {
/** Completion tokens 消耗 */ /** Completion tokens 消耗 */
private Integer completionTokens; private Integer completionTokens;
/** Prompt cache 命中 tokensprovider 未上报时为 0 */
private Integer cacheReadTokens;
/** Prompt cache 写入 tokensprovider 未上报时为 0 */
private Integer cacheWriteTokens;
/** 思考reasoning阶段消耗的 completion tokensprovider 未上报时为 0 */
private Integer reasoningTokens;
/** 运行时模型名称 */ /** 运行时模型名称 */
private String runtimeModel; private String runtimeModel;

View File

@ -41,6 +41,15 @@ public class MessageVO {
/** Completion tokens 消耗 */ /** Completion tokens 消耗 */
private Integer completionTokens; private Integer completionTokens;
/** Prompt cache 命中 tokensprovider 未上报时为 0 */
private Integer cacheReadTokens;
/** Prompt cache 写入 tokensprovider 未上报时为 0 */
private Integer cacheWriteTokens;
/** 思考reasoning阶段消耗的 completion tokensprovider 未上报时为 0 */
private Integer reasoningTokens;
/** Model name actually used to produce this message (e.g. "deepseek-chat"). */ /** Model name actually used to produce this message (e.g. "deepseek-chat"). */
private String runtimeModel; private String runtimeModel;
@ -65,6 +74,9 @@ public class MessageVO {
vo.setMetadata(parseMetadataToObject(entity.getMetadata())); vo.setMetadata(parseMetadataToObject(entity.getMetadata()));
vo.setPromptTokens(entity.getPromptTokens()); vo.setPromptTokens(entity.getPromptTokens());
vo.setCompletionTokens(entity.getCompletionTokens()); vo.setCompletionTokens(entity.getCompletionTokens());
vo.setCacheReadTokens(entity.getCacheReadTokens());
vo.setCacheWriteTokens(entity.getCacheWriteTokens());
vo.setReasoningTokens(entity.getReasoningTokens());
vo.setRuntimeModel(entity.getRuntimeModel()); vo.setRuntimeModel(entity.getRuntimeModel());
vo.setRuntimeProvider(entity.getRuntimeProvider()); vo.setRuntimeProvider(entity.getRuntimeProvider());
vo.setCreateTime(entity.getCreateTime()); vo.setCreateTime(entity.getCreateTime());

View File

@ -18,6 +18,15 @@ public class TokenUsageSummaryVO {
/** 总 completion tokens */ /** 总 completion tokens */
private long totalCompletionTokens; private long totalCompletionTokens;
/** 总 prompt cache 命中 tokens */
private long totalCacheReadTokens;
/** 总 prompt cache 写入 tokens */
private long totalCacheWriteTokens;
/** 总思考reasoningtokens */
private long totalReasoningTokens;
/** 总 assistant 消息数 */ /** 总 assistant 消息数 */
private long totalMessages; private long totalMessages;

View File

@ -0,0 +1,6 @@
-- V166: Per-message token usage detail for the chat consumption breakdown panel.
-- Adds prompt-cache hit/write and reasoning token counters to mate_message so the
-- UI can show input cache hit/miss/write and thinking-vs-reply output splits.
ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS cache_read_tokens INT DEFAULT 0;
ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS cache_write_tokens INT DEFAULT 0;
ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS reasoning_tokens INT DEFAULT 0;

View File

@ -0,0 +1,6 @@
-- V166: Per-message token usage detail for the chat consumption breakdown panel.
-- Adds prompt-cache hit/write and reasoning token counters to mate_message so the
-- UI can show input cache hit/miss/write and thinking-vs-reply output splits.
ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS cache_read_tokens INT DEFAULT 0;
ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS cache_write_tokens INT DEFAULT 0;
ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS reasoning_tokens INT DEFAULT 0;

View File

@ -0,0 +1,15 @@
-- V166: Per-message token usage detail for the chat consumption breakdown panel.
-- Adds prompt-cache hit/write and reasoning token counters to mate_message so the
-- UI can show input cache hit/miss/write and thinking-vs-reply output splits.
-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guard instead.
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_message' AND COLUMN_NAME = 'cache_read_tokens');
SET @s := IF(@c = 0, 'ALTER TABLE mate_message ADD COLUMN cache_read_tokens INT DEFAULT 0', 'SELECT 1');
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_message' AND COLUMN_NAME = 'cache_write_tokens');
SET @s := IF(@c = 0, 'ALTER TABLE mate_message ADD COLUMN cache_write_tokens INT DEFAULT 0', 'SELECT 1');
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_message' AND COLUMN_NAME = 'reasoning_tokens');
SET @s := IF(@c = 0, 'ALTER TABLE mate_message ADD COLUMN reasoning_tokens INT DEFAULT 0', 'SELECT 1');
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,82 @@
package vip.mate.llm.cache;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.metadata.Usage;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies reflective extraction of cache / reasoning token counters from the
* provider-native usage shapes (Anthropic top-level accessors, OpenAI-compatible
* and DashScope nested detail records).
*/
class CacheUsageExtractorTest {
/** Minimal Usage stub whose native payload drives the extraction. */
private record StubUsage(Object nativeUsage) implements Usage {
@Override public Integer getPromptTokens() { return 0; }
@Override public Integer getCompletionTokens() { return 0; }
@Override public Object getNativeUsage() { return nativeUsage; }
}
/** Anthropic-style native usage: top-level cache accessors. */
private record AnthropicStyleUsage(Integer inputTokens, Integer outputTokens,
Integer cacheCreationInputTokens,
Integer cacheReadInputTokens) {}
/** OpenAI-style native usage: nested prompt/completion detail records. */
private record OpenAiPromptDetails(Integer audioTokens, Integer cachedTokens) {}
private record OpenAiCompletionDetails(Integer reasoningTokens, Integer audioTokens) {}
private record OpenAiStyleUsage(Integer promptTokens, Integer completionTokens,
OpenAiPromptDetails promptTokensDetails,
OpenAiCompletionDetails completionTokenDetails) {}
/** DashScope-style native usage: promptTokenDetailed.cachedTokens. */
private record DashScopePromptDetailed(Integer cachedTokens) {}
private record DashScopeStyleUsage(Integer inputTokens, Integer outputTokens,
DashScopePromptDetailed promptTokenDetailed) {}
@Test
void anthropicTopLevelCacheFields() {
var usage = new StubUsage(new AnthropicStyleUsage(100, 50, 2000, 66000));
var tokens = CacheUsageExtractor.extract(usage);
assertEquals(66000, tokens.cacheReadTokens());
assertEquals(2000, tokens.cacheWriteTokens());
assertEquals(0, tokens.reasoningTokens());
}
@Test
void openAiNestedCachedAndReasoningTokens() {
var usage = new StubUsage(new OpenAiStyleUsage(5000, 800,
new OpenAiPromptDetails(0, 4200),
new OpenAiCompletionDetails(300, 0)));
var tokens = CacheUsageExtractor.extract(usage);
assertEquals(4200, tokens.cacheReadTokens());
assertEquals(0, tokens.cacheWriteTokens());
assertEquals(300, tokens.reasoningTokens());
}
@Test
void dashScopeNestedCachedTokens() {
var usage = new StubUsage(new DashScopeStyleUsage(9000, 400,
new DashScopePromptDetailed(7500)));
var tokens = CacheUsageExtractor.extract(usage);
assertEquals(7500, tokens.cacheReadTokens());
assertEquals(0, tokens.cacheWriteTokens());
assertEquals(0, tokens.reasoningTokens());
}
@Test
void unknownProviderYieldsEmpty() {
var tokens = CacheUsageExtractor.extract(new StubUsage(new Object()));
assertTrue(tokens.isEmpty());
}
@Test
void nullDetailRecordsYieldZeroNotError() {
var usage = new StubUsage(new OpenAiStyleUsage(5000, 800, null, null));
var tokens = CacheUsageExtractor.extract(usage);
assertTrue(tokens.isEmpty());
}
}

View File

@ -408,23 +408,100 @@
class="action-model" class="action-model"
:title="replyModelTitle" :title="replyModelTitle"
>{{ replyModel }}</span> >{{ replyModel }}</span>
<!-- Turn token total (assistant only): own usage + delegated sub-agents --> <!-- Turn token total (assistant only): own usage + delegated sub-agents.
<span Click opens the per-turn consumption breakdown panel (cache hit/miss/write,
reasoning vs reply, cache hit rate). -->
<el-popover
v-if="role === 'assistant' && tokenUsage" v-if="role === 'assistant' && tokenUsage"
class="action-tokens" placement="top-end"
:title="tokenUsage.delegated > 0 trigger="click"
? $t('chat.tokenUsageTooltipDelegated', { :width="288"
total: tokenUsage.total.toLocaleString(), popper-class="mc-usage-popover"
input: tokenUsage.input.toLocaleString(), >
output: tokenUsage.output.toLocaleString(), <template #reference>
delegated: tokenUsage.delegated.toLocaleString(), <button
}) class="action-tokens usage-trigger"
: $t('chat.tokenUsageTooltip', { type="button"
total: tokenUsage.total.toLocaleString(), :title="tokenUsage.delegated > 0
input: tokenUsage.input.toLocaleString(), ? $t('chat.tokenUsageTooltipDelegated', {
output: tokenUsage.output.toLocaleString(), total: tokenUsage.total.toLocaleString(),
})" input: tokenUsage.input.toLocaleString(),
>Σ {{ fmtTokens(tokenUsage.total) }} tok</span> output: tokenUsage.output.toLocaleString(),
delegated: tokenUsage.delegated.toLocaleString(),
})
: $t('chat.tokenUsageTooltip', {
total: tokenUsage.total.toLocaleString(),
input: tokenUsage.input.toLocaleString(),
output: tokenUsage.output.toLocaleString(),
})"
>Σ {{ fmtTokens(tokenUsage.total) }} tok</button>
</template>
<div class="mc-usage-panel">
<div class="mc-usage-header">
<span class="mc-usage-title">{{ $t('chat.usageDetail.title') }}</span>
<span class="mc-usage-total-label">{{ $t('chat.usageDetail.total') }}</span>
<span class="mc-usage-total">{{ tokenUsage.total.toLocaleString() }}</span>
</div>
<div class="mc-usage-row mc-usage-row-main">
<i class="mc-usage-dot mc-dot-input"></i>
<span class="mc-usage-label">{{ $t('chat.usageDetail.input') }}</span>
<span class="mc-usage-value">{{ tokenUsage.input.toLocaleString() }}</span>
</div>
<template v-if="tokenUsage.hasCacheData">
<div class="mc-usage-row mc-usage-row-sub">
<i class="mc-usage-dot mc-dot-hit"></i>
<span class="mc-usage-label">{{ $t('chat.usageDetail.cacheHit') }}</span>
<span class="mc-usage-value">{{ tokenUsage.cacheRead.toLocaleString() }}</span>
</div>
<div class="mc-usage-row mc-usage-row-sub">
<i class="mc-usage-dot mc-dot-miss"></i>
<span class="mc-usage-label">{{ $t('chat.usageDetail.cacheMiss') }}</span>
<span class="mc-usage-value">{{ tokenUsage.cacheMiss.toLocaleString() }}</span>
</div>
<div class="mc-usage-row mc-usage-row-sub">
<i class="mc-usage-dot mc-dot-write"></i>
<span class="mc-usage-label">{{ $t('chat.usageDetail.cacheWrite') }}</span>
<span class="mc-usage-value">{{ tokenUsage.cacheWrite.toLocaleString() }}</span>
</div>
</template>
<div class="mc-usage-divider"></div>
<div class="mc-usage-row mc-usage-row-main">
<i class="mc-usage-dot mc-dot-output"></i>
<span class="mc-usage-label">{{ $t('chat.usageDetail.output') }}</span>
<span class="mc-usage-value">{{ tokenUsage.output.toLocaleString() }}</span>
</div>
<div class="mc-usage-row mc-usage-row-sub">
<span class="mc-usage-label mc-usage-label-indent">{{ $t('chat.usageDetail.reasoning') }}</span>
<span class="mc-usage-value">{{ tokenUsage.reasoning.toLocaleString() }}</span>
</div>
<div class="mc-usage-row mc-usage-row-sub">
<span class="mc-usage-label mc-usage-label-indent">{{ $t('chat.usageDetail.reply') }}</span>
<span class="mc-usage-value">{{ tokenUsage.reply.toLocaleString() }}</span>
</div>
<div v-if="tokenUsage.delegated > 0" class="mc-usage-row mc-usage-row-sub">
<span class="mc-usage-label mc-usage-label-indent">{{ $t('chat.usageDetail.delegated') }}</span>
<span class="mc-usage-value">{{ tokenUsage.delegated.toLocaleString() }}</span>
</div>
<template v-if="tokenUsage.hasCacheData">
<div class="mc-usage-divider"></div>
<div class="mc-usage-row mc-usage-row-main">
<span class="mc-usage-hit-icon"></span>
<span class="mc-usage-label">{{ $t('chat.usageDetail.hitRate') }}</span>
<span class="mc-usage-value mc-usage-hit-rate">{{ (tokenUsage.hitRate * 100).toFixed(1) }}%</span>
</div>
<div class="mc-usage-bar">
<i class="mc-bar-hit" :style="{ width: usageBarPct(tokenUsage.cacheRead) }"></i>
<i class="mc-bar-write" :style="{ width: usageBarPct(tokenUsage.cacheWrite) }"></i>
<i class="mc-bar-miss" :style="{ width: usageBarPct(tokenUsage.cacheMiss) }"></i>
</div>
<div class="mc-usage-legend">
<span><i class="mc-usage-dot mc-dot-hit"></i>{{ $t('chat.usageDetail.legendHit') }}</span>
<span><i class="mc-usage-dot mc-dot-write"></i>{{ $t('chat.usageDetail.legendWrite') }}</span>
<span><i class="mc-usage-dot mc-dot-miss"></i>{{ $t('chat.usageDetail.legendMiss') }}</span>
</div>
</template>
</div>
</el-popover>
<!-- Multimodal sidecar routing badge (assistant only, when sidecar fired) --> <!-- Multimodal sidecar routing badge (assistant only, when sidecar fired) -->
<span <span
v-if="role === 'assistant' && routingBadge" v-if="role === 'assistant' && routingBadge"
@ -1027,13 +1104,25 @@ const useSegmentedView = computed(() =>
const tokenUsage = computed(() => { const tokenUsage = computed(() => {
const m = props.message const m = props.message
if (m.role !== 'assistant') return null if (m.role !== 'assistant') return null
// Total comes solely from the message usage, which the backend already rolls // Base usage comes from the message, which the backend already rolls
// delegated sub-agent tokens into (so live and reloaded values match and there // delegated sub-agent tokens into (so live and reloaded values match and there
// is no double counting against the segment sum below). // is no double counting against the segment sum below).
const input = m.promptTokens || 0 const prompt = m.promptTokens || 0
const output = m.completionTokens || 0 const output = m.completionTokens || 0
if (prompt + output <= 0) return null
const cacheRead = m.cacheReadTokens || 0
const cacheWrite = m.cacheWriteTokens || 0
const reasoning = m.reasoningTokens || 0
// Provider accounting differs: the native Anthropic API reports input_tokens
// EXCLUDING the cache read/write segments (additive), while OpenAI-compatible
// and DashScope responses report prompt_tokens INCLUDING cached hits.
const additive = (m.runtimeProvider || '').toLowerCase().includes('anthropic')
const input = additive ? prompt + cacheRead + cacheWrite : prompt
const cacheMiss = Math.max(0, input - cacheRead - cacheWrite)
const reply = Math.max(0, output - reasoning)
const hitRate = input > 0 ? cacheRead / input : 0
const hasCacheData = cacheRead > 0 || cacheWrite > 0
const total = input + output const total = input + output
if (total <= 0) return null
// Informational breakdown for the tooltip: how much of that total came from // Informational breakdown for the tooltip: how much of that total came from
// delegated sub-agents. Derived from the delegation segments, so it is present // delegated sub-agents. Derived from the delegation segments, so it is present
// live and degrades to 0 after reload (the segments are not persisted). // live and degrades to 0 after reload (the segments are not persisted).
@ -1050,9 +1139,19 @@ const tokenUsage = computed(() => {
delegated += (s.delegPromptTokens || 0) + (s.delegCompletionTokens || 0) delegated += (s.delegPromptTokens || 0) + (s.delegCompletionTokens || 0)
addNodes(s.childTimeline?.children) addNodes(s.childTimeline?.children)
} }
return { input, output, total, delegated: Math.min(delegated, total) } return {
input, output, total, delegated: Math.min(delegated, total),
cacheRead, cacheWrite, cacheMiss, reasoning, reply, hitRate, hasCacheData,
}
}) })
/** Width of a cache-bar segment as a percentage of total input tokens. */
function usageBarPct(part: number): string {
const u = tokenUsage.value
if (!u || u.input <= 0) return '0%'
return (part / u.input * 100).toFixed(2) + '%'
}
/** Compact token count, e.g. 67890 → "67.9k". */ /** Compact token count, e.g. 67890 → "67.9k". */
function fmtTokens(n: number): string { function fmtTokens(n: number): string {
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n) return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)
@ -1841,6 +1940,17 @@ watch(isGenerating, (generating) => {
white-space: nowrap; white-space: nowrap;
} }
/* The token chip is a popover trigger button — keep the chip look, add affordance. */
.usage-trigger {
border: none;
cursor: pointer;
line-height: inherit;
}
.usage-trigger:hover {
color: var(--mc-text-secondary, #64748b);
background: var(--mc-fill-3, rgba(100, 116, 139, 0.14));
}
.action-routing { .action-routing {
font-size: 11px; font-size: 11px;
color: var(--mc-primary, #d96d46); color: var(--mc-primary, #d96d46);
@ -2593,3 +2703,108 @@ watch(isGenerating, (generating) => {
} }
} }
</style> </style>
<!-- Unscoped: the usage popover renders into <body> via Element Plus popper,
so scoped selectors can't reach it. Classes are mc-usage-* prefixed. -->
<style>
.mc-usage-popover.el-popover {
padding: 12px 14px;
}
.mc-usage-panel {
font-size: 12px;
color: var(--mc-text-secondary, #64748b);
}
.mc-usage-header {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 8px;
}
.mc-usage-title {
font-weight: 600;
font-size: 13px;
color: var(--mc-text-primary, #1e293b);
flex: 1;
}
.mc-usage-total-label {
color: var(--mc-text-tertiary, #94a3b8);
}
.mc-usage-total {
font-weight: 700;
font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace);
color: var(--mc-text-primary, #1e293b);
}
.mc-usage-row {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
}
.mc-usage-row-main .mc-usage-label {
color: var(--mc-text-primary, #1e293b);
font-weight: 500;
}
.mc-usage-row-sub {
padding-left: 14px;
}
.mc-usage-label {
flex: 1;
}
.mc-usage-label-indent {
padding-left: 14px;
}
.mc-usage-value {
font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace);
color: var(--mc-text-primary, #1e293b);
}
.mc-usage-divider {
border-top: 1px dashed var(--mc-border-2, #e2e8f0);
margin: 6px 0;
}
.mc-usage-dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex: none;
display: inline-block;
}
.mc-dot-input { background: #3b82f6; }
.mc-dot-output { background: #a855f7; }
.mc-dot-hit { background: #10b981; }
.mc-dot-miss { background: #f43f5e; }
.mc-dot-write { background: #eab308; }
.mc-usage-hit-icon {
flex: none;
font-size: 11px;
}
.mc-usage-hit-rate {
color: #10b981;
font-weight: 700;
}
.mc-usage-bar {
display: flex;
height: 6px;
border-radius: 3px;
overflow: hidden;
background: var(--mc-fill-2, rgba(100, 116, 139, 0.08));
margin: 6px 0 6px;
}
.mc-usage-bar i {
display: block;
height: 100%;
}
.mc-bar-hit { background: #10b981; }
.mc-bar-write { background: #eab308; }
.mc-bar-miss { background: #f43f5e; }
.mc-usage-legend {
display: flex;
gap: 12px;
font-size: 11px;
color: var(--mc-text-tertiary, #94a3b8);
}
.mc-usage-legend span {
display: inline-flex;
align-items: center;
gap: 4px;
}
</style>

View File

@ -640,6 +640,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const msg = messages.value[msgIndex] const msg = messages.value[msgIndex]
if (data.promptTokens !== undefined) msg.promptTokens = data.promptTokens if (data.promptTokens !== undefined) msg.promptTokens = data.promptTokens
if (data.completionTokens !== undefined) msg.completionTokens = data.completionTokens if (data.completionTokens !== undefined) msg.completionTokens = data.completionTokens
if (data.cacheReadTokens !== undefined) msg.cacheReadTokens = data.cacheReadTokens
if (data.cacheWriteTokens !== undefined) msg.cacheWriteTokens = data.cacheWriteTokens
if (data.reasoningTokens !== undefined) msg.reasoningTokens = data.reasoningTokens
if (data.runtimeModel) msg.runtimeModel = data.runtimeModel if (data.runtimeModel) msg.runtimeModel = data.runtimeModel
if (data.runtimeProvider) msg.runtimeProvider = data.runtimeProvider if (data.runtimeProvider) msg.runtimeProvider = data.runtimeProvider
// Replace the local temp ID with the backend-persisted ID so reconcile can match by ID // Replace the local temp ID with the backend-persisted ID so reconcile can match by ID

View File

@ -198,6 +198,22 @@ export default {
replyModel: 'Reply model: {model}', replyModel: 'Reply model: {model}',
tokenUsageTooltip: 'This turn used {total} tokens ({input} in · {output} out)', tokenUsageTooltip: 'This turn used {total} tokens ({input} in · {output} out)',
tokenUsageTooltipDelegated: 'This turn used {total} tokens ({input} in · {output} out), of which {delegated} came from delegated sub-agents', tokenUsageTooltipDelegated: 'This turn used {total} tokens ({input} in · {output} out), of which {delegated} came from delegated sub-agents',
usageDetail: {
title: 'Token usage detail',
total: 'Total',
input: 'Input',
cacheHit: 'Cache hit',
cacheMiss: 'Cache miss',
cacheWrite: 'Cache write',
output: 'Output',
reasoning: 'Reasoning',
reply: 'Reply',
delegated: 'Delegated sub-agents',
hitRate: 'Cache hit rate',
legendHit: 'Hit',
legendWrite: 'Write',
legendMiss: 'Miss',
},
routing: { routing: {
kind: { kind: {
image: 'image', image: 'image',
@ -1789,6 +1805,9 @@ export default {
endDate: 'End', endDate: 'End',
promptTokens: 'Prompt Tokens', promptTokens: 'Prompt Tokens',
completionTokens: 'Completion Tokens', completionTokens: 'Completion Tokens',
cacheReadTokens: 'Cache Read Tokens',
cacheWriteTokens: 'Cache Write Tokens',
reasoningTokens: 'Reasoning Tokens',
assistantMessages: 'Assistant Messages', assistantMessages: 'Assistant Messages',
byModel: 'By Model', byModel: 'By Model',
byDate: 'By Date', byDate: 'By Date',

View File

@ -198,6 +198,22 @@ export default {
replyModel: '本条回复模型: {model}', replyModel: '本条回复模型: {model}',
tokenUsageTooltip: '本轮共消耗 {total} tokens输入 {input} · 输出 {output}', tokenUsageTooltip: '本轮共消耗 {total} tokens输入 {input} · 输出 {output}',
tokenUsageTooltipDelegated: '本轮共消耗 {total} tokens输入 {input} · 输出 {output}),其中子 Agent 委派占 {delegated}', tokenUsageTooltipDelegated: '本轮共消耗 {total} tokens输入 {input} · 输出 {output}),其中子 Agent 委派占 {delegated}',
usageDetail: {
title: 'Token 消耗明细',
total: '总计',
input: '输入',
cacheHit: '缓存命中',
cacheMiss: '缓存未命中',
cacheWrite: '缓存写入',
output: '输出',
reasoning: '思考过程',
reply: '回复内容',
delegated: '子 Agent 委派',
hitRate: '缓存命中率',
legendHit: '命中',
legendWrite: '写入',
legendMiss: '未命中',
},
routing: { routing: {
kind: { kind: {
image: '图片', image: '图片',
@ -1663,6 +1679,9 @@ export default {
endDate: '结束日期', endDate: '结束日期',
promptTokens: 'Prompt Tokens', promptTokens: 'Prompt Tokens',
completionTokens: 'Completion Tokens', completionTokens: 'Completion Tokens',
cacheReadTokens: '缓存命中 Tokens',
cacheWriteTokens: '缓存写入 Tokens',
reasoningTokens: '思考 Tokens',
assistantMessages: 'Assistant Messages', assistantMessages: 'Assistant Messages',
byModel: '按模型统计', byModel: '按模型统计',
byDate: '按日期统计', byDate: '按日期统计',

View File

@ -111,6 +111,9 @@ export interface Message {
// Token 统计 // Token 统计
promptTokens?: number promptTokens?: number
completionTokens?: number completionTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
reasoningTokens?: number
// Runtime model attribution (assistant messages): the model that actually produced this reply // Runtime model attribution (assistant messages): the model that actually produced this reply
runtimeModel?: string runtimeModel?: string
runtimeProvider?: string runtimeProvider?: string

View File

@ -20,6 +20,9 @@ export interface DateUsageItem {
export interface TokenUsageSummary { export interface TokenUsageSummary {
totalPromptTokens: number totalPromptTokens: number
totalCompletionTokens: number totalCompletionTokens: number
totalCacheReadTokens: number
totalCacheWriteTokens: number
totalReasoningTokens: number
totalMessages: number totalMessages: number
byModel: ModelUsageItem[] byModel: ModelUsageItem[]
byDate: DateUsageItem[] byDate: DateUsageItem[]

View File

@ -2073,6 +2073,9 @@ function normalizeMessage(raw: Message, preserveGeneratingStatus?: boolean): Mes
// token MessageVO // token MessageVO
if ((raw as any).promptTokens) msg.promptTokens = (raw as any).promptTokens if ((raw as any).promptTokens) msg.promptTokens = (raw as any).promptTokens
if ((raw as any).completionTokens) msg.completionTokens = (raw as any).completionTokens if ((raw as any).completionTokens) msg.completionTokens = (raw as any).completionTokens
if ((raw as any).cacheReadTokens) msg.cacheReadTokens = (raw as any).cacheReadTokens
if ((raw as any).cacheWriteTokens) msg.cacheWriteTokens = (raw as any).cacheWriteTokens
if ((raw as any).reasoningTokens) msg.reasoningTokens = (raw as any).reasoningTokens
if ((raw as any).runtimeModel) msg.runtimeModel = (raw as any).runtimeModel if ((raw as any).runtimeModel) msg.runtimeModel = (raw as any).runtimeModel
if ((raw as any).runtimeProvider) msg.runtimeProvider = (raw as any).runtimeProvider if ((raw as any).runtimeProvider) msg.runtimeProvider = (raw as any).runtimeProvider

View File

@ -57,6 +57,18 @@
<div class="card-kicker">{{ t('tokenUsage.assistantMessages') }}</div> <div class="card-kicker">{{ t('tokenUsage.assistantMessages') }}</div>
<div class="card-value">{{ formatNumber(data.totalMessages) }}</div> <div class="card-value">{{ formatNumber(data.totalMessages) }}</div>
</div> </div>
<div v-if="(data.totalCacheReadTokens || 0) + (data.totalCacheWriteTokens || 0) > 0" class="summary-card">
<div class="card-kicker">{{ t('tokenUsage.cacheReadTokens') }}</div>
<div class="card-value">{{ formatNumber(data.totalCacheReadTokens) }}</div>
</div>
<div v-if="(data.totalCacheReadTokens || 0) + (data.totalCacheWriteTokens || 0) > 0" class="summary-card">
<div class="card-kicker">{{ t('tokenUsage.cacheWriteTokens') }}</div>
<div class="card-value">{{ formatNumber(data.totalCacheWriteTokens) }}</div>
</div>
<div v-if="(data.totalReasoningTokens || 0) > 0" class="summary-card">
<div class="card-kicker">{{ t('tokenUsage.reasoningTokens') }}</div>
<div class="card-value">{{ formatNumber(data.totalReasoningTokens) }}</div>
</div>
</div> </div>
<div v-if="data.totalMessages === 0" class="empty-state"> <div v-if="data.totalMessages === 0" class="empty-state">