diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 55bb2ea61..dda8da4a5 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -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(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index d477991e4..63a73f801 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -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'", diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 238e14420..20b4eb039 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -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, }; diff --git a/src-tauri/src/proxy/usage/calculator.rs b/src-tauri/src/proxy/usage/calculator.rs index db8def267..4628a6868 100644 --- a/src-tauri/src/proxy/usage/calculator.rs +++ b/src-tauri/src/proxy/usage/calculator.rs @@ -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, }; diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index 072dd5ccf..985fad40a 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -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, }; diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs index 45120b8ae..c28edf8c5 100644 --- a/src-tauri/src/proxy/usage/parser.rs +++ b/src-tauri/src/proxy/usage/parser.rs @@ -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, /// 从响应中提取的消息 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 { 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())); } diff --git a/src-tauri/src/services/session_usage.rs b/src-tauri/src/services/session_usage.rs index 7d8756722..3c5c83660 100644 --- a/src-tauri/src/services/session_usage.rs +++ b/src-tauri/src/services/session_usage.rs @@ -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, }; diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs index 5757f9ded..7bf33257f 100644 --- a/src-tauri/src/services/session_usage_codex.rs +++ b/src-tauri/src/services/session_usage_codex.rs @@ -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, }; diff --git a/src-tauri/src/services/session_usage_gemini.rs b/src-tauri/src/services/session_usage_gemini.rs index c904726f2..3e81a3613 100644 --- a/src-tauri/src/services/session_usage_gemini.rs +++ b/src-tauri/src/services/session_usage_gemini.rs @@ -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, }; diff --git a/src-tauri/src/services/session_usage_opencode.rs b/src-tauri/src/services/session_usage_opencode.rs index 83574bb62..f4ec116ab 100644 --- a/src-tauri/src/services/session_usage_opencode.rs +++ b/src-tauri/src/services/session_usage_opencode.rs @@ -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, }; diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index d0760f2c4..0fb576b84 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -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, } -/// 把 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(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>(18)?.map(|v| v as u64), - duration_ms: row.get::<_, Option>(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>(19)?.map(|v| v as u64), + duration_ms: row.get::<_, Option>(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()?;