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
+8 -5
View File
@@ -76,10 +76,13 @@ impl CostCalculator {
) -> CostBreakdown {
let million = Decimal::from(1_000_000);
// OpenAI/Gemini 风格的 input_tokens 包含缓存命中,需要扣除后再按输入价计费;
// OpenAI/Gemini 风格的 input_tokens 包含缓存读取和写入,需要扣除后再按输入价计费;
// Claude/Anthropic 风格的 input_tokens 已经是 fresh input,不能再次扣减。
let billable_input_tokens = if input_includes_cache_read {
usage.input_tokens.saturating_sub(usage.cache_read_tokens)
usage
.input_tokens
.saturating_sub(usage.cache_read_tokens)
.saturating_sub(usage.cache_creation_tokens)
} else {
usage.input_tokens
};
@@ -197,15 +200,15 @@ mod tests {
let cost = CostCalculator::calculate_for_app("codex", &usage, &pricing, multiplier);
// Codex/OpenAI 语义:input_tokens 包含 cached_tokens,需要扣除 cache_read_tokens
assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap());
// Codex/OpenAI 语义:input_tokens 包含 cache read/write,两桶都需扣除。
assert_eq!(cost.input_cost, Decimal::from_str("0.0021").unwrap());
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.00006").unwrap());
assert_eq!(
cost.cache_creation_cost,
Decimal::from_str("0.000375").unwrap()
);
assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap());
assert_eq!(cost.total_cost, Decimal::from_str("0.010035").unwrap());
}
#[test]
+9 -1
View File
@@ -4,6 +4,7 @@ use super::calculator::{CostBreakdown, CostCalculator, ModelPricing};
use super::parser::TokenUsage;
use crate::database::{Database, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE};
use crate::error::AppError;
use crate::services::sql_helpers::{INPUT_TOKEN_SEMANTICS_FRESH, INPUT_TOKEN_SEMANTICS_TOTAL};
use crate::services::usage_stats::{find_model_pricing_row, is_placeholder_pricing_model};
use rust_decimal::Decimal;
use std::str::FromStr;
@@ -70,15 +71,21 @@ impl<'a> UsageLogger<'a> {
};
let created_at = chrono::Utc::now().timestamp();
let input_token_semantics = if matches!(log.app_type.as_str(), "codex" | "gemini") {
INPUT_TOKEN_SEMANTICS_TOTAL
} else {
INPUT_TOKEN_SEMANTICS_FRESH
};
conn.execute(
"INSERT OR REPLACE INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model, pricing_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_token_semantics,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
latency_ms, first_token_ms, status_code, error_message, session_id,
provider_type, is_streaming, cost_multiplier, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)",
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)",
rusqlite::params![
log.request_id,
log.provider_id,
@@ -90,6 +97,7 @@ impl<'a> UsageLogger<'a> {
log.usage.output_tokens,
log.usage.cache_read_tokens,
log.usage.cache_creation_tokens,
input_token_semantics,
input_cost,
output_cost,
cache_read_cost,
+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!({