mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(chat): per-turn token usage breakdown with cache hit/miss/write and reasoning split (#474)
This commit is contained in:
parent
6741920446
commit
25737495e5
@ -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 抽取出的
|
||||
|
||||
@ -954,9 +954,10 @@ public class NodeStreamingChatHelper {
|
||||
AtomicReference<Throwable> errorRef = new AtomicReference<>();
|
||||
AtomicInteger promptTokens = new AtomicInteger(0);
|
||||
AtomicInteger completionTokens = new AtomicInteger(0);
|
||||
// RFC-014: Anthropic prompt cache 计数(其它 provider 永远为 0)
|
||||
// Prompt cache / reasoning counters; providers that don't report them stay 0.
|
||||
AtomicInteger cacheReadTokens = new AtomicInteger(0);
|
||||
AtomicInteger cacheWriteTokens = new AtomicInteger(0);
|
||||
AtomicInteger reasoningTokens = new AtomicInteger(0);
|
||||
|
||||
// thinking-only soft cap 触发后设为 true,外层轮询线程据此 dispose 订阅。
|
||||
// 注意:内容流的字符级 / 句子级重复检测已整体移除(设计取舍:
|
||||
@ -1142,10 +1143,12 @@ public class NodeStreamingChatHelper {
|
||||
if (usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) {
|
||||
completionTokens.set(usage.getCompletionTokens().intValue());
|
||||
}
|
||||
// RFC-014: 反射抽取 Anthropic prompt cache 字段(DashScope/OpenAI 自然返回 0)
|
||||
// Reflective extraction of provider-native cache / reasoning
|
||||
// counters (Anthropic / OpenAI-compatible / DashScope).
|
||||
var cache = vip.mate.llm.cache.CacheUsageExtractor.extract(usage);
|
||||
if (cache.cacheReadTokens() > 0) cacheReadTokens.set(cache.cacheReadTokens());
|
||||
if (cache.cacheWriteTokens() > 0) cacheWriteTokens.set(cache.cacheWriteTokens());
|
||||
if (cache.reasoningTokens() > 0) reasoningTokens.set(cache.reasoningTokens());
|
||||
}
|
||||
})
|
||||
.subscribe(
|
||||
@ -1197,7 +1200,8 @@ public class NodeStreamingChatHelper {
|
||||
toolCallAccumulators.size(), conversationId);
|
||||
return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||
promptTokens.get(), completionTokens.get(),
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(), phase);
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(),
|
||||
reasoningTokens.get(), phase);
|
||||
}
|
||||
log.info("[{}] Stop requested during LLM call, no content accumulated, aborting: conversationId={}",
|
||||
phase, conversationId);
|
||||
@ -1231,6 +1235,7 @@ public class NodeStreamingChatHelper {
|
||||
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||
promptTokens.get(), completionTokens.get(),
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(),
|
||||
reasoningTokens.get(),
|
||||
phase, true, error.getMessage());
|
||||
}
|
||||
|
||||
@ -1319,7 +1324,8 @@ public class NodeStreamingChatHelper {
|
||||
: null;
|
||||
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||
promptTokens.get(), completionTokens.get(),
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(), phase,
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(),
|
||||
reasoningTokens.get(), phase,
|
||||
truncated,
|
||||
truncationReason);
|
||||
}
|
||||
@ -1328,7 +1334,8 @@ public class NodeStreamingChatHelper {
|
||||
private StreamResult assembleStoppedResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
|
||||
List<ToolCallAccumulator> toolCallAccumulators,
|
||||
int promptTok, int completionTok,
|
||||
int cacheReadTok, int cacheWriteTok, String phase) {
|
||||
int cacheReadTok, int cacheWriteTok,
|
||||
int reasoningTok, String phase) {
|
||||
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
|
||||
String fullContent = contentAccum.toString();
|
||||
String fullThinking = thinkingAccum.toString();
|
||||
@ -1351,14 +1358,14 @@ public class NodeStreamingChatHelper {
|
||||
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
|
||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
||||
true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok);
|
||||
true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok, reasoningTok);
|
||||
}
|
||||
|
||||
/** 组装最终 StreamResult(成功或 partial) */
|
||||
private StreamResult assembleResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
|
||||
List<ToolCallAccumulator> toolCallAccumulators,
|
||||
int promptTok, int completionTok,
|
||||
int cacheReadTok, int cacheWriteTok,
|
||||
int cacheReadTok, int cacheWriteTok, int reasoningTok,
|
||||
String phase, boolean partial, String errorMsg) {
|
||||
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
|
||||
String fullContent = contentAccum.toString();
|
||||
@ -1384,7 +1391,7 @@ public class NodeStreamingChatHelper {
|
||||
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
|
||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
||||
partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok);
|
||||
partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok, reasoningTok);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1822,17 +1829,19 @@ public class NodeStreamingChatHelper {
|
||||
ErrorType errorType,
|
||||
/** 用户主动停止(stopRequested)导致的提前返回 */
|
||||
boolean stopped,
|
||||
/** RFC-014: Anthropic prompt cache 命中字节数(其它 provider 为 0) */
|
||||
/** Prompt cache 命中 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<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
|
||||
int promptTokens, int completionTokens) {
|
||||
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
|
||||
promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0);
|
||||
promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** 兼容 10-arg 调用点 */
|
||||
@ -1841,17 +1850,17 @@ public class NodeStreamingChatHelper {
|
||||
int promptTokens, int completionTokens,
|
||||
boolean partial, String errorMessage, ErrorType errorType) {
|
||||
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
|
||||
promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0);
|
||||
promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** 兼容 12-arg 调用点(pre-RFC-014) */
|
||||
/** 兼容 11-arg 调用点(无 cache/reasoning 计数) */
|
||||
public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
|
||||
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
|
||||
int promptTokens, int completionTokens,
|
||||
boolean partial, String errorMessage, ErrorType errorType,
|
||||
boolean stopped) {
|
||||
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
|
||||
promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0);
|
||||
promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** 是否有不可忽略的错误(无内容 + 有错误) */
|
||||
|
||||
@ -198,6 +198,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
AtomicInteger sentEventCount = new AtomicInteger(0);
|
||||
AtomicInteger finalPromptTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalReasoningTokens = new AtomicInteger(0);
|
||||
AtomicReference<String> finalModelName = new AtomicReference<>("");
|
||||
AtomicReference<String> finalProviderId = new AtomicReference<>("");
|
||||
// 防重保护:同 chatStructuredStream
|
||||
@ -269,6 +272,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
|
||||
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
|
||||
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
|
||||
finalCacheReadTokens.set(output.state().value(CACHE_READ_TOKENS, 0));
|
||||
finalCacheWriteTokens.set(output.state().value(CACHE_WRITE_TOKENS, 0));
|
||||
finalReasoningTokens.set(output.state().value(REASONING_TOKENS, 0));
|
||||
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
|
||||
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
|
||||
|
||||
@ -295,6 +301,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
"completionTokens", completionTokens,
|
||||
"delegatedPromptTokens", delegated.promptTokens(),
|
||||
"delegatedCompletionTokens", delegated.completionTokens(),
|
||||
"cacheReadTokens", finalCacheReadTokens.get(),
|
||||
"cacheWriteTokens", finalCacheWriteTokens.get(),
|
||||
"reasoningTokens", finalReasoningTokens.get(),
|
||||
"runtimeModelName", finalModelName.get(),
|
||||
"runtimeProviderId", finalProviderId.get()
|
||||
));
|
||||
@ -348,6 +357,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
// Token usage 追踪(每次 NodeOutput 更新最新累计值,最后一次即最终值)
|
||||
AtomicInteger finalPromptTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalReasoningTokens = new AtomicInteger(0);
|
||||
AtomicReference<String> finalModelName = new AtomicReference<>("");
|
||||
AtomicReference<String> finalProviderId = new AtomicReference<>("");
|
||||
// 防重保护:StateGraph 对每个节点都 emit 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));
|
||||
|
||||
@ -143,6 +143,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
AtomicInteger sentEventCount = new AtomicInteger(0);
|
||||
AtomicInteger finalPromptTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalReasoningTokens = new AtomicInteger(0);
|
||||
AtomicReference<String> finalModelName = new AtomicReference<>("");
|
||||
AtomicReference<String> finalProviderId = new AtomicReference<>("");
|
||||
// Root conversation for this turn — used to roll delegated sub-agent
|
||||
@ -208,6 +211,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
// 3. 更新最新累计 token usage
|
||||
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
|
||||
finalCompletionTokens.set(output.state().value(MateClawStateKeys.COMPLETION_TOKENS, 0));
|
||||
finalCacheReadTokens.set(output.state().value(MateClawStateKeys.CACHE_READ_TOKENS, 0));
|
||||
finalCacheWriteTokens.set(output.state().value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0));
|
||||
finalReasoningTokens.set(output.state().value(MateClawStateKeys.REASONING_TOKENS, 0));
|
||||
finalModelName.set(output.state().value(MateClawStateKeys.RUNTIME_MODEL_NAME, ""));
|
||||
finalProviderId.set(output.state().value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""));
|
||||
|
||||
@ -229,6 +235,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
"completionTokens", completionTokens,
|
||||
"delegatedPromptTokens", delegated.promptTokens(),
|
||||
"delegatedCompletionTokens", delegated.completionTokens(),
|
||||
"cacheReadTokens", finalCacheReadTokens.get(),
|
||||
"cacheWriteTokens", finalCacheWriteTokens.get(),
|
||||
"reasoningTokens", finalReasoningTokens.get(),
|
||||
"runtimeModelName", finalModelName.get(),
|
||||
"runtimeProviderId", finalProviderId.get()
|
||||
));
|
||||
@ -320,6 +329,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
inputs.put(MateClawStateKeys.REQUESTER_ID, "");
|
||||
inputs.put(MateClawStateKeys.PROMPT_TOKENS, 0);
|
||||
inputs.put(MateClawStateKeys.COMPLETION_TOKENS, 0);
|
||||
inputs.put(MateClawStateKeys.CACHE_READ_TOKENS, 0);
|
||||
inputs.put(MateClawStateKeys.CACHE_WRITE_TOKENS, 0);
|
||||
inputs.put(MateClawStateKeys.REASONING_TOKENS, 0);
|
||||
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
||||
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
||||
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -258,10 +258,37 @@ public final class PlanStateAccessor {
|
||||
int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0);
|
||||
map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens());
|
||||
map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens());
|
||||
map.put(MateClawStateKeys.CACHE_READ_TOKENS,
|
||||
currentState.value(MateClawStateKeys.CACHE_READ_TOKENS, 0) + result.cacheReadTokens());
|
||||
map.put(MateClawStateKeys.CACHE_WRITE_TOKENS,
|
||||
currentState.value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0) + result.cacheWriteTokens());
|
||||
map.put(MateClawStateKeys.REASONING_TOKENS,
|
||||
currentState.value(MateClawStateKeys.REASONING_TOKENS, 0) + result.reasoningTokens());
|
||||
map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一个 step 的累计 usage(含 cache / reasoning 分项)加到 state 已有值上。
|
||||
* StepExecutionNode 在多个出口路径上写回同一组键,统一走这里避免漏项。
|
||||
*/
|
||||
public OutputBuilder addStepUsage(OverAllState currentState,
|
||||
int promptTokens, int completionTokens,
|
||||
int cacheReadTokens, int cacheWriteTokens,
|
||||
int reasoningTokens) {
|
||||
map.put(MateClawStateKeys.PROMPT_TOKENS,
|
||||
currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0) + promptTokens);
|
||||
map.put(MateClawStateKeys.COMPLETION_TOKENS,
|
||||
currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + completionTokens);
|
||||
map.put(MateClawStateKeys.CACHE_READ_TOKENS,
|
||||
currentState.value(MateClawStateKeys.CACHE_READ_TOKENS, 0) + cacheReadTokens);
|
||||
map.put(MateClawStateKeys.CACHE_WRITE_TOKENS,
|
||||
currentState.value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0) + cacheWriteTokens);
|
||||
map.put(MateClawStateKeys.REASONING_TOKENS,
|
||||
currentState.value(MateClawStateKeys.REASONING_TOKENS, 0) + reasoningTokens);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Map<String, Object> build() {
|
||||
return map;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -790,7 +790,7 @@ public class ChannelMessageRouter {
|
||||
StringBuilder replyAccumulator = new StringBuilder();
|
||||
final String channelType = adapter.getChannelType();
|
||||
// Token usage + model attribution: capture _usage_final event emitted at stream end
|
||||
final int[] usage = {0, 0}; // [promptTokens, completionTokens]
|
||||
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
|
||||
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
|
||||
agentService.chatStructuredStream(agentId, promptText, conversationId,
|
||||
message.getSenderId(), chatOrigin)
|
||||
@ -800,6 +800,9 @@ public class ChannelMessageRouter {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
|
||||
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
|
||||
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
|
||||
Object model = data.get("runtimeModelName");
|
||||
Object provider = data.get("runtimeProviderId");
|
||||
if (model != null) modelInfo[0] = model.toString();
|
||||
@ -840,7 +843,7 @@ public class ChannelMessageRouter {
|
||||
String status = isError ? "error" : "completed";
|
||||
MessageEntity saved = conversationService.saveMessage(
|
||||
conversationId, "assistant", reply, null, status,
|
||||
usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||
usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
|
||||
savedAssistantId = saved != null ? saved.getId() : null;
|
||||
if (!isError) {
|
||||
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin);
|
||||
@ -955,13 +958,16 @@ public class ChannelMessageRouter {
|
||||
// plan_step_* events, leaving the Web Console mirror with no
|
||||
// PlanStepsPanel for IM-routed conversations.
|
||||
// Token usage + model attribution: capture _usage_final event emitted at stream end
|
||||
final int[] usage = {0, 0}; // [promptTokens, completionTokens]
|
||||
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
|
||||
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
|
||||
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta -> {
|
||||
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
|
||||
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
|
||||
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
|
||||
Object model = data.get("runtimeModelName");
|
||||
Object provider = data.get("runtimeProviderId");
|
||||
if (model != null) modelInfo[0] = model.toString();
|
||||
@ -989,7 +995,7 @@ public class ChannelMessageRouter {
|
||||
String status = isError ? "error" : "completed";
|
||||
MessageEntity saved = conversationService.saveMessage(
|
||||
conversationId, "assistant", finalContent, null, status,
|
||||
usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||
usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
|
||||
if (!isError) {
|
||||
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin);
|
||||
}
|
||||
|
||||
@ -357,6 +357,9 @@ public class ChatController {
|
||||
persistStatus,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson()); // includes toolCalls metadata
|
||||
@ -436,6 +439,9 @@ public class ChatController {
|
||||
errStatus,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -629,6 +635,9 @@ public class ChatController {
|
||||
persistStatus,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -747,6 +756,9 @@ public class ChatController {
|
||||
status,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -849,6 +861,9 @@ public class ChatController {
|
||||
status,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -1375,6 +1390,9 @@ public class ChatController {
|
||||
persistStatus,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -1427,6 +1445,9 @@ public class ChatController {
|
||||
"failed",
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -1550,6 +1571,9 @@ public class ChatController {
|
||||
emptyAssistantPlaceholder(status), null, status,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -1597,6 +1621,19 @@ public class ChatController {
|
||||
}
|
||||
if (promptTokens > 0) payload.put("promptTokens", promptTokens);
|
||||
if (completionTokens > 0) payload.put("completionTokens", completionTokens);
|
||||
// Cache / reasoning detail rides on the persisted row so the live bubble
|
||||
// can render the usage breakdown without waiting for a history reload.
|
||||
if (savedAssistant != null) {
|
||||
if (savedAssistant.getCacheReadTokens() != null && savedAssistant.getCacheReadTokens() > 0) {
|
||||
payload.put("cacheReadTokens", savedAssistant.getCacheReadTokens());
|
||||
}
|
||||
if (savedAssistant.getCacheWriteTokens() != null && savedAssistant.getCacheWriteTokens() > 0) {
|
||||
payload.put("cacheWriteTokens", savedAssistant.getCacheWriteTokens());
|
||||
}
|
||||
if (savedAssistant.getReasoningTokens() != null && savedAssistant.getReasoningTokens() > 0) {
|
||||
payload.put("reasoningTokens", savedAssistant.getReasoningTokens());
|
||||
}
|
||||
}
|
||||
payload.put("persisted", persisted);
|
||||
if (messageCount != null) payload.put("messageCount", messageCount);
|
||||
return payload;
|
||||
@ -1651,6 +1688,9 @@ public class ChatController {
|
||||
status,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -1806,6 +1846,9 @@ public class ChatController {
|
||||
private int segCounter = 0;
|
||||
private int promptTokens = 0;
|
||||
private int completionTokens = 0;
|
||||
private int cacheReadTokens = 0;
|
||||
private int cacheWriteTokens = 0;
|
||||
private int reasoningTokens = 0;
|
||||
private String runtimeModelName = "";
|
||||
private String runtimeProviderId = "";
|
||||
private boolean awaitingApproval = false;
|
||||
@ -1852,6 +1895,9 @@ public class ChatController {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
completionTokens = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
cacheReadTokens = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
|
||||
cacheWriteTokens = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
|
||||
reasoningTokens = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
|
||||
runtimeModelName = String.valueOf(data.getOrDefault("runtimeModelName", ""));
|
||||
runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", ""));
|
||||
return;
|
||||
@ -2140,6 +2186,9 @@ public class ChatController {
|
||||
String getThinking() { return thinking.toString().trim(); }
|
||||
int getPromptTokens() { return promptTokens; }
|
||||
int getCompletionTokens() { return completionTokens; }
|
||||
int getCacheReadTokens() { return cacheReadTokens; }
|
||||
int getCacheWriteTokens() { return cacheWriteTokens; }
|
||||
int getReasoningTokens() { return reasoningTokens; }
|
||||
String getRuntimeModelName() { return runtimeModelName; }
|
||||
String getRuntimeProviderId() { return runtimeProviderId; }
|
||||
String getCurrentPhase() { return currentPhase; }
|
||||
|
||||
@ -212,7 +212,7 @@ public class WebChatController {
|
||||
// delta is not a persistence-only echo of content already streamed by inner nodes.
|
||||
StringBuilder assistantReply = new StringBuilder();
|
||||
// Token usage + model attribution: capture _usage_final event emitted at stream end
|
||||
final int[] usage = {0, 0}; // [promptTokens, completionTokens]
|
||||
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
|
||||
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
|
||||
|
||||
// Attribute memory to this external visitor so each end-user
|
||||
@ -230,6 +230,9 @@ public class WebChatController {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
|
||||
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
|
||||
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
|
||||
Object model = data.get("runtimeModelName");
|
||||
Object provider = data.get("runtimeProviderId");
|
||||
if (model != null) modelInfo[0] = model.toString();
|
||||
@ -265,7 +268,7 @@ public class WebChatController {
|
||||
if (!reply.isBlank()) {
|
||||
conversationService.saveMessage(
|
||||
conversationId, "assistant", reply, List.of(),
|
||||
"completed", usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||
"completed", usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
|
||||
}
|
||||
completionPublisher.publish(
|
||||
resolvedAgentId, conversationId, message, reply, "webchat", webchatOwnerKey);
|
||||
@ -1288,6 +1291,9 @@ public class WebChatController {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
|
||||
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
|
||||
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
|
||||
Object model = data.get("runtimeModelName");
|
||||
Object provider = data.get("runtimeProviderId");
|
||||
if (model != null) modelInfo[0] = model.toString();
|
||||
@ -1315,7 +1321,7 @@ public class WebChatController {
|
||||
if (!reply.isBlank()) {
|
||||
conversationService.saveMessage(
|
||||
conversationId, "assistant", reply, List.of(),
|
||||
"completed", usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||
"completed", usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
|
||||
}
|
||||
} catch (Exception persistErr) {
|
||||
log.warn("[WebChat] approve replay persist failed: {}", persistErr.getMessage());
|
||||
|
||||
@ -7,17 +7,26 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
/**
|
||||
* 从 Spring AI {@link Usage} 中提取 Anthropic 的 prompt cache token 计数。
|
||||
* 从 Spring AI {@link Usage} 中提取各 provider 的 prompt cache / reasoning token 计数。
|
||||
*
|
||||
* <p>spring-ai 的高层 {@code Usage} 接口只暴露 {@code promptTokens} / {@code completionTokens},
|
||||
* 没有 cache 维度;但 {@link Usage#getNativeUsage()} 会返回 provider 的原生 usage 对象。
|
||||
* 对 Anthropic 而言是 {@code AnthropicApi.Usage} record,含 {@code cacheCreationInputTokens}
|
||||
* 与 {@code cacheReadInputTokens}。</p>
|
||||
* 没有 cache / reasoning 维度;但 {@link Usage#getNativeUsage()} 会返回 provider 的原生
|
||||
* usage 对象。各 provider 的字段位置:</p>
|
||||
* <ul>
|
||||
* <li><b>Anthropic</b>({@code AnthropicApi.Usage}):顶层 {@code cacheReadInputTokens} /
|
||||
* {@code cacheCreationInputTokens}。注意其 {@code inputTokens} <b>不含</b>缓存部分
|
||||
* (加法口径)。无 reasoning 计数。</li>
|
||||
* <li><b>OpenAI 兼容</b>({@code OpenAiApi.Usage}):嵌套 {@code promptTokensDetails.cachedTokens}
|
||||
* 与 {@code completionTokenDetails.reasoningTokens};{@code promptTokens} <b>已含</b>
|
||||
* 缓存命中部分(包含口径)。无 cache 写入计数。</li>
|
||||
* <li><b>DashScope</b>({@code DashScopeApi.TokenUsage}):嵌套
|
||||
* {@code promptTokenDetailed.cachedTokens};包含口径,无写入/reasoning 计数。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>采用反射调用以避免:
|
||||
* <ul>
|
||||
* <li>对 spring-ai 内部 record 形态的硬编码(未来字段重命名风险小)</li>
|
||||
* <li>对其它 provider(OpenAI 兼容、DashScope)的 ClassCastException</li>
|
||||
* <li>对其它 provider 原生类型的编译期依赖与 ClassCastException</li>
|
||||
* </ul>
|
||||
* 反射结果按类缓存,热路径性能可接受。</p>
|
||||
*
|
||||
@ -25,11 +34,13 @@ import java.util.concurrent.ConcurrentMap;
|
||||
*/
|
||||
public final class CacheUsageExtractor {
|
||||
|
||||
/** {@code (cacheReadTokens, cacheWriteTokens)};任一字段不可得时为 0。 */
|
||||
public record CacheTokens(int cacheReadTokens, int cacheWriteTokens) {
|
||||
public static final CacheTokens EMPTY = new CacheTokens(0, 0);
|
||||
/** {@code (cacheReadTokens, cacheWriteTokens, reasoningTokens)};任一字段不可得时为 0。 */
|
||||
public record CacheTokens(int cacheReadTokens, int cacheWriteTokens, int reasoningTokens) {
|
||||
public static final CacheTokens EMPTY = new CacheTokens(0, 0, 0);
|
||||
|
||||
public boolean isEmpty() { return cacheReadTokens == 0 && cacheWriteTokens == 0; }
|
||||
public boolean isEmpty() {
|
||||
return cacheReadTokens == 0 && cacheWriteTokens == 0 && reasoningTokens == 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 缓存 (Class, methodName) → reflected Method(命中失败时为标记 NULL_METHOD)。 */
|
||||
@ -45,28 +56,58 @@ public final class CacheUsageExtractor {
|
||||
|
||||
private CacheUsageExtractor() {}
|
||||
|
||||
/** 从 spring-ai Usage 中尽力抽取 cache token;不支持的 provider 返回 EMPTY。 */
|
||||
/** 从 spring-ai Usage 中尽力抽取 cache / reasoning token;不支持的 provider 返回 EMPTY。 */
|
||||
public static CacheTokens extract(Usage usage) {
|
||||
if (usage == null) return CacheTokens.EMPTY;
|
||||
Object native_ = usage.getNativeUsage();
|
||||
if (native_ == null) return CacheTokens.EMPTY;
|
||||
|
||||
// Anthropic: top-level accessors on AnthropicApi.Usage
|
||||
int read = invokeIntAccessor(native_, "cacheReadInputTokens");
|
||||
int write = invokeIntAccessor(native_, "cacheCreationInputTokens");
|
||||
return (read == 0 && write == 0) ? CacheTokens.EMPTY : new CacheTokens(read, write);
|
||||
|
||||
// OpenAI-compatible: promptTokensDetails.cachedTokens
|
||||
if (read == 0) {
|
||||
read = invokeNestedIntAccessor(native_, "promptTokensDetails", "cachedTokens");
|
||||
}
|
||||
// DashScope: promptTokenDetailed.cachedTokens
|
||||
if (read == 0) {
|
||||
read = invokeNestedIntAccessor(native_, "promptTokenDetailed", "cachedTokens");
|
||||
}
|
||||
|
||||
// OpenAI-compatible: completionTokenDetails.reasoningTokens
|
||||
int reasoning = invokeNestedIntAccessor(native_, "completionTokenDetails", "reasoningTokens");
|
||||
if (reasoning == 0) {
|
||||
// Some OpenAI-compatible gateways pluralize the field name.
|
||||
reasoning = invokeNestedIntAccessor(native_, "completionTokensDetails", "reasoningTokens");
|
||||
}
|
||||
|
||||
return (read == 0 && write == 0 && reasoning == 0)
|
||||
? CacheTokens.EMPTY
|
||||
: new CacheTokens(read, write, reasoning);
|
||||
}
|
||||
|
||||
/** 两级访问:先取嵌套 detail 对象,再取其 int 字段;任一级缺失返回 0。 */
|
||||
private static int invokeNestedIntAccessor(Object target, String detailAccessor, String intAccessor) {
|
||||
Object detail = invokeAccessor(target, detailAccessor);
|
||||
if (detail == null) return 0;
|
||||
return invokeIntAccessor(detail, intAccessor);
|
||||
}
|
||||
|
||||
private static int invokeIntAccessor(Object target, String accessor) {
|
||||
Object v = invokeAccessor(target, accessor);
|
||||
return v instanceof Number n ? n.intValue() : 0;
|
||||
}
|
||||
|
||||
private static Object invokeAccessor(Object target, String accessor) {
|
||||
Class<?> cls = target.getClass();
|
||||
String key = cls.getName() + "#" + accessor;
|
||||
Method m = METHOD_CACHE.computeIfAbsent(key, k -> resolveAccessor(cls, accessor));
|
||||
if (m == NULL_METHOD) return 0;
|
||||
if (m == NULL_METHOD) return null;
|
||||
try {
|
||||
Object v = m.invoke(target);
|
||||
if (v instanceof Number n) return n.intValue();
|
||||
return 0;
|
||||
return m.invoke(target);
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
return 0;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -609,6 +609,16 @@ public class ConversationService {
|
||||
List<MessageContentPart> parts, String status,
|
||||
int promptTokens, int completionTokens,
|
||||
String runtimeModel, String runtimeProvider, String metadata) {
|
||||
return saveMessage(conversationId, role, content, parts, status,
|
||||
promptTokens, completionTokens, 0, 0, 0, runtimeModel, runtimeProvider, metadata);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MessageEntity saveMessage(String conversationId, String role, String content,
|
||||
List<MessageContentPart> parts, String status,
|
||||
int promptTokens, int completionTokens,
|
||||
int cacheReadTokens, int cacheWriteTokens, int reasoningTokens,
|
||||
String runtimeModel, String runtimeProvider, String metadata) {
|
||||
MessageEntity message = new MessageEntity();
|
||||
message.setConversationId(conversationId);
|
||||
message.setRole(role);
|
||||
@ -618,6 +628,9 @@ public class ConversationService {
|
||||
message.setTokenUsage(promptTokens + completionTokens);
|
||||
message.setPromptTokens(promptTokens);
|
||||
message.setCompletionTokens(completionTokens);
|
||||
message.setCacheReadTokens(cacheReadTokens);
|
||||
message.setCacheWriteTokens(cacheWriteTokens);
|
||||
message.setReasoningTokens(reasoningTokens);
|
||||
message.setRuntimeModel(runtimeModel);
|
||||
message.setRuntimeProvider(runtimeProvider);
|
||||
message.setMetadata(metadata != null ? metadata : "{}"); // Initialize as empty JSON object / 初始化为空对象
|
||||
|
||||
@ -72,6 +72,9 @@ public class TokenUsageService {
|
||||
wrapper.select(
|
||||
MessageEntity::getPromptTokens,
|
||||
MessageEntity::getCompletionTokens,
|
||||
MessageEntity::getCacheReadTokens,
|
||||
MessageEntity::getCacheWriteTokens,
|
||||
MessageEntity::getReasoningTokens,
|
||||
MessageEntity::getRuntimeModel,
|
||||
MessageEntity::getRuntimeProvider,
|
||||
MessageEntity::getCreateTime
|
||||
@ -87,6 +90,9 @@ public class TokenUsageService {
|
||||
|
||||
long totalPrompt = 0;
|
||||
long totalCompletion = 0;
|
||||
long totalCacheRead = 0;
|
||||
long totalCacheWrite = 0;
|
||||
long totalReasoning = 0;
|
||||
|
||||
// 按模型聚合
|
||||
Map<String, long[]> modelMap = new LinkedHashMap<>();
|
||||
@ -100,6 +106,9 @@ public class TokenUsageService {
|
||||
int completion = msg.getCompletionTokens() != null ? msg.getCompletionTokens() : 0;
|
||||
totalPrompt += prompt;
|
||||
totalCompletion += completion;
|
||||
totalCacheRead += msg.getCacheReadTokens() != null ? msg.getCacheReadTokens() : 0;
|
||||
totalCacheWrite += msg.getCacheWriteTokens() != null ? msg.getCacheWriteTokens() : 0;
|
||||
totalReasoning += msg.getReasoningTokens() != null ? msg.getReasoningTokens() : 0;
|
||||
|
||||
// 模型维度
|
||||
String model = msg.getRuntimeModel() != null ? msg.getRuntimeModel() : "unknown";
|
||||
@ -124,6 +133,9 @@ public class TokenUsageService {
|
||||
|
||||
vo.setTotalPromptTokens(totalPrompt);
|
||||
vo.setTotalCompletionTokens(totalCompletion);
|
||||
vo.setTotalCacheReadTokens(totalCacheRead);
|
||||
vo.setTotalCacheWriteTokens(totalCacheWrite);
|
||||
vo.setTotalReasoningTokens(totalReasoning);
|
||||
vo.setTotalMessages(messages.size());
|
||||
|
||||
// 转换 byModel
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
82
mateclaw-server/src/test/java/vip/mate/llm/cache/CacheUsageExtractorTest.java
vendored
Normal file
82
mateclaw-server/src/test/java/vip/mate/llm/cache/CacheUsageExtractorTest.java
vendored
Normal file
@ -0,0 +1,82 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.metadata.Usage;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Verifies reflective extraction of cache / reasoning token counters from the
|
||||
* provider-native usage shapes (Anthropic top-level accessors, OpenAI-compatible
|
||||
* and DashScope nested detail records).
|
||||
*/
|
||||
class CacheUsageExtractorTest {
|
||||
|
||||
/** Minimal Usage stub whose native payload drives the extraction. */
|
||||
private record StubUsage(Object nativeUsage) implements Usage {
|
||||
@Override public Integer getPromptTokens() { return 0; }
|
||||
@Override public Integer getCompletionTokens() { return 0; }
|
||||
@Override public Object getNativeUsage() { return nativeUsage; }
|
||||
}
|
||||
|
||||
/** Anthropic-style native usage: top-level cache accessors. */
|
||||
private record AnthropicStyleUsage(Integer inputTokens, Integer outputTokens,
|
||||
Integer cacheCreationInputTokens,
|
||||
Integer cacheReadInputTokens) {}
|
||||
|
||||
/** OpenAI-style native usage: nested prompt/completion detail records. */
|
||||
private record OpenAiPromptDetails(Integer audioTokens, Integer cachedTokens) {}
|
||||
private record OpenAiCompletionDetails(Integer reasoningTokens, Integer audioTokens) {}
|
||||
private record OpenAiStyleUsage(Integer promptTokens, Integer completionTokens,
|
||||
OpenAiPromptDetails promptTokensDetails,
|
||||
OpenAiCompletionDetails completionTokenDetails) {}
|
||||
|
||||
/** DashScope-style native usage: promptTokenDetailed.cachedTokens. */
|
||||
private record DashScopePromptDetailed(Integer cachedTokens) {}
|
||||
private record DashScopeStyleUsage(Integer inputTokens, Integer outputTokens,
|
||||
DashScopePromptDetailed promptTokenDetailed) {}
|
||||
|
||||
@Test
|
||||
void anthropicTopLevelCacheFields() {
|
||||
var usage = new StubUsage(new AnthropicStyleUsage(100, 50, 2000, 66000));
|
||||
var tokens = CacheUsageExtractor.extract(usage);
|
||||
assertEquals(66000, tokens.cacheReadTokens());
|
||||
assertEquals(2000, tokens.cacheWriteTokens());
|
||||
assertEquals(0, tokens.reasoningTokens());
|
||||
}
|
||||
|
||||
@Test
|
||||
void openAiNestedCachedAndReasoningTokens() {
|
||||
var usage = new StubUsage(new OpenAiStyleUsage(5000, 800,
|
||||
new OpenAiPromptDetails(0, 4200),
|
||||
new OpenAiCompletionDetails(300, 0)));
|
||||
var tokens = CacheUsageExtractor.extract(usage);
|
||||
assertEquals(4200, tokens.cacheReadTokens());
|
||||
assertEquals(0, tokens.cacheWriteTokens());
|
||||
assertEquals(300, tokens.reasoningTokens());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dashScopeNestedCachedTokens() {
|
||||
var usage = new StubUsage(new DashScopeStyleUsage(9000, 400,
|
||||
new DashScopePromptDetailed(7500)));
|
||||
var tokens = CacheUsageExtractor.extract(usage);
|
||||
assertEquals(7500, tokens.cacheReadTokens());
|
||||
assertEquals(0, tokens.cacheWriteTokens());
|
||||
assertEquals(0, tokens.reasoningTokens());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownProviderYieldsEmpty() {
|
||||
var tokens = CacheUsageExtractor.extract(new StubUsage(new Object()));
|
||||
assertTrue(tokens.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullDetailRecordsYieldZeroNotError() {
|
||||
var usage = new StubUsage(new OpenAiStyleUsage(5000, 800, null, null));
|
||||
var tokens = CacheUsageExtractor.extract(usage);
|
||||
assertTrue(tokens.isEmpty());
|
||||
}
|
||||
}
|
||||
@ -408,23 +408,100 @@
|
||||
class="action-model"
|
||||
:title="replyModelTitle"
|
||||
>{{ replyModel }}</span>
|
||||
<!-- Turn token total (assistant only): own usage + delegated sub-agents -->
|
||||
<span
|
||||
<!-- Turn token total (assistant only): own usage + delegated sub-agents.
|
||||
Click opens the per-turn consumption breakdown panel (cache hit/miss/write,
|
||||
reasoning vs reply, cache hit rate). -->
|
||||
<el-popover
|
||||
v-if="role === 'assistant' && tokenUsage"
|
||||
class="action-tokens"
|
||||
:title="tokenUsage.delegated > 0
|
||||
? $t('chat.tokenUsageTooltipDelegated', {
|
||||
total: tokenUsage.total.toLocaleString(),
|
||||
input: tokenUsage.input.toLocaleString(),
|
||||
output: tokenUsage.output.toLocaleString(),
|
||||
delegated: tokenUsage.delegated.toLocaleString(),
|
||||
})
|
||||
: $t('chat.tokenUsageTooltip', {
|
||||
total: tokenUsage.total.toLocaleString(),
|
||||
input: tokenUsage.input.toLocaleString(),
|
||||
output: tokenUsage.output.toLocaleString(),
|
||||
})"
|
||||
>Σ {{ fmtTokens(tokenUsage.total) }} tok</span>
|
||||
placement="top-end"
|
||||
trigger="click"
|
||||
:width="288"
|
||||
popper-class="mc-usage-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<button
|
||||
class="action-tokens usage-trigger"
|
||||
type="button"
|
||||
:title="tokenUsage.delegated > 0
|
||||
? $t('chat.tokenUsageTooltipDelegated', {
|
||||
total: tokenUsage.total.toLocaleString(),
|
||||
input: tokenUsage.input.toLocaleString(),
|
||||
output: tokenUsage.output.toLocaleString(),
|
||||
delegated: tokenUsage.delegated.toLocaleString(),
|
||||
})
|
||||
: $t('chat.tokenUsageTooltip', {
|
||||
total: tokenUsage.total.toLocaleString(),
|
||||
input: tokenUsage.input.toLocaleString(),
|
||||
output: tokenUsage.output.toLocaleString(),
|
||||
})"
|
||||
>Σ {{ fmtTokens(tokenUsage.total) }} tok</button>
|
||||
</template>
|
||||
<div class="mc-usage-panel">
|
||||
<div class="mc-usage-header">
|
||||
<span class="mc-usage-title">{{ $t('chat.usageDetail.title') }}</span>
|
||||
<span class="mc-usage-total-label">{{ $t('chat.usageDetail.total') }}</span>
|
||||
<span class="mc-usage-total">{{ tokenUsage.total.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="mc-usage-row mc-usage-row-main">
|
||||
<i class="mc-usage-dot mc-dot-input"></i>
|
||||
<span class="mc-usage-label">{{ $t('chat.usageDetail.input') }}</span>
|
||||
<span class="mc-usage-value">{{ tokenUsage.input.toLocaleString() }}</span>
|
||||
</div>
|
||||
<template v-if="tokenUsage.hasCacheData">
|
||||
<div class="mc-usage-row mc-usage-row-sub">
|
||||
<i class="mc-usage-dot mc-dot-hit"></i>
|
||||
<span class="mc-usage-label">{{ $t('chat.usageDetail.cacheHit') }}</span>
|
||||
<span class="mc-usage-value">{{ tokenUsage.cacheRead.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="mc-usage-row mc-usage-row-sub">
|
||||
<i class="mc-usage-dot mc-dot-miss"></i>
|
||||
<span class="mc-usage-label">{{ $t('chat.usageDetail.cacheMiss') }}</span>
|
||||
<span class="mc-usage-value">{{ tokenUsage.cacheMiss.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="mc-usage-row mc-usage-row-sub">
|
||||
<i class="mc-usage-dot mc-dot-write"></i>
|
||||
<span class="mc-usage-label">{{ $t('chat.usageDetail.cacheWrite') }}</span>
|
||||
<span class="mc-usage-value">{{ tokenUsage.cacheWrite.toLocaleString() }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="mc-usage-divider"></div>
|
||||
<div class="mc-usage-row mc-usage-row-main">
|
||||
<i class="mc-usage-dot mc-dot-output"></i>
|
||||
<span class="mc-usage-label">{{ $t('chat.usageDetail.output') }}</span>
|
||||
<span class="mc-usage-value">{{ tokenUsage.output.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="mc-usage-row mc-usage-row-sub">
|
||||
<span class="mc-usage-label mc-usage-label-indent">{{ $t('chat.usageDetail.reasoning') }}</span>
|
||||
<span class="mc-usage-value">{{ tokenUsage.reasoning.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="mc-usage-row mc-usage-row-sub">
|
||||
<span class="mc-usage-label mc-usage-label-indent">{{ $t('chat.usageDetail.reply') }}</span>
|
||||
<span class="mc-usage-value">{{ tokenUsage.reply.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenUsage.delegated > 0" class="mc-usage-row mc-usage-row-sub">
|
||||
<span class="mc-usage-label mc-usage-label-indent">{{ $t('chat.usageDetail.delegated') }}</span>
|
||||
<span class="mc-usage-value">{{ tokenUsage.delegated.toLocaleString() }}</span>
|
||||
</div>
|
||||
<template v-if="tokenUsage.hasCacheData">
|
||||
<div class="mc-usage-divider"></div>
|
||||
<div class="mc-usage-row mc-usage-row-main">
|
||||
<span class="mc-usage-hit-icon">⚡</span>
|
||||
<span class="mc-usage-label">{{ $t('chat.usageDetail.hitRate') }}</span>
|
||||
<span class="mc-usage-value mc-usage-hit-rate">{{ (tokenUsage.hitRate * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="mc-usage-bar">
|
||||
<i class="mc-bar-hit" :style="{ width: usageBarPct(tokenUsage.cacheRead) }"></i>
|
||||
<i class="mc-bar-write" :style="{ width: usageBarPct(tokenUsage.cacheWrite) }"></i>
|
||||
<i class="mc-bar-miss" :style="{ width: usageBarPct(tokenUsage.cacheMiss) }"></i>
|
||||
</div>
|
||||
<div class="mc-usage-legend">
|
||||
<span><i class="mc-usage-dot mc-dot-hit"></i>{{ $t('chat.usageDetail.legendHit') }}</span>
|
||||
<span><i class="mc-usage-dot mc-dot-write"></i>{{ $t('chat.usageDetail.legendWrite') }}</span>
|
||||
<span><i class="mc-usage-dot mc-dot-miss"></i>{{ $t('chat.usageDetail.legendMiss') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-popover>
|
||||
<!-- Multimodal sidecar routing badge (assistant only, when sidecar fired) -->
|
||||
<span
|
||||
v-if="role === 'assistant' && routingBadge"
|
||||
@ -1027,13 +1104,25 @@ const useSegmentedView = computed(() =>
|
||||
const tokenUsage = computed(() => {
|
||||
const m = props.message
|
||||
if (m.role !== 'assistant') return null
|
||||
// Total comes solely from the message usage, which the backend already rolls
|
||||
// Base usage comes from the message, which the backend already rolls
|
||||
// delegated sub-agent tokens into (so live and reloaded values match and there
|
||||
// is no double counting against the segment sum below).
|
||||
const input = m.promptTokens || 0
|
||||
const prompt = m.promptTokens || 0
|
||||
const output = m.completionTokens || 0
|
||||
if (prompt + output <= 0) return null
|
||||
const cacheRead = m.cacheReadTokens || 0
|
||||
const cacheWrite = m.cacheWriteTokens || 0
|
||||
const reasoning = m.reasoningTokens || 0
|
||||
// Provider accounting differs: the native Anthropic API reports input_tokens
|
||||
// EXCLUDING the cache read/write segments (additive), while OpenAI-compatible
|
||||
// and DashScope responses report prompt_tokens INCLUDING cached hits.
|
||||
const additive = (m.runtimeProvider || '').toLowerCase().includes('anthropic')
|
||||
const input = additive ? prompt + cacheRead + cacheWrite : prompt
|
||||
const cacheMiss = Math.max(0, input - cacheRead - cacheWrite)
|
||||
const reply = Math.max(0, output - reasoning)
|
||||
const hitRate = input > 0 ? cacheRead / input : 0
|
||||
const hasCacheData = cacheRead > 0 || cacheWrite > 0
|
||||
const total = input + output
|
||||
if (total <= 0) return null
|
||||
// Informational breakdown for the tooltip: how much of that total came from
|
||||
// delegated sub-agents. Derived from the delegation segments, so it is present
|
||||
// live and degrades to 0 after reload (the segments are not persisted).
|
||||
@ -1050,9 +1139,19 @@ const tokenUsage = computed(() => {
|
||||
delegated += (s.delegPromptTokens || 0) + (s.delegCompletionTokens || 0)
|
||||
addNodes(s.childTimeline?.children)
|
||||
}
|
||||
return { input, output, total, delegated: Math.min(delegated, total) }
|
||||
return {
|
||||
input, output, total, delegated: Math.min(delegated, total),
|
||||
cacheRead, cacheWrite, cacheMiss, reasoning, reply, hitRate, hasCacheData,
|
||||
}
|
||||
})
|
||||
|
||||
/** Width of a cache-bar segment as a percentage of total input tokens. */
|
||||
function usageBarPct(part: number): string {
|
||||
const u = tokenUsage.value
|
||||
if (!u || u.input <= 0) return '0%'
|
||||
return (part / u.input * 100).toFixed(2) + '%'
|
||||
}
|
||||
|
||||
/** Compact token count, e.g. 67890 → "67.9k". */
|
||||
function fmtTokens(n: number): string {
|
||||
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)
|
||||
@ -1841,6 +1940,17 @@ watch(isGenerating, (generating) => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The token chip is a popover trigger button — keep the chip look, add affordance. */
|
||||
.usage-trigger {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
line-height: inherit;
|
||||
}
|
||||
.usage-trigger:hover {
|
||||
color: var(--mc-text-secondary, #64748b);
|
||||
background: var(--mc-fill-3, rgba(100, 116, 139, 0.14));
|
||||
}
|
||||
|
||||
.action-routing {
|
||||
font-size: 11px;
|
||||
color: var(--mc-primary, #d96d46);
|
||||
@ -2593,3 +2703,108 @@ watch(isGenerating, (generating) => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Unscoped: the usage popover renders into <body> via Element Plus popper,
|
||||
so scoped selectors can't reach it. Classes are mc-usage-* prefixed. -->
|
||||
<style>
|
||||
.mc-usage-popover.el-popover {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.mc-usage-panel {
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-secondary, #64748b);
|
||||
}
|
||||
.mc-usage-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.mc-usage-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-primary, #1e293b);
|
||||
flex: 1;
|
||||
}
|
||||
.mc-usage-total-label {
|
||||
color: var(--mc-text-tertiary, #94a3b8);
|
||||
}
|
||||
.mc-usage-total {
|
||||
font-weight: 700;
|
||||
font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace);
|
||||
color: var(--mc-text-primary, #1e293b);
|
||||
}
|
||||
.mc-usage-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.mc-usage-row-main .mc-usage-label {
|
||||
color: var(--mc-text-primary, #1e293b);
|
||||
font-weight: 500;
|
||||
}
|
||||
.mc-usage-row-sub {
|
||||
padding-left: 14px;
|
||||
}
|
||||
.mc-usage-label {
|
||||
flex: 1;
|
||||
}
|
||||
.mc-usage-label-indent {
|
||||
padding-left: 14px;
|
||||
}
|
||||
.mc-usage-value {
|
||||
font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace);
|
||||
color: var(--mc-text-primary, #1e293b);
|
||||
}
|
||||
.mc-usage-divider {
|
||||
border-top: 1px dashed var(--mc-border-2, #e2e8f0);
|
||||
margin: 6px 0;
|
||||
}
|
||||
.mc-usage-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
flex: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.mc-dot-input { background: #3b82f6; }
|
||||
.mc-dot-output { background: #a855f7; }
|
||||
.mc-dot-hit { background: #10b981; }
|
||||
.mc-dot-miss { background: #f43f5e; }
|
||||
.mc-dot-write { background: #eab308; }
|
||||
.mc-usage-hit-icon {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
}
|
||||
.mc-usage-hit-rate {
|
||||
color: #10b981;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mc-usage-bar {
|
||||
display: flex;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: var(--mc-fill-2, rgba(100, 116, 139, 0.08));
|
||||
margin: 6px 0 6px;
|
||||
}
|
||||
.mc-usage-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.mc-bar-hit { background: #10b981; }
|
||||
.mc-bar-write { background: #eab308; }
|
||||
.mc-bar-miss { background: #f43f5e; }
|
||||
.mc-usage-legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--mc-text-tertiary, #94a3b8);
|
||||
}
|
||||
.mc-usage-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '按日期统计',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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[]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -57,6 +57,18 @@
|
||||
<div class="card-kicker">{{ t('tokenUsage.assistantMessages') }}</div>
|
||||
<div class="card-value">{{ formatNumber(data.totalMessages) }}</div>
|
||||
</div>
|
||||
<div v-if="(data.totalCacheReadTokens || 0) + (data.totalCacheWriteTokens || 0) > 0" class="summary-card">
|
||||
<div class="card-kicker">{{ t('tokenUsage.cacheReadTokens') }}</div>
|
||||
<div class="card-value">{{ formatNumber(data.totalCacheReadTokens) }}</div>
|
||||
</div>
|
||||
<div v-if="(data.totalCacheReadTokens || 0) + (data.totalCacheWriteTokens || 0) > 0" class="summary-card">
|
||||
<div class="card-kicker">{{ t('tokenUsage.cacheWriteTokens') }}</div>
|
||||
<div class="card-value">{{ formatNumber(data.totalCacheWriteTokens) }}</div>
|
||||
</div>
|
||||
<div v-if="(data.totalReasoningTokens || 0) > 0" class="summary-card">
|
||||
<div class="card-kicker">{{ t('tokenUsage.reasoningTokens') }}</div>
|
||||
<div class="card-value">{{ formatNumber(data.totalReasoningTokens) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.totalMessages === 0" class="empty-state">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user