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
.addStrategy(MateClawStateKeys.PROMPT_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.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
@ -936,6 +939,9 @@ public class AgentGraphBuilder {
// Token Usage
.addStrategy(MateClawStateKeys.PROMPT_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_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的

View File

@ -954,9 +954,10 @@ public class NodeStreamingChatHelper {
AtomicReference<Throwable> errorRef = new AtomicReference<>();
AtomicInteger promptTokens = 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 cacheWriteTokens = new AtomicInteger(0);
AtomicInteger reasoningTokens = new AtomicInteger(0);
// thinking-only soft cap 触发后设为 true外层轮询线程据此 dispose 订阅
// 注意内容流的字符级 / 句子级重复检测已整体移除设计取舍
@ -1142,10 +1143,12 @@ public class NodeStreamingChatHelper {
if (usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) {
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);
if (cache.cacheReadTokens() > 0) cacheReadTokens.set(cache.cacheReadTokens());
if (cache.cacheWriteTokens() > 0) cacheWriteTokens.set(cache.cacheWriteTokens());
if (cache.reasoningTokens() > 0) reasoningTokens.set(cache.reasoningTokens());
}
})
.subscribe(
@ -1197,7 +1200,8 @@ public class NodeStreamingChatHelper {
toolCallAccumulators.size(), conversationId);
return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators,
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={}",
phase, conversationId);
@ -1231,6 +1235,7 @@ public class NodeStreamingChatHelper {
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(),
reasoningTokens.get(),
phase, true, error.getMessage());
}
@ -1319,7 +1324,8 @@ public class NodeStreamingChatHelper {
: null;
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(), phase,
cacheReadTokens.get(), cacheWriteTokens.get(),
reasoningTokens.get(), phase,
truncated,
truncationReason);
}
@ -1328,7 +1334,8 @@ public class NodeStreamingChatHelper {
private StreamResult assembleStoppedResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
List<ToolCallAccumulator> toolCallAccumulators,
int promptTok, int completionTok,
int cacheReadTok, int cacheWriteTok, String phase) {
int cacheReadTok, int cacheWriteTok,
int reasoningTok, String phase) {
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString();
String fullThinking = thinkingAccum.toString();
@ -1351,14 +1358,14 @@ public class NodeStreamingChatHelper {
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok);
true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok, reasoningTok);
}
/** 组装最终 StreamResult成功或 partial */
private StreamResult assembleResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
List<ToolCallAccumulator> toolCallAccumulators,
int promptTok, int completionTok,
int cacheReadTok, int cacheWriteTok,
int cacheReadTok, int cacheWriteTok, int reasoningTok,
String phase, boolean partial, String errorMsg) {
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString();
@ -1384,7 +1391,7 @@ public class NodeStreamingChatHelper {
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage,
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,
/** 用户主动停止stopRequested导致的提前返回 */
boolean stopped,
/** RFC-014: Anthropic prompt cache 命中字节数(其它 provider 为 0 */
/** Prompt cache 命中 tokensprovider 未上报时为 0 */
int cacheReadTokens,
/** RFC-014: Anthropic prompt cache 写入字节数(其它 provider 为 0 */
int cacheWriteTokens
/** Prompt cache 写入 tokensprovider 未上报时为 0 */
int cacheWriteTokens,
/** 思考reasoning阶段消耗的 completion tokensprovider 未上报时为 0 */
int reasoningTokens
) {
/** 兼容旧调用方 — 无 partial/error/stopped 的正常结果 */
public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
int promptTokens, int completionTokens) {
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 调用点 */
@ -1841,17 +1850,17 @@ public class NodeStreamingChatHelper {
int promptTokens, int completionTokens,
boolean partial, String errorMessage, ErrorType errorType) {
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,
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
int promptTokens, int completionTokens,
boolean partial, String errorMessage, ErrorType errorType,
boolean stopped) {
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 finalPromptTokens = 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> finalProviderId = new AtomicReference<>("");
// 防重保护 chatStructuredStream
@ -269,6 +272,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
finalPromptTokens.set(output.state().value(PROMPT_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, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
@ -295,6 +301,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
"completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"cacheReadTokens", finalCacheReadTokens.get(),
"cacheWriteTokens", finalCacheWriteTokens.get(),
"reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
@ -348,6 +357,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// Token usage 追踪每次 NodeOutput 更新最新累计值最后一次即最终值
AtomicInteger finalPromptTokens = 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> finalProviderId = new AtomicReference<>("");
// 防重保护StateGraph 对每个节点都 emit NodeOutputFINAL_ANSWER 一旦写入后续节点都携带
@ -433,6 +445,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(PROMPT_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, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
@ -461,6 +476,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
"completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"cacheReadTokens", finalCacheReadTokens.get(),
"cacheWriteTokens", finalCacheWriteTokens.get(),
"reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
@ -551,6 +569,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(FORCED_TOOL_CALL, "");
inputs.put(PROMPT_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_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
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 finalPromptTokens = 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> finalProviderId = new AtomicReference<>("");
// 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
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_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, ""));
finalProviderId.set(output.state().value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""));
@ -229,6 +235,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
"completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"cacheReadTokens", finalCacheReadTokens.get(),
"cacheWriteTokens", finalCacheWriteTokens.get(),
"reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
@ -320,6 +329,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
inputs.put(MateClawStateKeys.REQUESTER_ID, "");
inputs.put(MateClawStateKeys.PROMPT_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_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
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;
int stepPromptTokens = 0;
int stepCompletionTokens = 0;
int stepCacheReadTokens = 0;
int stepCacheWriteTokens = 0;
int stepReasoningTokens = 0;
// RFC-052: any returnDirect tool that fires inside this step must
// short-circuit the entire plan (not just this step). We accumulate
@ -322,6 +325,9 @@ public class StepExecutionNode implements NodeAction {
stepPromptTokens += result.promptTokens();
stepCompletionTokens += result.completionTokens();
stepCacheReadTokens += result.cacheReadTokens();
stepCacheWriteTokens += result.cacheWriteTokens();
stepReasoningTokens += result.reasoningTokens();
if (!result.thinking().isEmpty()) {
stepThinking = result.thinking();
@ -444,8 +450,8 @@ public class StepExecutionNode implements NodeAction {
.currentPhase("awaiting_approval")
.contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty())
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@ -480,10 +486,8 @@ public class StepExecutionNode implements NodeAction {
.contentStreamed(false) // StateGraphPlanExecuteAgent finalSummary 推送
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
.put(MateClawStateKeys.PROMPT_TOKENS,
state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS,
state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@ -532,8 +536,8 @@ public class StepExecutionNode implements NodeAction {
.currentStepTitle("")
.currentStepResult("")
.contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@ -590,8 +594,8 @@ public class StepExecutionNode implements NodeAction {
.currentStepTitle("")
.currentStepResult("")
.contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@ -602,8 +606,8 @@ public class StepExecutionNode implements NodeAction {
.currentStepResult(shortError)
.currentPhase("plan_aborted")
.contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@ -646,8 +650,8 @@ public class StepExecutionNode implements NodeAction {
.currentPhase("step_completed")
.contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty())
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}

View File

@ -258,10 +258,37 @@ public final class PlanStateAccessor {
int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0);
map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens());
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);
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() {
return map;
}

View File

@ -537,6 +537,12 @@ public final class MateClawStateAccessor {
int existingCompletion = currentState.value(COMPLETION_TOKENS, 0);
map.put(PROMPT_TOKENS, existingPrompt + result.promptTokens());
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;
}

View File

@ -155,6 +155,12 @@ public final class MateClawStateKeys {
// ===== Token Usage 累计REPLACE 策略节点内累加后写回=====
public static final String PROMPT_TOKENS = "prompt_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 注入=====
public static final String RUNTIME_MODEL_NAME = "runtime_model_name";

View File

@ -790,7 +790,7 @@ public class ChannelMessageRouter {
StringBuilder replyAccumulator = new StringBuilder();
final String channelType = adapter.getChannelType();
// 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]
agentService.chatStructuredStream(agentId, promptText, conversationId,
message.getSenderId(), chatOrigin)
@ -800,6 +800,9 @@ public class ChannelMessageRouter {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 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 provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
@ -840,7 +843,7 @@ public class ChannelMessageRouter {
String status = isError ? "error" : "completed";
MessageEntity saved = conversationService.saveMessage(
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;
if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin);
@ -955,13 +958,16 @@ public class ChannelMessageRouter {
// plan_step_* events, leaving the Web Console mirror with no
// PlanStepsPanel for IM-routed conversations.
// 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]
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 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 provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
@ -989,7 +995,7 @@ public class ChannelMessageRouter {
String status = isError ? "error" : "completed";
MessageEntity saved = conversationService.saveMessage(
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) {
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin);
}

View File

@ -357,6 +357,9 @@ public class ChatController {
persistStatus,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); // includes toolCalls metadata
@ -436,6 +439,9 @@ public class ChatController {
errStatus,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -629,6 +635,9 @@ public class ChatController {
persistStatus,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -747,6 +756,9 @@ public class ChatController {
status,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -849,6 +861,9 @@ public class ChatController {
status,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -1375,6 +1390,9 @@ public class ChatController {
persistStatus,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -1427,6 +1445,9 @@ public class ChatController {
"failed",
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -1550,6 +1571,9 @@ public class ChatController {
emptyAssistantPlaceholder(status), null, status,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -1597,6 +1621,19 @@ public class ChatController {
}
if (promptTokens > 0) payload.put("promptTokens", promptTokens);
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);
if (messageCount != null) payload.put("messageCount", messageCount);
return payload;
@ -1651,6 +1688,9 @@ public class ChatController {
status,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -1806,6 +1846,9 @@ public class ChatController {
private int segCounter = 0;
private int promptTokens = 0;
private int completionTokens = 0;
private int cacheReadTokens = 0;
private int cacheWriteTokens = 0;
private int reasoningTokens = 0;
private String runtimeModelName = "";
private String runtimeProviderId = "";
private boolean awaitingApproval = false;
@ -1852,6 +1895,9 @@ public class ChatController {
Map<String, Object> data = delta.eventData();
promptTokens = ((Number) data.getOrDefault("promptTokens", 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", ""));
runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", ""));
return;
@ -2140,6 +2186,9 @@ public class ChatController {
String getThinking() { return thinking.toString().trim(); }
int getPromptTokens() { return promptTokens; }
int getCompletionTokens() { return completionTokens; }
int getCacheReadTokens() { return cacheReadTokens; }
int getCacheWriteTokens() { return cacheWriteTokens; }
int getReasoningTokens() { return reasoningTokens; }
String getRuntimeModelName() { return runtimeModelName; }
String getRuntimeProviderId() { return runtimeProviderId; }
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.
StringBuilder assistantReply = new StringBuilder();
// 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]
// Attribute memory to this external visitor so each end-user
@ -230,6 +230,9 @@ public class WebChatController {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 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 provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
@ -265,7 +268,7 @@ public class WebChatController {
if (!reply.isBlank()) {
conversationService.saveMessage(
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(
resolvedAgentId, conversationId, message, reply, "webchat", webchatOwnerKey);
@ -1288,6 +1291,9 @@ public class WebChatController {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 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 provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
@ -1315,7 +1321,7 @@ public class WebChatController {
if (!reply.isBlank()) {
conversationService.saveMessage(
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) {
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;
/**
* 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}
* 没有 cache 维度 {@link Usage#getNativeUsage()} 会返回 provider 的原生 usage 对象
* Anthropic 而言是 {@code AnthropicApi.Usage} record {@code cacheCreationInputTokens}
* {@code cacheReadInputTokens}</p>
* 没有 cache / reasoning 维度 {@link Usage#getNativeUsage()} 会返回 provider 的原生
* usage 对象 provider 的字段位置</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>采用反射调用以避免
* <ul>
* <li> spring-ai 内部 record 形态的硬编码未来字段重命名风险小</li>
* <li>对其它 providerOpenAI 兼容DashScope ClassCastException</li>
* <li>对其它 provider 原生类型的编译期依赖与 ClassCastException</li>
* </ul>
* 反射结果按类缓存热路径性能可接受</p>
*
@ -25,11 +34,13 @@ import java.util.concurrent.ConcurrentMap;
*/
public final class CacheUsageExtractor {
/** {@code (cacheReadTokens, cacheWriteTokens)};任一字段不可得时为 0。 */
public record CacheTokens(int cacheReadTokens, int cacheWriteTokens) {
public static final CacheTokens EMPTY = new CacheTokens(0, 0);
/** {@code (cacheReadTokens, cacheWriteTokens, reasoningTokens)};任一字段不可得时为 0。 */
public record CacheTokens(int cacheReadTokens, int cacheWriteTokens, int reasoningTokens) {
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。 */
@ -45,28 +56,58 @@ public final class CacheUsageExtractor {
private CacheUsageExtractor() {}
/** 从 spring-ai Usage 中尽力抽取 cache token不支持的 provider 返回 EMPTY。 */
/** 从 spring-ai Usage 中尽力抽取 cache / reasoning token不支持的 provider 返回 EMPTY。 */
public static CacheTokens extract(Usage usage) {
if (usage == null) return CacheTokens.EMPTY;
Object native_ = usage.getNativeUsage();
if (native_ == null) return CacheTokens.EMPTY;
// Anthropic: top-level accessors on AnthropicApi.Usage
int read = invokeIntAccessor(native_, "cacheReadInputTokens");
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) {
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();
String key = cls.getName() + "#" + accessor;
Method m = METHOD_CACHE.computeIfAbsent(key, k -> resolveAccessor(cls, accessor));
if (m == NULL_METHOD) return 0;
if (m == NULL_METHOD) return null;
try {
Object v = m.invoke(target);
if (v instanceof Number n) return n.intValue();
return 0;
return m.invoke(target);
} catch (ReflectiveOperationException ignored) {
return 0;
return null;
}
}

View File

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

View File

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

View File

@ -43,6 +43,15 @@ public class MessageEntity {
/** Completion tokens 消耗 */
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;

View File

@ -41,6 +41,15 @@ public class MessageVO {
/** Completion tokens 消耗 */
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"). */
private String runtimeModel;
@ -65,6 +74,9 @@ public class MessageVO {
vo.setMetadata(parseMetadataToObject(entity.getMetadata()));
vo.setPromptTokens(entity.getPromptTokens());
vo.setCompletionTokens(entity.getCompletionTokens());
vo.setCacheReadTokens(entity.getCacheReadTokens());
vo.setCacheWriteTokens(entity.getCacheWriteTokens());
vo.setReasoningTokens(entity.getReasoningTokens());
vo.setRuntimeModel(entity.getRuntimeModel());
vo.setRuntimeProvider(entity.getRuntimeProvider());
vo.setCreateTime(entity.getCreateTime());

View File

@ -18,6 +18,15 @@ public class TokenUsageSummaryVO {
/** 总 completion tokens */
private long totalCompletionTokens;
/** 总 prompt cache 命中 tokens */
private long totalCacheReadTokens;
/** 总 prompt cache 写入 tokens */
private long totalCacheWriteTokens;
/** 总思考reasoningtokens */
private long totalReasoningTokens;
/** 总 assistant 消息数 */
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"
:title="replyModelTitle"
>{{ replyModel }}</span>
<!-- Turn token total (assistant only): own usage + delegated sub-agents -->
<span
<!-- Turn token total (assistant only): own usage + delegated sub-agents.
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"
class="action-tokens"
:title="tokenUsage.delegated > 0
? $t('chat.tokenUsageTooltipDelegated', {
total: tokenUsage.total.toLocaleString(),
input: tokenUsage.input.toLocaleString(),
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</span>
placement="top-end"
trigger="click"
:width="288"
popper-class="mc-usage-popover"
>
<template #reference>
<button
class="action-tokens usage-trigger"
type="button"
:title="tokenUsage.delegated > 0
? $t('chat.tokenUsageTooltipDelegated', {
total: tokenUsage.total.toLocaleString(),
input: tokenUsage.input.toLocaleString(),
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) -->
<span
v-if="role === 'assistant' && routingBadge"
@ -1027,13 +1104,25 @@ const useSegmentedView = computed(() =>
const tokenUsage = computed(() => {
const m = props.message
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
// is no double counting against the segment sum below).
const input = m.promptTokens || 0
const prompt = m.promptTokens || 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
if (total <= 0) return null
// Informational breakdown for the tooltip: how much of that total came from
// delegated sub-agents. Derived from the delegation segments, so it is present
// 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)
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". */
function fmtTokens(n: number): string {
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)
@ -1841,6 +1940,17 @@ watch(isGenerating, (generating) => {
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 {
font-size: 11px;
color: var(--mc-primary, #d96d46);
@ -2593,3 +2703,108 @@ watch(isGenerating, (generating) => {
}
}
</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]
if (data.promptTokens !== undefined) msg.promptTokens = data.promptTokens
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.runtimeProvider) msg.runtimeProvider = data.runtimeProvider
// 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}',
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',
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: {
kind: {
image: 'image',
@ -1789,6 +1805,9 @@ export default {
endDate: 'End',
promptTokens: 'Prompt Tokens',
completionTokens: 'Completion Tokens',
cacheReadTokens: 'Cache Read Tokens',
cacheWriteTokens: 'Cache Write Tokens',
reasoningTokens: 'Reasoning Tokens',
assistantMessages: 'Assistant Messages',
byModel: 'By Model',
byDate: 'By Date',

View File

@ -198,6 +198,22 @@ export default {
replyModel: '本条回复模型: {model}',
tokenUsageTooltip: '本轮共消耗 {total} tokens输入 {input} · 输出 {output}',
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: {
kind: {
image: '图片',
@ -1663,6 +1679,9 @@ export default {
endDate: '结束日期',
promptTokens: 'Prompt Tokens',
completionTokens: 'Completion Tokens',
cacheReadTokens: '缓存命中 Tokens',
cacheWriteTokens: '缓存写入 Tokens',
reasoningTokens: '思考 Tokens',
assistantMessages: 'Assistant Messages',
byModel: '按模型统计',
byDate: '按日期统计',

View File

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

View File

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

View File

@ -2073,6 +2073,9 @@ function normalizeMessage(raw: Message, preserveGeneratingStatus?: boolean): Mes
// token MessageVO
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).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).runtimeProvider) msg.runtimeProvider = (raw as any).runtimeProvider

View File

@ -57,6 +57,18 @@
<div class="card-kicker">{{ t('tokenUsage.assistantMessages') }}</div>
<div class="card-value">{{ formatNumber(data.totalMessages) }}</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 v-if="data.totalMessages === 0" class="empty-state">