mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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;
|
||||||
|
|||||||
1266
apps/aether-gateway/src/tests/usage/pricing.rs
Normal file
1266
apps/aether-gateway/src/tests/usage/pricing.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user