fix(usage): Claude 流式合并 message_start/delta usage,前端移动端缓存 token 合并展示

- Claude provider state 在 message_start 记录基础 usage,message_delta 合并 output_tokens
- 前端 UsageRecordsTable 移动端缓存列改为 cache_creation + cache_read 合计展示
This commit is contained in:
fawney19
2026-04-24 22:28:04 +08:00
parent 5b3593038d
commit 2c6209277f
2 changed files with 104 additions and 3 deletions

View File

@@ -20,6 +20,7 @@ pub struct ClaudeProviderState {
model: Option<String>,
started: bool,
finished: bool,
usage: Option<CanonicalUsage>,
tool_calls: BTreeMap<usize, ClaudeProviderToolState>,
}
@@ -73,6 +74,7 @@ impl ClaudeProviderState {
.get("model")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
self.merge_usage(canonical_usage_from_claude_usage(message.get("usage")));
}
self.ensure_started(report_context, &mut out);
}
@@ -303,7 +305,9 @@ impl ClaudeProviderState {
model,
event: CanonicalStreamEvent::Finish {
finish_reason,
usage: canonical_usage_from_claude_usage(event_object.get("usage")),
usage: self.finish_usage(canonical_usage_from_claude_usage(
event_object.get("usage"),
)),
},
});
self.finished = true;
@@ -331,6 +335,19 @@ impl ClaudeProviderState {
},
}])
}
fn merge_usage(&mut self, usage: Option<CanonicalUsage>) {
let Some(usage) = usage else {
return;
};
let current = self.usage.take().unwrap_or_default();
self.usage = Some(merge_claude_usage(current, usage));
}
fn finish_usage(&mut self, usage: Option<CanonicalUsage>) -> Option<CanonicalUsage> {
self.merge_usage(usage);
self.usage.take()
}
}
enum ClaudeOpenBlock {
@@ -771,6 +788,34 @@ impl ClaudeClientEmitter {
)?);
Ok(out)
}
}
fn merge_claude_usage(mut current: CanonicalUsage, next: CanonicalUsage) -> CanonicalUsage {
if next.input_tokens > 0 {
current.input_tokens = next.input_tokens;
}
if next.output_tokens > 0 {
current.output_tokens = next.output_tokens;
}
if next.cache_creation_tokens > 0 {
current.cache_creation_tokens = next.cache_creation_tokens;
}
if next.cache_creation_ephemeral_5m_tokens > 0 {
current.cache_creation_ephemeral_5m_tokens = next.cache_creation_ephemeral_5m_tokens;
}
if next.cache_creation_ephemeral_1h_tokens > 0 {
current.cache_creation_ephemeral_1h_tokens = next.cache_creation_ephemeral_1h_tokens;
}
if next.cache_read_tokens > 0 {
current.cache_read_tokens = next.cache_read_tokens;
}
current.total_tokens = current
.input_tokens
.saturating_add(current.output_tokens)
.saturating_add(current.cache_creation_tokens)
.saturating_add(current.cache_read_tokens);
current
}
fn canonical_content_part_from_claude_block(
@@ -945,6 +990,58 @@ mod tests {
)));
}
#[test]
fn claude_provider_state_merges_start_and_delta_usage() {
let mut state = ClaudeProviderState::default();
let report_context = json!({});
let _ = state
.push_line(
&report_context,
data_line(json!({
"type": "message_start",
"message": {
"id": "msg_123",
"model": "claude-sonnet-4-5",
"usage": {
"input_tokens": 5,
"cache_creation_input_tokens": 59_573,
"cache_read_input_tokens": 0,
"output_tokens": 0,
},
},
})),
)
.expect("message_start should parse");
let frames = state
.push_line(
&report_context,
data_line(json!({
"type": "message_delta",
"delta": {
"stop_reason": "end_turn",
},
"usage": {
"output_tokens": 162,
},
})),
)
.expect("message_delta should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::Finish {
usage: Some(CanonicalUsage {
input_tokens: 5,
output_tokens: 162,
cache_creation_tokens: 59_573,
cache_read_tokens: 0,
..
}),
..
}
)));
}
#[test]
fn claude_client_emitter_preserves_tool_identity_and_emits_thinking_blocks() {
let mut emitter = ClaudeClientEmitter::default();

View File

@@ -569,7 +569,7 @@
<span>{{ formatTokens(record.output_tokens || 0) }}</span>
</div>
<div class="flex items-center gap-1 text-muted-foreground">
<span :class="hasPositiveTokens(record.cache_read_input_tokens) ? 'text-foreground/70' : ''">{{ formatOptionalTokens(record.cache_read_input_tokens) }}</span>
<span :class="hasPositiveTokens(getRecordCacheTokens(record)) ? 'text-foreground/70' : ''">{{ formatOptionalTokens(getRecordCacheTokens(record)) }}</span>
<span>/</span>
<span>-</span>
</div>
@@ -689,7 +689,7 @@ import {
import { RefreshCcw, Search } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format'
import { formatDateTime } from '../composables'
import { getEffectiveInputTokens } from '../token-normalization'
import { getCacheCreationTokens, getEffectiveInputTokens } from '../token-normalization'
import {
formatUsageStreamLabel,
isUsageRecordFailed,
@@ -825,6 +825,10 @@ function getRecordEffectiveInputTokens(record: UsageRecord): number {
return getEffectiveInputTokens(record)
}
function getRecordCacheTokens(record: UsageRecord): number {
return getCacheCreationTokens(record) + (record.cache_read_input_tokens || 0)
}
function hasPositiveTokens(value: number | null | undefined): boolean {
return typeof value === 'number' && Number.isFinite(value) && value > 0
}