mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user