diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
index de300d00..e13bc4b9 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
@@ -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 抽取出的
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java
index ed178f8a..0b67ab23 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java
@@ -954,9 +954,10 @@ public class NodeStreamingChatHelper {
AtomicReference 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 toolCallAccumulators,
int promptTok, int completionTok,
- int cacheReadTok, int cacheWriteTok, String phase) {
+ int cacheReadTok, int cacheWriteTok,
+ int reasoningTok, String phase) {
List 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 toolCallAccumulators,
int promptTok, int completionTok,
- int cacheReadTok, int cacheWriteTok,
+ int cacheReadTok, int cacheWriteTok, int reasoningTok,
String phase, boolean partial, String errorMsg) {
List 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 命中 tokens(provider 未上报时为 0) */
int cacheReadTokens,
- /** RFC-014: Anthropic prompt cache 写入字节数(其它 provider 为 0) */
- int cacheWriteTokens
+ /** Prompt cache 写入 tokens(provider 未上报时为 0) */
+ int cacheWriteTokens,
+ /** 思考(reasoning)阶段消耗的 completion tokens(provider 未上报时为 0) */
+ int reasoningTokens
) {
/** 兼容旧调用方 — 无 partial/error/stopped 的正常结果 */
public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
List 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 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);
}
/** 是否有不可忽略的错误(无内容 + 有错误) */
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java
index c74edc28..369dff8a 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java
@@ -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 finalModelName = new AtomicReference<>("");
AtomicReference 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 finalModelName = new AtomicReference<>("");
AtomicReference finalProviderId = new AtomicReference<>("");
// 防重保护:StateGraph 对每个节点都 emit NodeOutput,FINAL_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));
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java
index b2af00a5..06af1b05 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java
@@ -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 finalModelName = new AtomicReference<>("");
AtomicReference 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));
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java
index 4c33ff59..d2895c26 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java
@@ -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();
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java
index 98d3e385..f041871e 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java
@@ -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 build() {
return map;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java
index 23a8b6f8..5529f440 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java
@@ -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;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java
index 56b66517..2bddc46c 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java
@@ -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";
+ /** 思考(reasoning)tokens 累计(provider 未上报时保持 0) */
+ public static final String REASONING_TOKENS = "reasoning_tokens";
// ===== 运行时模型快照(REPLACE 策略,buildInitialState 注入)=====
public static final String RUNTIME_MODEL_NAME = "runtime_model_name";
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
index 23b8812d..d0790db7 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
@@ -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 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 mirroredStream = stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map 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);
}
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java
index 2212cd98..1492785d 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java
@@ -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 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; }
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java
index 869379c4..f4afe6af 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java
@@ -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 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 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());
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheUsageExtractor.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheUsageExtractor.java
index dc94eea2..3e1bb195 100644
--- a/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheUsageExtractor.java
+++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheUsageExtractor.java
@@ -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 计数。
*
* spring-ai 的高层 {@code Usage} 接口只暴露 {@code promptTokens} / {@code completionTokens},
- * 没有 cache 维度;但 {@link Usage#getNativeUsage()} 会返回 provider 的原生 usage 对象。
- * 对 Anthropic 而言是 {@code AnthropicApi.Usage} record,含 {@code cacheCreationInputTokens}
- * 与 {@code cacheReadInputTokens}。
+ * 没有 cache / reasoning 维度;但 {@link Usage#getNativeUsage()} 会返回 provider 的原生
+ * usage 对象。各 provider 的字段位置:
+ *
+ * - Anthropic({@code AnthropicApi.Usage}):顶层 {@code cacheReadInputTokens} /
+ * {@code cacheCreationInputTokens}。注意其 {@code inputTokens} 不含缓存部分
+ * (加法口径)。无 reasoning 计数。
+ * - OpenAI 兼容({@code OpenAiApi.Usage}):嵌套 {@code promptTokensDetails.cachedTokens}
+ * 与 {@code completionTokenDetails.reasoningTokens};{@code promptTokens} 已含
+ * 缓存命中部分(包含口径)。无 cache 写入计数。
+ * - DashScope({@code DashScopeApi.TokenUsage}):嵌套
+ * {@code promptTokenDetailed.cachedTokens};包含口径,无写入/reasoning 计数。
+ *
*
* 采用反射调用以避免:
*
* - 对 spring-ai 内部 record 形态的硬编码(未来字段重命名风险小)
- * - 对其它 provider(OpenAI 兼容、DashScope)的 ClassCastException
+ * - 对其它 provider 原生类型的编译期依赖与 ClassCastException
*
* 反射结果按类缓存,热路径性能可接受。
*
@@ -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;
}
}
diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java
index a795439e..3ad92676 100644
--- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java
+++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java
@@ -609,6 +609,16 @@ public class ConversationService {
List 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 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 / 初始化为空对象
diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/TokenUsageService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/TokenUsageService.java
index 20a0e6f9..a15effa4 100644
--- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/TokenUsageService.java
+++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/TokenUsageService.java
@@ -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 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
diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageEntity.java
index 8de74692..36a93b37 100644
--- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageEntity.java
@@ -43,6 +43,15 @@ public class MessageEntity {
/** Completion tokens 消耗 */
private Integer completionTokens;
+ /** Prompt cache 命中 tokens(provider 未上报时为 0) */
+ private Integer cacheReadTokens;
+
+ /** Prompt cache 写入 tokens(provider 未上报时为 0) */
+ private Integer cacheWriteTokens;
+
+ /** 思考(reasoning)阶段消耗的 completion tokens(provider 未上报时为 0) */
+ private Integer reasoningTokens;
+
/** 运行时模型名称 */
private String runtimeModel;
diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java
index 111ce8b2..2ebbe80e 100644
--- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java
+++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java
@@ -41,6 +41,15 @@ public class MessageVO {
/** Completion tokens 消耗 */
private Integer completionTokens;
+ /** Prompt cache 命中 tokens(provider 未上报时为 0) */
+ private Integer cacheReadTokens;
+
+ /** Prompt cache 写入 tokens(provider 未上报时为 0) */
+ private Integer cacheWriteTokens;
+
+ /** 思考(reasoning)阶段消耗的 completion tokens(provider 未上报时为 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());
diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/TokenUsageSummaryVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/TokenUsageSummaryVO.java
index e6b24145..9d0b1e57 100644
--- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/TokenUsageSummaryVO.java
+++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/TokenUsageSummaryVO.java
@@ -18,6 +18,15 @@ public class TokenUsageSummaryVO {
/** 总 completion tokens */
private long totalCompletionTokens;
+ /** 总 prompt cache 命中 tokens */
+ private long totalCacheReadTokens;
+
+ /** 总 prompt cache 写入 tokens */
+ private long totalCacheWriteTokens;
+
+ /** 总思考(reasoning)tokens */
+ private long totalReasoningTokens;
+
/** 总 assistant 消息数 */
private long totalMessages;
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V166__message_usage_detail.sql b/mateclaw-server/src/main/resources/db/migration/h2/V166__message_usage_detail.sql
new file mode 100644
index 00000000..d44de6c4
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V166__message_usage_detail.sql
@@ -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;
diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V166__message_usage_detail.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V166__message_usage_detail.sql
new file mode 100644
index 00000000..d44de6c4
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V166__message_usage_detail.sql
@@ -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;
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V166__message_usage_detail.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V166__message_usage_detail.sql
new file mode 100644
index 00000000..10810192
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V166__message_usage_detail.sql
@@ -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;
diff --git a/mateclaw-server/src/test/java/vip/mate/llm/cache/CacheUsageExtractorTest.java b/mateclaw-server/src/test/java/vip/mate/llm/cache/CacheUsageExtractorTest.java
new file mode 100644
index 00000000..c8646346
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/llm/cache/CacheUsageExtractorTest.java
@@ -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());
+ }
+}
diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue
index 679d3377..852a1cc7 100644
--- a/mateclaw-ui/src/components/chat/MessageBubble.vue
+++ b/mateclaw-ui/src/components/chat/MessageBubble.vue
@@ -408,23 +408,100 @@
class="action-model"
:title="replyModelTitle"
>{{ replyModel }}
-
-
+ Σ {{ fmtTokens(tokenUsage.total) }} tok
+ placement="top-end"
+ trigger="click"
+ :width="288"
+ popper-class="mc-usage-popover"
+ >
+
+
+
+
+
+
+
+ {{ $t('chat.usageDetail.input') }}
+ {{ tokenUsage.input.toLocaleString() }}
+
+
+
+
+ {{ $t('chat.usageDetail.cacheHit') }}
+ {{ tokenUsage.cacheRead.toLocaleString() }}
+
+
+
+ {{ $t('chat.usageDetail.cacheMiss') }}
+ {{ tokenUsage.cacheMiss.toLocaleString() }}
+
+
+
+ {{ $t('chat.usageDetail.cacheWrite') }}
+ {{ tokenUsage.cacheWrite.toLocaleString() }}
+
+
+
+
+
+ {{ $t('chat.usageDetail.output') }}
+ {{ tokenUsage.output.toLocaleString() }}
+
+
+ {{ $t('chat.usageDetail.reasoning') }}
+ {{ tokenUsage.reasoning.toLocaleString() }}
+
+
+ {{ $t('chat.usageDetail.reply') }}
+ {{ tokenUsage.reply.toLocaleString() }}
+
+
+ {{ $t('chat.usageDetail.delegated') }}
+ {{ tokenUsage.delegated.toLocaleString() }}
+
+
+
+
+ ⚡
+ {{ $t('chat.usageDetail.hitRate') }}
+ {{ (tokenUsage.hitRate * 100).toFixed(1) }}%
+
+
+
+
+
+
+
+ {{ $t('chat.usageDetail.legendHit') }}
+ {{ $t('chat.usageDetail.legendWrite') }}
+ {{ $t('chat.usageDetail.legendMiss') }}
+
+
+
+
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) => {
}
}
+
+
+
diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts
index 0afec9dc..f1611ab1 100644
--- a/mateclaw-ui/src/composables/chat/useChat.ts
+++ b/mateclaw-ui/src/composables/chat/useChat.ts
@@ -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
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index 7d4a6bf2..1c3dafad 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -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',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index a83f1985..64e0abdd 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -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: '按日期统计',
diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts
index 9af1d633..af1d37d6 100644
--- a/mateclaw-ui/src/types/index.ts
+++ b/mateclaw-ui/src/types/index.ts
@@ -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
diff --git a/mateclaw-ui/src/types/tokenUsage.ts b/mateclaw-ui/src/types/tokenUsage.ts
index 27302dff..b7f67fa3 100644
--- a/mateclaw-ui/src/types/tokenUsage.ts
+++ b/mateclaw-ui/src/types/tokenUsage.ts
@@ -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[]
diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue
index 6d7bf9b6..c8716882 100644
--- a/mateclaw-ui/src/views/ChatConsole.vue
+++ b/mateclaw-ui/src/views/ChatConsole.vue
@@ -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
diff --git a/mateclaw-ui/src/views/TokenUsage.vue b/mateclaw-ui/src/views/TokenUsage.vue
index 699576f2..7bc747bc 100644
--- a/mateclaw-ui/src/views/TokenUsage.vue
+++ b/mateclaw-ui/src/views/TokenUsage.vue
@@ -57,6 +57,18 @@
{{ t('tokenUsage.assistantMessages') }}
{{ formatNumber(data.totalMessages) }}
+
+
{{ t('tokenUsage.cacheReadTokens') }}
+
{{ formatNumber(data.totalCacheReadTokens) }}
+
+
+
{{ t('tokenUsage.cacheWriteTokens') }}
+
{{ formatNumber(data.totalCacheWriteTokens) }}
+
+
+
{{ t('tokenUsage.reasoningTokens') }}
+
{{ formatNumber(data.totalReasoningTokens) }}
+