fix(usage): account for Anthropic cache write TTLs

Parse and retain Anthropic's ephemeral 5-minute and 1-hour cache-creation token buckets while preserving the existing aggregate cache-write metric for compatibility.

Price 1-hour writes at the documented premium relative to the configured 5-minute write rate, clamp inconsistent provider details safely, and include TTL buckets in usage diagnostics.

Persist 1-hour cache-write tokens with schema version 14 so zero-cost backfills and later pricing updates retain the original TTL semantics. Keep session import paths compatible through zero-valued detail fields.
This commit is contained in:
Jason
2026-07-12 12:19:36 +08:00
parent b9263a8040
commit 13e7c1fcc4
11 changed files with 258 additions and 29 deletions
+79 -20
View File
@@ -137,6 +137,9 @@ pub struct RequestLogDetail {
pub output_tokens: u32,
pub cache_read_tokens: u32,
pub cache_creation_tokens: u32,
/// Internal TTL pricing bucket; aggregate UI metrics remain unchanged.
#[serde(skip)]
pub cache_creation_1h_tokens: u32,
/// Internal storage semantics; omitted from the UI/API payload.
#[serde(skip)]
pub input_token_semantics: i64,
@@ -159,12 +162,12 @@ pub struct RequestLogDetail {
pub pricing_model: Option<String>,
}
/// 把 26 列的查询结果映射为 `RequestLogDetail`。
/// 把 27 列的查询结果映射为 `RequestLogDetail`。
///
/// 调用方的 SELECT **必须**按以下顺序返回 26 列:
/// 调用方的 SELECT **必须**按以下顺序返回 27 列:
/// `request_id, provider_id, provider_name, app_type, model, request_model,
/// cost_multiplier, input_tokens, output_tokens, cache_read_tokens,
/// cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd,
/// cache_creation_tokens, cache_creation_1h_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd,
/// cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms,
/// first_token_ms, duration_ms, status_code, error_message, created_at,
/// data_source, pricing_model, input_token_semantics`
@@ -185,21 +188,22 @@ fn row_to_request_log_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result<Reques
output_tokens: row.get::<_, i64>(8)? as u32,
cache_read_tokens: row.get::<_, i64>(9)? as u32,
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
input_cost_usd: row.get(11)?,
output_cost_usd: row.get(12)?,
cache_read_cost_usd: row.get(13)?,
cache_creation_cost_usd: row.get(14)?,
total_cost_usd: row.get(15)?,
is_streaming: row.get::<_, i64>(16)? != 0,
latency_ms: row.get::<_, i64>(17)? as u64,
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
status_code: row.get::<_, i64>(20)? as u16,
error_message: row.get(21)?,
created_at: row.get(22)?,
data_source: row.get(23)?,
pricing_model: row.get(24)?,
input_token_semantics: row.get::<_, i64>(25)?,
cache_creation_1h_tokens: row.get::<_, i64>(11)? as u32,
input_cost_usd: row.get(12)?,
output_cost_usd: row.get(13)?,
cache_read_cost_usd: row.get(14)?,
cache_creation_cost_usd: row.get(15)?,
total_cost_usd: row.get(16)?,
is_streaming: row.get::<_, i64>(17)? != 0,
latency_ms: row.get::<_, i64>(18)? as u64,
first_token_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(20)?.map(|v| v as u64),
status_code: row.get::<_, i64>(21)? as u16,
error_message: row.get(22)?,
created_at: row.get(23)?,
data_source: row.get(24)?,
pricing_model: row.get(25)?,
input_token_semantics: row.get::<_, i64>(26)?,
})
}
@@ -1530,6 +1534,7 @@ impl Database {
"SELECT l.request_id, l.provider_id, {logs_pname} as provider_name, l.app_type, l.model,
l.request_model, l.cost_multiplier,
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
l.cache_creation_1h_tokens,
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
l.status_code, l.error_message, l.created_at, l.data_source, l.pricing_model,
@@ -1574,6 +1579,7 @@ impl Database {
"SELECT l.request_id, l.provider_id, {detail_pname} as provider_name, l.app_type, l.model,
l.request_model, l.cost_multiplier,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
cache_creation_1h_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
is_streaming, latency_ms, first_token_ms, duration_ms,
status_code, error_message, created_at, l.data_source, l.pricing_model,
@@ -1730,6 +1736,7 @@ impl Database {
"SELECT request_id, provider_id, NULL AS provider_name, app_type, model, request_model,
cost_multiplier,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
cache_creation_1h_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd,
cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms,
first_token_ms, duration_ms, status_code, error_message, created_at,
@@ -1836,9 +1843,17 @@ impl Database {
let cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64)
* pricing.cache_read
/ million;
let cache_creation_cost = rust_decimal::Decimal::from(log.cache_creation_tokens as u64)
let cache_creation_1h_tokens =
log.cache_creation_1h_tokens.min(log.cache_creation_tokens) as u64;
let cache_creation_standard_tokens =
(log.cache_creation_tokens as u64).saturating_sub(cache_creation_1h_tokens);
let cache_creation_1h_price = pricing.cache_creation * rust_decimal::Decimal::from(8u32)
/ rust_decimal::Decimal::from(5u32);
let cache_creation_cost = rust_decimal::Decimal::from(cache_creation_standard_tokens)
* pricing.cache_creation
/ million;
/ million
+ rust_decimal::Decimal::from(cache_creation_1h_tokens) * cache_creation_1h_price
/ million;
// 总成本 = 基础成本之和 × 倍率
let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;
let total_cost = base_total * multiplier;
@@ -2868,6 +2883,50 @@ mod tests {
Ok(())
}
#[test]
fn test_backfill_preserves_one_hour_cache_write_pricing() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
insert_usage_log(
&conn,
"claude-cache-one-hour",
"claude",
"p1",
"claude-haiku-4-5",
"proxy",
1000,
0,
0,
0,
1_000_000,
200,
"0",
)?;
conn.execute(
"UPDATE proxy_request_logs
SET cache_creation_1h_tokens = 1000000
WHERE request_id = 'claude-cache-one-hour'",
[],
)?;
}
assert_eq!(db.backfill_missing_usage_costs()?, 1);
let conn = lock_conn!(db.conn);
let (cache_creation_cost, total_cost): (String, String) = conn.query_row(
"SELECT cache_creation_cost_usd, total_cost_usd
FROM proxy_request_logs WHERE request_id = 'claude-cache-one-hour'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(cache_creation_cost, "2.000000");
assert_eq!(total_cost, "2.000000");
Ok(())
}
#[test]
fn test_get_usage_summary() -> Result<(), AppError> {
let db = Database::memory()?;