fix(usage): account for cache-write tokens across schema versions

Parse cache_write_tokens from OpenAI usage details and preserve cache creation data across Chat, Responses, and Anthropic conversion paths.

Add explicit input-token semantics to request logs and rollups so legacy rows subtract cache reads only while new total-inclusive rows subtract both cache reads and writes. Migrate v12 databases, normalize rollups to fresh input, and cover historical backfill behavior with regression tests.
This commit is contained in:
Jason
2026-07-11 12:27:39 +08:00
parent 06039540ff
commit f991726ff0
13 changed files with 443 additions and 102 deletions
+55 -36
View File
@@ -9,6 +9,24 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
fn openai_cache_read_tokens(usage: &Value) -> u32 {
usage
.get("cache_read_input_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cached_tokens"))
.or_else(|| usage.pointer("/prompt_tokens_details/cached_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0) as u32
}
fn openai_cache_write_tokens(usage: &Value) -> u32 {
usage
.get("cache_creation_input_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cache_write_tokens"))
.or_else(|| usage.pointer("/prompt_tokens_details/cache_write_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0) as u32
}
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
@@ -250,25 +268,14 @@ impl TokenUsage {
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let cached_tokens = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.get("input_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0) as u32;
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
Some(Self {
input_tokens: input_tokens? as u32,
output_tokens: output_tokens? as u32,
cache_read_tokens: cached_tokens,
cache_creation_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
})
@@ -285,19 +292,13 @@ impl TokenUsage {
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
// 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中)
let cached_tokens = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.get("input_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0) as u32;
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
// 调整 input_tokens: 减去 cached_tokens
let adjusted_input = input_tokens.saturating_sub(cached_tokens);
// 调整 input_tokens: OpenAI total input 同时包含 cache read/write 两桶。
let adjusted_input = input_tokens
.saturating_sub(cached_tokens)
.saturating_sub(cache_write_tokens);
// 提取响应中的模型名称
let model = body
@@ -309,10 +310,7 @@ impl TokenUsage {
input_tokens: adjusted_input,
output_tokens,
cache_read_tokens: cached_tokens,
cache_creation_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
})
@@ -391,11 +389,8 @@ impl TokenUsage {
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64())?;
// 获取 cached_tokens (可能在 prompt_tokens_details 中)
let cached_tokens = usage
.get("prompt_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
// 提取响应中的模型名称
let model = body
@@ -407,7 +402,7 @@ impl TokenUsage {
input_tokens: prompt_tokens as u32,
output_tokens: completion_tokens as u32,
cache_read_tokens: cached_tokens,
cache_creation_tokens: 0,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
})
@@ -797,6 +792,30 @@ mod tests {
assert_eq!(usage.cache_read_tokens, 300);
}
#[test]
fn test_codex_response_parsing_cache_write_tokens_in_details() {
let response = json!({
"usage": {
"input_tokens": 1000,
"output_tokens": 500,
"input_tokens_details": {
"cached_tokens": 300,
"cache_write_tokens": 200
}
}
});
let usage = TokenUsage::from_codex_response(&response).unwrap();
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.cache_read_tokens, 300);
assert_eq!(usage.cache_creation_tokens, 200);
let adjusted = TokenUsage::from_codex_response_adjusted(&response).unwrap();
assert_eq!(adjusted.input_tokens, 500);
assert_eq!(adjusted.cache_read_tokens, 300);
assert_eq!(adjusted.cache_creation_tokens, 200);
}
#[test]
fn test_codex_response_adjusted() {
let response = json!({