feat(usage): 在 CanonicalUsage 中追加缓存 token 字段并在标准化流程透传

- 扩展 CanonicalUsage 支持 cache_creation/cache_read 及 5m/1h 明细
- OpenAI/Claude/Gemini 解析器提取缓存 token 并计入 total
- usage_mapper 补齐 Claude 缓存字段映射
- 新增 gateway pricing 集成测试覆盖三家同步/流式缓存计费
This commit is contained in:
fawney19
2026-04-19 03:55:55 +08:00
parent 2f487fda66
commit 0329b7b784
7 changed files with 1443 additions and 7 deletions

View File

@@ -178,4 +178,5 @@ pub(super) fn sample_local_openai_key() -> StoredProviderCatalogKey {
mod direct; mod direct;
mod local; mod local;
mod pricing;
mod wallet; mod wallet;

File diff suppressed because it is too large Load Diff

View File

@@ -514,6 +514,7 @@ mod tests {
input_tokens: 1, input_tokens: 1,
output_tokens: 2, output_tokens: 2,
total_tokens: 3, total_tokens: 3,
..CanonicalUsage::default()
}), }),
}, },
}) })

View File

@@ -1764,6 +1764,7 @@ mod tests {
input_tokens: 1, input_tokens: 1,
output_tokens: 2, output_tokens: 2,
total_tokens: 3, total_tokens: 3,
..CanonicalUsage::default()
}), }),
}, },
}; };
@@ -1902,6 +1903,7 @@ mod tests {
input_tokens: 1, input_tokens: 1,
output_tokens: 2, output_tokens: 2,
total_tokens: 3, total_tokens: 3,
..
}), }),
} if reason == "tool_calls" } if reason == "tool_calls"
))); )));
@@ -1961,6 +1963,7 @@ mod tests {
input_tokens: 1, input_tokens: 1,
output_tokens: 2, output_tokens: 2,
total_tokens: 3, total_tokens: 3,
..CanonicalUsage::default()
}), }),
}, },
}) })
@@ -2043,6 +2046,7 @@ mod tests {
input_tokens: 1, input_tokens: 1,
output_tokens: 2, output_tokens: 2,
total_tokens: 3, total_tokens: 3,
..
}), }),
} if reason == "stop" } if reason == "stop"
))); )));
@@ -2078,6 +2082,7 @@ mod tests {
input_tokens: 1, input_tokens: 1,
output_tokens: 2, output_tokens: 2,
total_tokens: 3, total_tokens: 3,
..CanonicalUsage::default()
}), }),
}, },
}) })

View File

