feat: 扩展 cache creation token 细分统计与 effective_input_tokens 计费逻辑

- 新增 cache_creation_ephemeral_5m/1h_input_tokens 字段,区分不同 TTL 的缓存写入 token
- 引入 effective_input_tokens(扣除 cache read 后的有效输入 token),暴露给 usage 接口
- billing 规则生成器支持 5m/1h ephemeral cache 独立定价与分级计费
- usage_mapper 增加 Claude/Anthropic 格式映射,修复 OpenAI responses 格式字段兼容性
- 迁移逻辑增强:支持 checksum 容错、applied/pending 数量日志、逐步执行信息输出
- executor 抽离 LocalExecutionRequestOutcome 类型,统一 sync/stream 路径返回语义
- provider-transport auth 层新增 complete passthrough headers 构建逻辑
- 前端 usage 类型全面补充 effective_input_tokens、cache_creation_tokens、total_input_context 字段
This commit is contained in:
fawney19
2026-04-10 17:44:55 +08:00
parent 5014e2f5fd
commit 010ab127e2
64 changed files with 4217 additions and 477 deletions
+4
View File
@@ -64,6 +64,10 @@ pub struct UsageEventData {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_ephemeral_5m_input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_ephemeral_1h_input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_cost_usd: Option<f64>,
+2 -1
View File
@@ -41,5 +41,6 @@ pub use write::{
build_pending_usage_record, build_stream_terminal_usage_event,
build_stream_terminal_usage_outcome, build_streaming_usage_record,
build_sync_terminal_usage_event, build_sync_terminal_usage_outcome,
build_terminal_usage_event_from_outcome, TerminalUsageOutcome, UsageTerminalState,
build_terminal_usage_event_from_outcome, build_usage_event_data_seed, TerminalUsageOutcome,
UsageTerminalState,
};
@@ -36,6 +36,8 @@ pub fn build_upsert_usage_record_from_event(
output_tokens: data.output_tokens,
total_tokens: data.total_tokens,
cache_creation_input_tokens: data.cache_creation_input_tokens,
cache_creation_ephemeral_5m_input_tokens: data.cache_creation_ephemeral_5m_input_tokens,
cache_creation_ephemeral_1h_input_tokens: data.cache_creation_ephemeral_1h_input_tokens,
cache_read_input_tokens: data.cache_read_input_tokens,
cache_creation_cost_usd: data.cache_creation_cost_usd,
cache_read_cost_usd: data.cache_read_cost_usd,
@@ -238,6 +238,25 @@ impl UsageRuntime {
}
}
pub async fn record_terminal_event<T>(&self, data: &T, mut event: UsageEvent)
where
T: UsageRuntimeAccess,
{
if !self.is_enabled() {
return;
}
if let Err(err) = data.enrich_usage_event(&mut event).await {
warn!(
event_name = "usage_terminal_billing_enrichment_failed",
log_type = "event",
request_id = %event.request_id,
error = %err,
"usage runtime failed to enrich terminal usage event with billing"
);
}
self.enqueue_or_write_terminal(data, event).await;
}
async fn enqueue_or_write_terminal<T>(&self, data: &T, event: UsageEvent)
where
T: UsageRuntimeAccess,
@@ -5,6 +5,8 @@ pub struct StandardizedUsage {
pub input_tokens: i64,
pub output_tokens: i64,
pub cache_creation_tokens: i64,
pub cache_creation_ephemeral_5m_tokens: i64,
pub cache_creation_ephemeral_1h_tokens: i64,
pub cache_read_tokens: i64,
pub reasoning_tokens: i64,
pub cache_storage_token_hours: f64,
@@ -25,6 +27,12 @@ impl StandardizedUsage {
"input_tokens" => Some(serde_json::json!(self.input_tokens)),
"output_tokens" => Some(serde_json::json!(self.output_tokens)),
"cache_creation_tokens" => Some(serde_json::json!(self.cache_creation_tokens)),
"cache_creation_ephemeral_5m_tokens" => {
Some(serde_json::json!(self.cache_creation_ephemeral_5m_tokens))
}
"cache_creation_ephemeral_1h_tokens" => {
Some(serde_json::json!(self.cache_creation_ephemeral_1h_tokens))
}
"cache_read_tokens" => Some(serde_json::json!(self.cache_read_tokens)),
"reasoning_tokens" => Some(serde_json::json!(self.reasoning_tokens)),
"cache_storage_token_hours" => Some(serde_json::json!(self.cache_storage_token_hours)),
@@ -40,6 +48,12 @@ impl StandardizedUsage {
"input_tokens" => self.input_tokens = as_i64(&value, 0),
"output_tokens" => self.output_tokens = as_i64(&value, 0),
"cache_creation_tokens" => self.cache_creation_tokens = as_i64(&value, 0),
"cache_creation_ephemeral_5m_tokens" => {
self.cache_creation_ephemeral_5m_tokens = as_i64(&value, 0)
}
"cache_creation_ephemeral_1h_tokens" => {
self.cache_creation_ephemeral_1h_tokens = as_i64(&value, 0)
}
"cache_read_tokens" => self.cache_read_tokens = as_i64(&value, 0),
"reasoning_tokens" => self.reasoning_tokens = as_i64(&value, 0),
"cache_storage_token_hours" => self.cache_storage_token_hours = as_f64(&value, 0.0),
@@ -55,6 +69,18 @@ impl StandardizedUsage {
}
}
}
pub fn normalize_cache_creation_breakdown(mut self) -> Self {
if self.cache_creation_tokens <= 0 {
let derived = self
.cache_creation_ephemeral_5m_tokens
.saturating_add(self.cache_creation_ephemeral_1h_tokens);
if derived > 0 {
self.cache_creation_tokens = derived;
}
}
self
}
}
fn as_i64(value: &serde_json::Value, default: i64) -> i64 {
+181 -14
View File
@@ -26,23 +26,13 @@ impl UsageMapper {
}
}
usage
usage.normalize_cache_creation_breakdown()
}
pub fn map_from_response(response: &serde_json::Value, api_format: &str) -> StandardizedUsage {
let family = api_family(api_format);
let usage_value = if family == "gemini" {
response
.get("usageMetadata")
.or_else(|| {
response
.get("candidates")
.and_then(|v| v.get(0))
.and_then(|v| v.get("usageMetadata"))
})
.unwrap_or(&serde_json::Value::Null)
} else {
response.get("usage").unwrap_or(&serde_json::Value::Null)
let Some(usage_value) = resolve_usage_value(response, family.as_str()) else {
return StandardizedUsage::new();
};
Self::map(usage_value, api_format, None)
}
@@ -74,14 +64,32 @@ fn base_mapping(api_format: &str) -> BTreeMap<String, String> {
"openai" => {
mapping.insert("prompt_tokens".to_string(), "input_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("output_tokens".to_string(), "output_tokens".to_string());
mapping.insert(
"prompt_tokens_details.cached_tokens".to_string(),
"cache_read_tokens".to_string(),
);
mapping.insert(
"input_tokens_details.cached_tokens".to_string(),
"cache_read_tokens".to_string(),
);
mapping.insert(
"prompt_tokens_details.cached_creation_tokens".to_string(),
"cache_creation_tokens".to_string(),
);
mapping.insert(
"input_tokens_details.cached_creation_tokens".to_string(),
"cache_creation_tokens".to_string(),
);
mapping.insert(
"completion_tokens_details.reasoning_tokens".to_string(),
"reasoning_tokens".to_string(),
);
mapping.insert(
"output_tokens_details.reasoning_tokens".to_string(),
"reasoning_tokens".to_string(),
);
}
"gemini" => {
mapping.insert("promptTokenCount".to_string(), "input_tokens".to_string());
@@ -106,6 +114,26 @@ fn base_mapping(api_format: &str) -> BTreeMap<String, String> {
"cache_read_tokens".to_string(),
);
}
"claude" | "anthropic" => {
mapping.insert("input_tokens".to_string(), "input_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("input_tokens".to_string(), "input_tokens".to_string());
mapping.insert("output_tokens".to_string(), "output_tokens".to_string());
@@ -130,6 +158,47 @@ fn get_nested_value<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a
Some(current)
}
fn resolve_usage_value<'a>(
response: &'a serde_json::Value,
family: &str,
) -> Option<&'a serde_json::Value> {
match family {
"gemini" => {
if let Some(usage) = response.get("usageMetadata") {
return Some(usage);
}
if let Some(usage) = response
.get("candidates")
.and_then(|value| value.get(0))
.and_then(|value| value.get("usageMetadata"))
{
return Some(usage);
}
}
_ => {
if let Some(usage) = response.get("usage") {
return Some(usage);
}
}
}
if let Some(nested) = response.get("response") {
if let Some(usage) = resolve_usage_value(nested, family) {
return Some(usage);
}
}
if let Some(chunks) = response.get("chunks").and_then(serde_json::Value::as_array) {
for chunk in chunks.iter().rev() {
if let Some(usage) = resolve_usage_value(chunk, family) {
return Some(usage);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::{map_usage, map_usage_from_response};
@@ -140,7 +209,10 @@ mod tests {
&serde_json::json!({
"prompt_tokens": 12,
"completion_tokens": 8,
"prompt_tokens_details": { "cached_tokens": 2 },
"prompt_tokens_details": {
"cached_tokens": 2,
"cached_creation_tokens": 1
},
"completion_tokens_details": { "reasoning_tokens": 3 }
}),
"openai:chat",
@@ -148,10 +220,81 @@ mod tests {
assert_eq!(usage.input_tokens, 12);
assert_eq!(usage.output_tokens, 8);
assert_eq!(usage.cache_creation_tokens, 1);
assert_eq!(usage.cache_read_tokens, 2);
assert_eq!(usage.reasoning_tokens, 3);
}
#[test]
fn maps_openai_responses_usage_from_response() {
let usage = map_usage_from_response(
&serde_json::json!({
"usage": {
"input_tokens": 14,
"output_tokens": 6,
"total_tokens": 20,
"input_tokens_details": {
"cached_tokens": 3,
"cached_creation_tokens": 2
},
"output_tokens_details": {
"reasoning_tokens": 1
}
}
}),
"openai:cli",
);
assert_eq!(usage.input_tokens, 14);
assert_eq!(usage.output_tokens, 6);
assert_eq!(usage.cache_creation_tokens, 2);
assert_eq!(usage.cache_read_tokens, 3);
assert_eq!(usage.reasoning_tokens, 1);
}
#[test]
fn maps_openai_responses_usage_from_stream_chunks() {
let usage = map_usage_from_response(
&serde_json::json!({
"chunks": [
{
"type": "response.created",
"response": {
"id": "resp_123",
"object": "response"
}
},
{
"type": "response.completed",
"response": {
"id": "resp_123",
"object": "response",
"usage": {
"input_tokens": 9,
"output_tokens": 4,
"total_tokens": 13,
"input_tokens_details": {
"cached_tokens": 5,
"cached_creation_tokens": 2
},
"output_tokens_details": {
"reasoning_tokens": 1
}
}
}
}
]
}),
"openai:cli",
);
assert_eq!(usage.input_tokens, 9);
assert_eq!(usage.output_tokens, 4);
assert_eq!(usage.cache_creation_tokens, 2);
assert_eq!(usage.cache_read_tokens, 5);
assert_eq!(usage.reasoning_tokens, 1);
}
#[test]
fn maps_claude_usage() {
let usage = map_usage(
@@ -170,6 +313,30 @@ mod tests {
assert_eq!(usage.cache_read_tokens, 1);
}
#[test]
fn maps_claude_usage_with_ephemeral_cache_breakdown() {
let usage = map_usage(
&serde_json::json!({
"input_tokens": 1,
"output_tokens": 8,
"cache_creation": {
"ephemeral_1h_input_tokens": 0,
"ephemeral_5m_input_tokens": 5191
},
"cache_creation_input_tokens": 5191,
"cache_read_input_tokens": 97634
}),
"claude:chat",
);
assert_eq!(usage.input_tokens, 1);
assert_eq!(usage.output_tokens, 8);
assert_eq!(usage.cache_creation_tokens, 5191);
assert_eq!(usage.cache_creation_ephemeral_5m_tokens, 5191);
assert_eq!(usage.cache_creation_ephemeral_1h_tokens, 0);
assert_eq!(usage.cache_read_tokens, 97634);
}
#[test]
fn maps_gemini_usage_from_response() {
let usage = map_usage_from_response(
+34 -2
View File
@@ -409,6 +409,8 @@ fn build_upsert_usage_record(
output_tokens: data.output_tokens,
total_tokens: data.total_tokens,
cache_creation_input_tokens: data.cache_creation_input_tokens,
cache_creation_ephemeral_5m_input_tokens: data.cache_creation_ephemeral_5m_input_tokens,
cache_creation_ephemeral_1h_input_tokens: data.cache_creation_ephemeral_1h_input_tokens,
cache_read_input_tokens: data.cache_read_input_tokens,
cache_creation_cost_usd: data.cache_creation_cost_usd,
cache_read_cost_usd: data.cache_read_cost_usd,
@@ -437,7 +439,10 @@ fn build_upsert_usage_record(
})
}
fn build_base_usage_data(plan: &ExecutionPlan, report_context: Option<&Value>) -> UsageEventData {
pub fn build_usage_event_data_seed(
plan: &ExecutionPlan,
report_context: Option<&Value>,
) -> UsageEventData {
let context = report_context.and_then(Value::as_object);
let api_format = context_string(context, "client_api_format")
.or_else(|| non_empty_string(Some(plan.client_api_format.clone())));
@@ -497,6 +502,10 @@ fn build_base_usage_data(plan: &ExecutionPlan, report_context: Option<&Value>) -
}
}
fn build_base_usage_data(plan: &ExecutionPlan, report_context: Option<&Value>) -> UsageEventData {
build_usage_event_data_seed(plan, report_context)
}
fn merge_usage_data(base: UsageEventData, override_data: UsageEventData) -> UsageEventData {
UsageEventData {
user_id: override_data.user_id.or(base.user_id),
@@ -544,6 +553,12 @@ fn merge_usage_data(base: UsageEventData, override_data: UsageEventData) -> Usag
cache_creation_input_tokens: override_data
.cache_creation_input_tokens
.or(base.cache_creation_input_tokens),
cache_creation_ephemeral_5m_input_tokens: override_data
.cache_creation_ephemeral_5m_input_tokens
.or(base.cache_creation_ephemeral_5m_input_tokens),
cache_creation_ephemeral_1h_input_tokens: override_data
.cache_creation_ephemeral_1h_input_tokens
.or(base.cache_creation_ephemeral_1h_input_tokens),
cache_read_input_tokens: override_data
.cache_read_input_tokens
.or(base.cache_read_input_tokens),
@@ -668,6 +683,14 @@ fn apply_standardized_usage(
if usage.cache_creation_tokens > 0 {
data.cache_creation_input_tokens = Some(usage.cache_creation_tokens as u64);
}
if usage.cache_creation_ephemeral_5m_tokens > 0 {
data.cache_creation_ephemeral_5m_input_tokens =
Some(usage.cache_creation_ephemeral_5m_tokens as u64);
}
if usage.cache_creation_ephemeral_1h_tokens > 0 {
data.cache_creation_ephemeral_1h_input_tokens =
Some(usage.cache_creation_ephemeral_1h_tokens as u64);
}
if usage.cache_read_tokens > 0 {
data.cache_read_input_tokens = Some(usage.cache_read_tokens as u64);
}
@@ -1167,7 +1190,7 @@ mod tests {
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hello from CLI stream\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello from CLI stream\"}]}],\"usage\":{\"input_tokens\":3,\"output_tokens\":5,\"total_tokens\":8}}}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello from CLI stream\"}]}],\"usage\":{\"input_tokens\":3,\"input_tokens_details\":{\"cached_tokens\":2,\"cached_creation_tokens\":1},\"output_tokens\":5,\"output_tokens_details\":{\"reasoning_tokens\":1},\"total_tokens\":8}}}\n\n",
"data: [DONE]\n",
);
let payload = GatewayStreamReportRequest {
@@ -1191,6 +1214,8 @@ mod tests {
assert_eq!(event.data.input_tokens, Some(3));
assert_eq!(event.data.output_tokens, Some(5));
assert_eq!(event.data.total_tokens, Some(8));
assert_eq!(event.data.cache_creation_input_tokens, Some(1));
assert_eq!(event.data.cache_read_input_tokens, Some(2));
assert_eq!(
event.data.response_body,
Some(json!({
@@ -1229,7 +1254,14 @@ mod tests {
],
"usage": {
"input_tokens": 3,
"input_tokens_details": {
"cached_tokens": 2,
"cached_creation_tokens": 1
},
"output_tokens": 5,
"output_tokens_details": {
"reasoning_tokens": 1
},
"total_tokens": 8
}
}