fix: 修复缓存 token usage 转换语义

统一 OpenAI、Gemini、Claude 之间缓存 token 的 usage 语义,避免 Claude 侧重复统计缓存输入 token。

同时补充 stream 合并逻辑、字段注释和覆盖转换链路的测试。
This commit is contained in:
stabey
2026-05-27 23:49:07 +08:00
parent 14ad6e9b75
commit 739e39e1ca
9 changed files with 274 additions and 64 deletions
@@ -928,6 +928,7 @@ impl ClaudeClientEmitter {
}
fn merge_claude_usage(mut current: CanonicalUsage, next: CanonicalUsage) -> CanonicalUsage {
current.input_tokens_include_cache |= next.input_tokens_include_cache;
if next.input_tokens > 0 {
current.input_tokens = next.input_tokens;
}
@@ -949,11 +950,15 @@ fn merge_claude_usage(mut current: CanonicalUsage, next: CanonicalUsage) -> Cano
if next.reasoning_tokens > 0 {
current.reasoning_tokens = next.reasoning_tokens;
}
current.total_tokens = current
.input_tokens
.saturating_add(current.output_tokens)
.saturating_add(current.cache_creation_tokens)
let cache_input_tokens = current
.cache_creation_tokens
.saturating_add(current.cache_read_tokens);
let input_tokens = if current.input_tokens_include_cache {
current.input_tokens
} else {
current.input_tokens.saturating_add(cache_input_tokens)
};
current.total_tokens = input_tokens.saturating_add(current.output_tokens);
current
}
@@ -1204,6 +1209,23 @@ mod tests {
)));
}
#[test]
fn merge_claude_usage_preserves_inclusive_input_semantics() {
let merged = super::merge_claude_usage(
CanonicalUsage::default(),
CanonicalUsage {
input_tokens: 110_161,
input_tokens_include_cache: true,
output_tokens: 691,
cache_read_tokens: 107_008,
..CanonicalUsage::default()
},
);
assert!(merged.input_tokens_include_cache);
assert_eq!(merged.total_tokens, 110_852);
}
#[test]
fn claude_client_emitter_preserves_tool_identity_and_emits_thinking_blocks() {
let mut emitter = ClaudeClientEmitter::default();
@@ -1394,10 +1416,39 @@ mod tests {
.expect("finish should encode");
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"input_tokens\":10"));
assert!(sse.contains("\"cache_creation_input_tokens\":5"));
assert!(sse.contains("\"cache_read_input_tokens\":4"));
}
#[test]
fn claude_client_emitter_subtracts_cache_when_input_tokens_include_cache() {
let mut emitter = ClaudeClientEmitter::default();
let bytes = emitter
.emit(CanonicalStreamFrame {
id: "msg_cache".to_string(),
model: "claude-sonnet-4-5".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("stop".to_string()),
usage: Some(CanonicalUsage {
input_tokens: 110_161,
input_tokens_include_cache: true,
output_tokens: 691,
total_tokens: 110_852,
cache_read_tokens: 107_008,
reasoning_tokens: 516,
..CanonicalUsage::default()
}),
},
})
.expect("finish should encode");
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"input_tokens\":3153"));
assert!(sse.contains("\"cache_read_input_tokens\":107008"));
assert!(sse.contains("\"output_tokens\":691"));
}
#[test]
fn claude_client_emitter_emits_image_blocks_for_media_parts() {
let mut emitter = ClaudeClientEmitter::default();