@@ -5,6 +5,10 @@ pub struct CanonicalUsage {
pub input_tokens: u64, pub input_tokens: u64,
pub output_tokens: u64, pub output_tokens: u64,
pub total_tokens: u64, pub total_tokens: u64,
pub cache_creation_tokens: u64,
pub cache_creation_ephemeral_5m_tokens: u64,
pub cache_creation_ephemeral_1h_tokens: u64,
pub cache_read_tokens: u64,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -78,14 +82,43 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
.or_else(|| usage.get("completion_tokens")) .or_else(|| usage.get("completion_tokens"))
.and_then(Value::as_u64) .and_then(Value::as_u64)
.unwrap_or(0); .unwrap_or(0);
let total_tokens = usage let cache_creation_tokens = usage
.get("total_tokens") .get("cache_creation_input_tokens")
.and_then(Value::as_u64) .and_then(Value::as_u64)
.unwrap_or(input_tokens + output_tokens); .or_else(|| {
usage
.get("input_tokens_details")
.or_else(|| usage.get("prompt_tokens_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("cached_creation_tokens"))
.and_then(Value::as_u64)
})
.unwrap_or(0);
let cache_read_tokens = usage
.get("cache_read_input_tokens")
.and_then(Value::as_u64)
.or_else(|| {
usage
.get("input_tokens_details")
.or_else(|| usage.get("prompt_tokens_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("cached_tokens"))
.and_then(Value::as_u64)
})
.unwrap_or(0);
let total_tokens = usage.get("total_tokens").and_then(Value::as_u64).unwrap_or(
input_tokens
.saturating_add(output_tokens)
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
);
Some(CanonicalUsage { Some(CanonicalUsage {
input_tokens, input_tokens,
output_tokens, output_tokens,
total_tokens, total_tokens,
cache_creation_tokens,
cache_read_tokens,
..CanonicalUsage::default()
}) })
} }
@@ -99,10 +132,39 @@ pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<Canoni
.get("output_tokens") .get("output_tokens")
.and_then(Value::as_u64) .and_then(Value::as_u64)
.unwrap_or(0); .unwrap_or(0);
let cache_creation_ephemeral_5m_tokens = usage
.get("cache_creation")
.and_then(Value::as_object)
.and_then(|value| value.get("ephemeral_5m_input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let cache_creation_ephemeral_1h_tokens = usage
.get("cache_creation")
.and_then(Value::as_object)
.and_then(|value| value.get("ephemeral_1h_input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let cache_creation_tokens = usage
.get("cache_creation_input_tokens")
.and_then(Value::as_u64)
.unwrap_or(
cache_creation_ephemeral_5m_tokens.saturating_add(cache_creation_ephemeral_1h_tokens),
);
let cache_read_tokens = usage
.get("cache_read_input_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
Some(CanonicalUsage { Some(CanonicalUsage {
input_tokens, input_tokens,
output_tokens, output_tokens,
total_tokens: input_tokens + output_tokens, total_tokens: input_tokens
.saturating_add(output_tokens)
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
cache_creation_tokens,
cache_creation_ephemeral_5m_tokens,
cache_creation_ephemeral_1h_tokens,
cache_read_tokens,
}) })
} }
@@ -116,14 +178,24 @@ pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<Canoni
.get("candidatesTokenCount") .get("candidatesTokenCount")
.and_then(Value::as_u64) .and_then(Value::as_u64)
.unwrap_or(0); .unwrap_or(0);
let cache_read_tokens = usage
.get("cachedContentTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage let total_tokens = usage
.get("totalTokenCount") .get("totalTokenCount")
.and_then(Value::as_u64) .and_then(Value::as_u64)
.unwrap_or(input_tokens + output_tokens); .unwrap_or(
input_tokens
.saturating_add(output_tokens)
.saturating_add(cache_read_tokens),
);
Some(CanonicalUsage { Some(CanonicalUsage {
input_tokens, input_tokens,
output_tokens, output_tokens,
total_tokens, total_tokens,
cache_read_tokens,
..CanonicalUsage::default()
}) })
} }

View File

@@ -259,11 +259,17 @@ fn standardized_usage_from_canonical(usage: CanonicalUsage) -> StandardizedUsage
let mut standardized = StandardizedUsage::new(); let mut standardized = StandardizedUsage::new();
standardized.input_tokens = usage.input_tokens as i64; standardized.input_tokens = usage.input_tokens as i64;
standardized.output_tokens = usage.output_tokens as i64; standardized.output_tokens = usage.output_tokens as i64;
standardized.cache_creation_tokens = usage.cache_creation_tokens as i64;
standardized.cache_creation_ephemeral_5m_tokens =
usage.cache_creation_ephemeral_5m_tokens as i64;
standardized.cache_creation_ephemeral_1h_tokens =
usage.cache_creation_ephemeral_1h_tokens as i64;
standardized.cache_read_tokens = usage.cache_read_tokens as i64;
standardized.dimensions.insert( standardized.dimensions.insert(
"total_tokens".to_string(), "total_tokens".to_string(),
serde_json::json!(usage.total_tokens), serde_json::json!(usage.total_tokens),
); );
standardized standardized.normalize_cache_creation_breakdown()
} }
impl ClientStreamEmitter { impl ClientStreamEmitter {
@@ -413,7 +419,7 @@ fn parse_gemini_error(payload: &Value) -> Option<(String, Option<String>, LocalC
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::StreamingStandardFormatMatrix; use super::{StreamingStandardFormatMatrix, StreamingStandardTerminalObserver};
use serde_json::{json, Value}; use serde_json::{json, Value};
fn report_context(provider_api_format: &str, client_api_format: &str) -> Value { fn report_context(provider_api_format: &str, client_api_format: &str) -> Value {
@@ -689,4 +695,53 @@ mod tests {
.is_empty()); .is_empty());
} }
} }
#[test]
fn terminal_observer_preserves_claude_cache_usage() {
let report_context = report_context("claude:chat", "openai:chat");
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(
&report_context,
data_line(json!({
"type": "message_start",
"message": {
"id": "msg_cache_123",
"model": "claude-sonnet-4-5"
}
})),
)
.expect("message_start should parse");
observer
.push_line(
&report_context,
data_line(json!({
"type": "message_delta",
"delta": {
"stop_reason": "end_turn"
},
"usage": {
"input_tokens": 6,
"output_tokens": 20,
"cache_creation_input_tokens": 42262,
"cache_read_input_tokens": 0
}
})),
)
.expect("message_delta should parse");
let summary = observer
.latest_summary()
.cloned()
.expect("summary should exist");
let usage = summary
.standardized_usage
.expect("standardized usage should exist");
assert_eq!(usage.input_tokens, 6);
assert_eq!(usage.output_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 42_262);
assert_eq!(usage.cache_read_tokens, 0);
}
} }

View File

@@ -66,6 +66,22 @@ fn base_mapping(api_format: &str) -> BTreeMap<String, String> {
mapping.insert("completion_tokens".to_string(), "output_tokens".to_string()); mapping.insert("completion_tokens".to_string(), "output_tokens".to_string());
mapping.insert("input_tokens".to_string(), "input_tokens".to_string()); mapping.insert("input_tokens".to_string(), "input_tokens".to_string());
mapping.insert("output_tokens".to_string(), "output_tokens".to_string()); mapping.insert("output_tokens".to_string(), "output_tokens".to_string());
mapping.insert(
"cache_creation_input_tokens".to_string(),
"cache_creation_tokens".to_string(),
);
mapping.insert(
"cache_creation.ephemeral_5m_input_tokens".to_string(),
"cache_creation_ephemeral_5m_tokens".to_string(),
);
mapping.insert(
"cache_creation.ephemeral_1h_input_tokens".to_string(),
"cache_creation_ephemeral_1h_tokens".to_string(),
);
mapping.insert(
"cache_read_input_tokens".to_string(),
"cache_read_tokens".to_string(),
);
mapping.insert( mapping.insert(
"prompt_tokens_details.cached_tokens".to_string(), "prompt_tokens_details.cached_tokens".to_string(),
"cache_read_tokens".to_string(), "cache_read_tokens".to_string(),
@@ -252,6 +268,26 @@ mod tests {
assert_eq!(usage.reasoning_tokens, 1); assert_eq!(usage.reasoning_tokens, 1);
} }
#[test]
fn maps_openai_responses_with_top_level_cache_fields() {
let usage = map_usage_from_response(
&serde_json::json!({
"usage": {
"input_tokens": 6,
"output_tokens": 20,
"cache_creation_input_tokens": 42_262,
"cache_read_input_tokens": 0
}
}),
"openai:chat",
);
assert_eq!(usage.input_tokens, 6);
assert_eq!(usage.output_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 42_262);
assert_eq!(usage.cache_read_tokens, 0);
}
#[test] #[test]
fn maps_openai_responses_usage_from_stream_chunks() { fn maps_openai_responses_usage_from_stream_chunks() {
let usage = map_usage_from_response( let usage = map_usage_from_response(