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
+1 -1
View File
@@ -50,7 +50,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 13;
pub(crate) const SCHEMA_VERSION: i32 = 14;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+28 -1
View File
@@ -189,6 +189,7 @@ impl Database {
pricing_model TEXT,
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_1h_tokens INTEGER NOT NULL DEFAULT 0,
input_token_semantics INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
@@ -485,6 +486,11 @@ impl Database {
Self::migrate_v12_to_v13(conn)?;
Self::set_user_version(conn, 13)?;
}
13 => {
log::info!("迁移数据库从 v13 到 v14(记录 1 小时缓存写入 token)");
Self::migrate_v13_to_v14(conn)?;
Self::set_user_version(conn, 14)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -1354,6 +1360,22 @@ impl Database {
Ok(())
}
/// v13 -> v14:持久化 Anthropic 1-hour cache-write bucket。
/// 5-minute/unknown writes can be derived from aggregate cache_creation_tokens;
/// storing only the premium bucket keeps existing aggregate APIs stable while
/// allowing later cost backfills to retain correct TTL pricing.
fn migrate_v13_to_v14(conn: &Connection) -> Result<(), AppError> {
if Self::table_exists(conn, "proxy_request_logs")? {
Self::add_column_if_missing(
conn,
"proxy_request_logs",
"cache_creation_1h_tokens",
"INTEGER NOT NULL DEFAULT 0",
)?;
}
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
@@ -2713,7 +2735,7 @@ mod tests {
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, 13);
assert_eq!(Database::get_user_version(&conn)?, 14);
assert!(Database::has_column(
&conn,
"proxy_request_logs",
@@ -2724,6 +2746,11 @@ mod tests {
"usage_daily_rollups",
"input_token_semantics"
)?);
assert!(Database::has_column(
&conn,
"proxy_request_logs",
"cache_creation_1h_tokens"
)?);
let log_default: i64 = conn.query_row(
"SELECT dflt_value = '0' FROM pragma_table_info('proxy_request_logs')
WHERE name = 'input_token_semantics'",
+10 -2
View File
@@ -645,12 +645,14 @@ async fn log_usage_internal(
let request_id = usage.dedup_request_id();
log::debug!(
"[{app_type}] 记录请求日志: id={request_id}, provider={provider_id}, model={model}, streaming={is_streaming}, status={status_code}, latency_ms={latency_ms}, first_token_ms={first_token_ms:?}, session={}, input={}, output={}, cache_read={}, cache_creation={}",
"[{app_type}] 记录请求日志: id={request_id}, provider={provider_id}, model={model}, streaming={is_streaming}, status={status_code}, latency_ms={latency_ms}, first_token_ms={first_token_ms:?}, session={}, input={}, output={}, cache_read={}, cache_creation={}, cache_creation_5m={}, cache_creation_1h={}",
session_id.as_deref().unwrap_or("none"),
usage.input_tokens,
usage.output_tokens,
usage.cache_read_tokens,
usage.cache_creation_tokens
usage.cache_creation_tokens,
usage.cache_creation_5m_tokens,
usage.cache_creation_1h_tokens
);
if let Err(e) = logger.log_with_calculation(
@@ -1004,6 +1006,8 @@ mod tests {
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: None,
message_id: None,
};
@@ -1072,6 +1076,8 @@ mod tests {
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: None,
message_id: None,
};
@@ -1154,6 +1160,8 @@ mod tests {
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: None,
message_id: None,
};
+43 -3
View File
@@ -94,9 +94,20 @@ impl CostCalculator {
Decimal::from(usage.output_tokens) * pricing.output_cost_per_million / million;
let cache_read_cost =
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million;
let cache_creation_cost = Decimal::from(usage.cache_creation_tokens)
* pricing.cache_creation_cost_per_million
/ million;
let (cache_creation_unspecified, cache_creation_5m, cache_creation_1h) =
usage.normalized_cache_creation_buckets();
let cache_creation_5m_price = pricing.cache_creation_cost_per_million;
// The stored cache-creation price is the existing 5-minute rate. Anthropic
// prices 5m writes at 1.25x input and 1h writes at 2x input, therefore the
// corresponding 1h price is 8/5 of the configured 5m price. Unknown providers
// without TTL details retain the configured legacy rate.
let cache_creation_1h_price =
cache_creation_5m_price * Decimal::from(8u32) / Decimal::from(5u32);
let cache_creation_cost = (Decimal::from(cache_creation_unspecified)
+ Decimal::from(cache_creation_5m))
* cache_creation_5m_price
/ million
+ Decimal::from(cache_creation_1h) * cache_creation_1h_price / million;
// 总成本 = 各项基础成本之和 × 倍率
let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;
@@ -159,6 +170,8 @@ mod tests {
output_tokens: 500,
cache_read_tokens: 200,
cache_creation_tokens: 100,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: None,
message_id: None,
};
@@ -191,6 +204,8 @@ mod tests {
output_tokens: 500,
cache_read_tokens: 200,
cache_creation_tokens: 100,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: None,
message_id: None,
};
@@ -211,6 +226,25 @@ mod tests {
assert_eq!(cost.total_cost, Decimal::from_str("0.010035").unwrap());
}
#[test]
fn test_one_hour_cache_creation_uses_premium_rate() {
let usage = TokenUsage {
cache_creation_tokens: 1_000_000,
cache_creation_5m_tokens: 250_000,
cache_creation_1h_tokens: 750_000,
..TokenUsage::default()
};
let pricing = ModelPricing::from_strings("3", "15", "0.3", "3.75").unwrap();
let cost = CostCalculator::calculate(&usage, &pricing, Decimal::ONE);
// 250k × $3.75/M + 750k × $6/M = $5.4375.
assert_eq!(
cost.cache_creation_cost,
Decimal::from_str("5.4375").unwrap()
);
}
#[test]
fn test_cost_multiplier() {
let usage = TokenUsage {
@@ -218,6 +252,8 @@ mod tests {
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: None,
message_id: None,
};
@@ -240,6 +276,8 @@ mod tests {
output_tokens: 500,
cache_read_tokens: 0,
cache_creation_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: None,
message_id: None,
};
@@ -257,6 +295,8 @@ mod tests {
output_tokens: 1,
cache_read_tokens: 1,
cache_creation_tokens: 1,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: None,
message_id: None,
};
+5 -1
View File
@@ -81,11 +81,12 @@ impl<'a> UsageLogger<'a> {
"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,
cache_creation_1h_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, ?25)",
) 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, ?26)",
rusqlite::params![
log.request_id,
log.provider_id,
@@ -97,6 +98,7 @@ impl<'a> UsageLogger<'a> {
log.usage.output_tokens,
log.usage.cache_read_tokens,
log.usage.cache_creation_tokens,
log.usage.cache_creation_1h_tokens,
input_token_semantics,
input_cost,
output_cost,
@@ -396,6 +398,8 @@ mod tests {
output_tokens: 500,
cache_read_tokens: 0,
cache_creation_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: None,
message_id: None,
};
+84 -1
View File
@@ -27,6 +27,22 @@ fn openai_cache_write_tokens(usage: &Value) -> u32 {
.unwrap_or(0) as u32
}
fn cache_creation_ttl_tokens(usage: &Value) -> (u32, u32) {
let details = usage
.get("cache_creation")
.or_else(|| usage.pointer("/input_tokens_details/cache_creation"))
.or_else(|| usage.pointer("/prompt_tokens_details/cache_creation"));
let five_minutes = details
.and_then(|value| value.get("ephemeral_5m_input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0) as u32;
let one_hour = details
.and_then(|value| value.get("ephemeral_1h_input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0) as u32;
(five_minutes, one_hour)
}
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
@@ -37,6 +53,13 @@ pub struct TokenUsage {
pub output_tokens: u32,
pub cache_read_tokens: u32,
pub cache_creation_tokens: u32,
/// Anthropic cache-write TTL detail. The aggregate above remains the stable
/// public/storage metric; these buckets make 1-hour writes billable at 2x input
/// instead of the 5-minute 1.25x rate.
#[serde(default)]
pub cache_creation_5m_tokens: u32,
#[serde(default)]
pub cache_creation_1h_tokens: u32,
/// 从响应中提取的实际模型名称(如果可用)
pub model: Option<String>,
/// 从响应中提取的消息 ID(用于跨源去重)
@@ -47,6 +70,23 @@ pub struct TokenUsage {
}
impl TokenUsage {
/// Return mutually exclusive cache-write buckets, clamped to the aggregate.
/// Providers that do not expose TTL detail remain in `unspecified` and keep the
/// legacy cache-creation price.
pub fn normalized_cache_creation_buckets(&self) -> (u32, u32, u32) {
let one_hour = self
.cache_creation_1h_tokens
.min(self.cache_creation_tokens);
let five_minutes = self
.cache_creation_5m_tokens
.min(self.cache_creation_tokens.saturating_sub(one_hour));
let unspecified = self
.cache_creation_tokens
.saturating_sub(one_hour)
.saturating_sub(five_minutes);
(unspecified, five_minutes, one_hour)
}
/// 生成与 session 日志共享的 request_id,用于跨源去重。
/// 有 message_id 时返回 `session:{id}`,否则回退到随机 UUID。
pub fn dedup_request_id(&self) -> String {
@@ -83,6 +123,7 @@ impl TokenUsage {
/// 从 Claude API 非流式响应解析
pub fn from_claude_response(body: &Value) -> Option<Self> {
let usage = body.get("usage")?;
let (cache_creation_5m_tokens, cache_creation_1h_tokens) = cache_creation_ttl_tokens(usage);
// 提取响应中的模型名称
let model = body
.get("model")
@@ -104,6 +145,8 @@ impl TokenUsage {
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_5m_tokens,
cache_creation_1h_tokens,
model,
message_id,
})
@@ -134,6 +177,8 @@ impl TokenUsage {
}
}
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
let (cache_creation_5m, cache_creation_1h) =
cache_creation_ttl_tokens(msg_usage);
// 从 message_start 获取 input_tokens(原生 Claude API
if let Some(input) =
msg_usage.get("input_tokens").and_then(|v| v.as_u64())
@@ -150,10 +195,14 @@ impl TokenUsage {
.and_then(|v| v.as_u64())
.unwrap_or(0)
as u32;
usage.cache_creation_5m_tokens = cache_creation_5m;
usage.cache_creation_1h_tokens = cache_creation_1h;
}
}
"message_delta" => {
if let Some(delta_usage) = event.get("usage") {
let (delta_cache_creation_5m, delta_cache_creation_1h) =
cache_creation_ttl_tokens(delta_usage);
// 从 message_delta 获取 output_tokens
if let Some(output) =
delta_usage.get("output_tokens").and_then(|v| v.as_u64())
@@ -192,6 +241,14 @@ impl TokenUsage {
}
if let Some(cache_creation) = delta_cache_creation {
usage.cache_creation_tokens = cache_creation;
if delta_cache_creation_5m > 0
|| delta_cache_creation_1h > 0
{
usage.cache_creation_5m_tokens =
delta_cache_creation_5m;
usage.cache_creation_1h_tokens =
delta_cache_creation_1h;
}
}
}
}
@@ -206,6 +263,10 @@ impl TokenUsage {
if usage.cache_creation_tokens == 0 {
if let Some(cache_creation) = delta_cache_creation {
usage.cache_creation_tokens = cache_creation;
if delta_cache_creation_5m > 0 || delta_cache_creation_1h > 0 {
usage.cache_creation_5m_tokens = delta_cache_creation_5m;
usage.cache_creation_1h_tokens = delta_cache_creation_1h;
}
}
}
}
@@ -237,6 +298,8 @@ impl TokenUsage {
output_tokens: usage.get("completion_tokens")?.as_u64()? as u32,
cache_read_tokens: 0,
cache_creation_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: None,
message_id: None,
})
@@ -270,12 +333,15 @@ impl TokenUsage {
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
let (cache_creation_5m_tokens, cache_creation_1h_tokens) = cache_creation_ttl_tokens(usage);
Some(Self {
input_tokens: input_tokens? as u32,
output_tokens: output_tokens? as u32,
cache_read_tokens: cached_tokens,
cache_creation_tokens: cache_write_tokens,
cache_creation_5m_tokens,
cache_creation_1h_tokens,
model,
message_id: None,
})
@@ -294,6 +360,7 @@ impl TokenUsage {
// 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中)
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
let (cache_creation_5m_tokens, cache_creation_1h_tokens) = cache_creation_ttl_tokens(usage);
// 调整 input_tokens: OpenAI total input 同时包含 cache read/write 两桶。
let adjusted_input = input_tokens
@@ -311,6 +378,8 @@ impl TokenUsage {
output_tokens,
cache_read_tokens: cached_tokens,
cache_creation_tokens: cache_write_tokens,
cache_creation_5m_tokens,
cache_creation_1h_tokens,
model,
message_id: None,
})
@@ -391,6 +460,7 @@ impl TokenUsage {
// 获取 cached_tokens (可能在 prompt_tokens_details 中)
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
let (cache_creation_5m_tokens, cache_creation_1h_tokens) = cache_creation_ttl_tokens(usage);
// 提取响应中的模型名称
let model = body
@@ -403,6 +473,8 @@ impl TokenUsage {
output_tokens: completion_tokens as u32,
cache_read_tokens: cached_tokens,
cache_creation_tokens: cache_write_tokens,
cache_creation_5m_tokens,
cache_creation_1h_tokens,
model,
message_id: None,
})
@@ -448,6 +520,8 @@ impl TokenUsage {
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model,
message_id: None,
})
@@ -499,6 +573,8 @@ impl TokenUsage {
output_tokens: total_output,
cache_read_tokens: total_cache_read,
cache_creation_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model,
message_id: None,
})
@@ -521,7 +597,11 @@ mod tests {
"input_tokens": 100,
"output_tokens": 50,
"cache_read_input_tokens": 20,
"cache_creation_input_tokens": 10
"cache_creation_input_tokens": 10,
"cache_creation": {
"ephemeral_5m_input_tokens": 4,
"ephemeral_1h_input_tokens": 6
}
}
});
@@ -530,6 +610,9 @@ mod tests {
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 10);
assert_eq!(usage.cache_creation_5m_tokens, 4);
assert_eq!(usage.cache_creation_1h_tokens, 6);
assert_eq!(usage.normalized_cache_creation_buckets(), (0, 4, 6));
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
}
+2
View File
@@ -448,6 +448,8 @@ fn insert_session_log_entry(
output_tokens: msg.output_tokens,
cache_read_tokens: msg.cache_read_tokens,
cache_creation_tokens: msg.cache_creation_tokens,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: Some(msg.model.clone()),
message_id: None,
};
@@ -634,6 +634,8 @@ fn insert_codex_session_entry(
output_tokens: delta.output,
cache_read_tokens: delta.cached_input,
cache_creation_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: Some(model.to_string()),
message_id: None,
};
@@ -270,6 +270,8 @@ fn insert_gemini_session_entry(
output_tokens,
cache_read_tokens: tokens.cached,
cache_creation_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: Some(model.to_string()),
message_id: None,
};
@@ -362,6 +362,8 @@ fn insert_opencode_message(
output_tokens: output_with_reasoning,
cache_read_tokens: msg.cache_read_tokens,
cache_creation_tokens: msg.cache_write_tokens,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
model: Some(msg.model_id.clone()),
message_id: None,
};
+78 -19
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,8 +1843,16 @@ 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
+ 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;
@@ -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()?;