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.title') }} + {{ $t('chat.usageDetail.total') }} + {{ tokenUsage.total.toLocaleString() }} +
+
+ + {{ $t('chat.usageDetail.input') }} + {{ tokenUsage.input.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() }} +
+ +
+ 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) }}
+