mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-13 12:39:50 +08:00
fix: 修复缓存 token usage 转换语义
统一 OpenAI、Gemini、Claude 之间缓存 token 的 usage 语义,避免 Claude 侧重复统计缓存输入 token。 同时补充 stream 合并逻辑、字段注释和覆盖转换链路的测试。
This commit is contained in:
@@ -99,6 +99,7 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
|
||||
}
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
input_tokens_include_cache: cache_read_tokens > 0 || cache_creation_tokens > 0,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
cache_creation_tokens,
|
||||
@@ -247,6 +248,7 @@ pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<Canoni
|
||||
.unwrap_or(0);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
input_tokens_include_cache: false,
|
||||
output_tokens,
|
||||
total_tokens: input_tokens
|
||||
.saturating_add(output_tokens)
|
||||
@@ -314,22 +316,27 @@ pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<Canoni
|
||||
let usage = value?.as_object()?;
|
||||
let input_tokens = usage
|
||||
.get("promptTokenCount")
|
||||
.or_else(|| usage.get("prompt_token_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.get("candidatesTokenCount")
|
||||
.or_else(|| usage.get("candidates_token_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let reasoning_tokens = usage
|
||||
.get("thoughtsTokenCount")
|
||||
.or_else(|| usage.get("thoughts_token_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let cache_read_tokens = usage
|
||||
.get("cachedContentTokenCount")
|
||||
.or_else(|| usage.get("cached_content_token_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.get("totalTokenCount")
|
||||
.or_else(|| usage.get("total_token_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(
|
||||
input_tokens
|
||||
@@ -338,6 +345,7 @@ pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<Canoni
|
||||
);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
input_tokens_include_cache: cache_read_tokens > 0,
|
||||
output_tokens: output_tokens.saturating_add(reasoning_tokens),
|
||||
total_tokens,
|
||||
cache_read_tokens,
|
||||
@@ -490,12 +498,13 @@ pub fn build_openai_chat_usage_chunk_from_usage(
|
||||
model: &str,
|
||||
usage: &CanonicalUsage,
|
||||
) -> Value {
|
||||
let input_tokens = inclusive_input_tokens_from_usage(usage);
|
||||
build_openai_chat_usage_chunk_with_cache(
|
||||
id,
|
||||
model,
|
||||
usage.input_tokens,
|
||||
input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.total_tokens,
|
||||
inclusive_total_tokens_from_usage(usage, input_tokens),
|
||||
usage.reasoning_tokens,
|
||||
cache_creation_tokens_for_usage(usage),
|
||||
usage.cache_read_tokens,
|
||||
@@ -504,12 +513,16 @@ pub fn build_openai_chat_usage_chunk_from_usage(
|
||||
|
||||
pub fn openai_responses_usage_from_usage(usage: &CanonicalUsage) -> Value {
|
||||
let mut output = Map::new();
|
||||
output.insert("input_tokens".to_string(), Value::from(usage.input_tokens));
|
||||
let input_tokens = inclusive_input_tokens_from_usage(usage);
|
||||
output.insert("input_tokens".to_string(), Value::from(input_tokens));
|
||||
output.insert(
|
||||
"output_tokens".to_string(),
|
||||
Value::from(usage.output_tokens),
|
||||
);
|
||||
output.insert("total_tokens".to_string(), Value::from(usage.total_tokens));
|
||||
output.insert(
|
||||
"total_tokens".to_string(),
|
||||
Value::from(inclusive_total_tokens_from_usage(usage, input_tokens)),
|
||||
);
|
||||
if usage.reasoning_tokens > 0 {
|
||||
output.insert(
|
||||
"output_tokens_details".to_string(),
|
||||
@@ -527,7 +540,10 @@ pub fn openai_responses_usage_from_usage(usage: &CanonicalUsage) -> Value {
|
||||
|
||||
pub fn claude_usage_from_usage(usage: &CanonicalUsage) -> Value {
|
||||
let mut output = Map::new();
|
||||
output.insert("input_tokens".to_string(), Value::from(usage.input_tokens));
|
||||
output.insert(
|
||||
"input_tokens".to_string(),
|
||||
Value::from(claude_input_tokens_from_usage(usage)),
|
||||
);
|
||||
output.insert(
|
||||
"output_tokens".to_string(),
|
||||
Value::from(usage.output_tokens),
|
||||
@@ -560,18 +576,16 @@ pub fn claude_usage_from_usage(usage: &CanonicalUsage) -> Value {
|
||||
|
||||
pub fn gemini_usage_metadata_from_usage(usage: &CanonicalUsage) -> Value {
|
||||
let visible_output_tokens = usage.output_tokens.saturating_sub(usage.reasoning_tokens);
|
||||
let input_tokens = inclusive_input_tokens_from_usage(usage);
|
||||
let mut output = Map::new();
|
||||
output.insert(
|
||||
"promptTokenCount".to_string(),
|
||||
Value::from(usage.input_tokens),
|
||||
);
|
||||
output.insert("promptTokenCount".to_string(), Value::from(input_tokens));
|
||||
output.insert(
|
||||
"candidatesTokenCount".to_string(),
|
||||
Value::from(visible_output_tokens),
|
||||
);
|
||||
output.insert(
|
||||
"totalTokenCount".to_string(),
|
||||
Value::from(usage.total_tokens),
|
||||
Value::from(inclusive_total_tokens_from_usage(usage, input_tokens)),
|
||||
);
|
||||
if usage.reasoning_tokens > 0 {
|
||||
output.insert(
|
||||
@@ -649,3 +663,39 @@ fn cache_creation_tokens_for_usage(usage: &CanonicalUsage) -> u64 {
|
||||
.saturating_add(usage.cache_creation_ephemeral_1h_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_input_tokens_for_usage(usage: &CanonicalUsage) -> u64 {
|
||||
usage
|
||||
.cache_read_tokens
|
||||
.saturating_add(cache_creation_tokens_for_usage(usage))
|
||||
}
|
||||
|
||||
fn claude_input_tokens_from_usage(usage: &CanonicalUsage) -> u64 {
|
||||
if usage.input_tokens_include_cache {
|
||||
usage
|
||||
.input_tokens
|
||||
.saturating_sub(cache_input_tokens_for_usage(usage))
|
||||
} else {
|
||||
usage.input_tokens
|
||||
}
|
||||
}
|
||||
|
||||
fn inclusive_input_tokens_from_usage(usage: &CanonicalUsage) -> u64 {
|
||||
if usage.input_tokens_include_cache {
|
||||
usage.input_tokens
|
||||
} else {
|
||||
usage
|
||||
.input_tokens
|
||||
.saturating_add(cache_input_tokens_for_usage(usage))
|
||||
}
|
||||
}
|
||||
|
||||
fn inclusive_total_tokens_from_usage(usage: &CanonicalUsage, input_tokens: u64) -> u64 {
|
||||
if usage.total_tokens > 0
|
||||
&& (usage.input_tokens_include_cache || cache_input_tokens_for_usage(usage) == 0)
|
||||
{
|
||||
usage.total_tokens
|
||||
} else {
|
||||
input_tokens.saturating_add(usage.output_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,9 @@ use crate::formats::shared::response::{
|
||||
sanitize_claude_read_tool_inputs,
|
||||
};
|
||||
use crate::formats::shared::stream_core::common::{
|
||||
content_part_from_openai_image_generation_item, map_openai_finish_reason_to_gemini,
|
||||
parse_json_arguments_value, CanonicalContentPart, CanonicalStreamEvent, CanonicalUsage,
|
||||
content_part_from_openai_image_generation_item, gemini_usage_metadata_from_usage,
|
||||
map_openai_finish_reason_to_gemini, parse_json_arguments_value, CanonicalContentPart,
|
||||
CanonicalStreamEvent, CanonicalUsage,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -3044,26 +3045,7 @@ fn gemini_sync_part_from_canonical_content_part(part: CanonicalContentPart) -> V
|
||||
}
|
||||
|
||||
fn gemini_usage_metadata_from_canonical(usage: CanonicalUsage) -> Value {
|
||||
let mut usage_metadata = Map::new();
|
||||
usage_metadata.insert(
|
||||
"promptTokenCount".to_string(),
|
||||
Value::from(usage.input_tokens),
|
||||
);
|
||||
usage_metadata.insert(
|
||||
"candidatesTokenCount".to_string(),
|
||||
Value::from(usage.output_tokens.saturating_sub(usage.reasoning_tokens)),
|
||||
);
|
||||
usage_metadata.insert(
|
||||
"totalTokenCount".to_string(),
|
||||
Value::from(usage.total_tokens),
|
||||
);
|
||||
if usage.reasoning_tokens > 0 {
|
||||
usage_metadata.insert(
|
||||
"thoughtsTokenCount".to_string(),
|
||||
Value::from(usage.reasoning_tokens),
|
||||
);
|
||||
}
|
||||
Value::Object(usage_metadata)
|
||||
gemini_usage_metadata_from_usage(&usage)
|
||||
}
|
||||
|
||||
fn parse_data_url(value: &str) -> Option<(String, String)> {
|
||||
@@ -4213,6 +4195,9 @@ mod tests {
|
||||
"prompt_tokens": 2,
|
||||
"completion_tokens": 4,
|
||||
"total_tokens": 6,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 1
|
||||
},
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 1
|
||||
}
|
||||
@@ -4230,6 +4215,9 @@ mod tests {
|
||||
)
|
||||
.expect("canonical openai chat -> claude");
|
||||
assert_eq!(converted_claude, legacy_claude);
|
||||
assert_eq!(converted_claude["usage"]["input_tokens"], 1);
|
||||
assert_eq!(converted_claude["usage"]["cache_read_input_tokens"], 1);
|
||||
assert_eq!(converted_claude["usage"]["output_tokens"], 4);
|
||||
|
||||
let legacy_gemini =
|
||||
convert_openai_chat_response_to_gemini_chat(&provider_body_json, &report_context)
|
||||
@@ -4341,6 +4329,9 @@ mod tests {
|
||||
converted_claude["content"][3]["content"],
|
||||
json!({"ok": true})
|
||||
);
|
||||
assert_eq!(converted_claude["usage"]["input_tokens"], 1);
|
||||
assert_eq!(converted_claude["usage"]["cache_read_input_tokens"], 2);
|
||||
assert_eq!(converted_claude["usage"]["output_tokens"], 5);
|
||||
|
||||
let converted_gemini = convert_standard_chat_response(
|
||||
&provider_body_json,
|
||||
|
||||
Reference in New Issue
Block a user