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>, model: Option<String>,
started: bool, started: bool,
finished: bool, finished: bool,
usage: Option<CanonicalUsage>,
tool_calls: BTreeMap<usize, ClaudeProviderToolState>, tool_calls: BTreeMap<usize, ClaudeProviderToolState>,
} }
@@ -73,6 +74,7 @@ impl ClaudeProviderState {
.get("model") .get("model")
.and_then(Value::as_str) .and_then(Value::as_str)
.map(ToOwned::to_owned); .map(ToOwned::to_owned);
self.merge_usage(canonical_usage_from_claude_usage(message.get("usage")));
} }
self.ensure_started(report_context, &mut out); self.ensure_started(report_context, &mut out);
} }
@@ -303,7 +305,9 @@ impl ClaudeProviderState {
model, model,
event: CanonicalStreamEvent::Finish { event: CanonicalStreamEvent::Finish {
finish_reason, 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; 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 { enum ClaudeOpenBlock {
@@ -771,6 +788,34 @@ impl ClaudeClientEmitter {
)?); )?);
Ok(out) 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( 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] #[test]
fn claude_client_emitter_preserves_tool_identity_and_emits_thinking_blocks() { fn claude_client_emitter_preserves_tool_identity_and_emits_thinking_blocks() {
let mut emitter = ClaudeClientEmitter::default(); let mut emitter = ClaudeClientEmitter::default();

View File

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