fix(usage): correct cache cost semantics and silence pricing warn storm

- Split CostCalculator into per-app cache semantics: Anthropic's
  input_tokens is already fresh input, while Codex/Gemini include
  cached tokens in their prompt count. The old shared formula
  double-subtracted cache_read for Claude, under-billing input cost.
- Backfill now reads cost_multiplier from the per-log snapshot column
  instead of re-querying providers.meta, so historical rows are no
  longer rewritten with the current multiplier.
- Move the "pricing not found" warn out of find_model_pricing_row;
  emit it only when a brand new log is written, and skip placeholder
  models (unknown / empty / null / none) entirely.
- Broaden model id normalization: strip namespace prefixes
  (anthropic./openai./global./bedrock.), bedrock-style -vN suffixes,
  reasoning effort suffixes (-low/-medium/-high/-xhigh/-minimal),
  Claude Desktop's claude-<non-anthropic> wrapper, dot-to-dash for
  Claude, and try a LIKE prefix match for Claude short route ids
  (e.g. claude-haiku-4-5 -> claude-haiku-4-5-20251001).
- Fall back to request_model when the stored model is missing, so
  early Codex session rows with model=unknown can still be priced.
This commit is contained in:
Jason
2026-05-13 17:51:35 +08:00
parent 4b57f7e113
commit aa5e58d060
5 changed files with 505 additions and 102 deletions
+78 -7
View File
@@ -37,19 +37,52 @@ impl CostCalculator {
/// - `cost_multiplier`: 成本倍数 (provider 自定义)
///
/// # 计算逻辑
/// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
/// - input_cost: input_tokens × 输入价格
/// - cache_read_cost: cache_read_tokens × 缓存读取价格
/// - 这样避免缓存部分被重复计费
/// - Claude/Anthropic 的 input_tokens 已经不包含 cache_read_tokens
/// - total_cost: 各项成本之和 × 倍率(倍率只作用于最终总价)
pub fn calculate(
usage: &TokenUsage,
pricing: &ModelPricing,
cost_multiplier: Decimal,
) -> CostBreakdown {
Self::calculate_with_cache_semantics(usage, pricing, cost_multiplier, false)
}
/// 按 app_type 选择输入 token 语义后计算成本。
///
/// Codex/OpenAI Responses 与 Gemini 的输入 token 字段包含 cache read 部分;
/// Claude/Anthropic 的 input_tokens 已经是 fresh input。
pub fn calculate_for_app(
app_type: &str,
usage: &TokenUsage,
pricing: &ModelPricing,
cost_multiplier: Decimal,
) -> CostBreakdown {
let input_includes_cache_read = matches!(app_type, "codex" | "gemini");
Self::calculate_with_cache_semantics(
usage,
pricing,
cost_multiplier,
input_includes_cache_read,
)
}
fn calculate_with_cache_semantics(
usage: &TokenUsage,
pricing: &ModelPricing,
cost_multiplier: Decimal,
input_includes_cache_read: bool,
) -> CostBreakdown {
let million = Decimal::from(1_000_000);
// 计算实际需要按输入价格计费的 token 数(减去缓存命中部分)
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_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)
} else {
usage.input_tokens
};
// 各项基础成本(不含倍率)
let input_cost =
@@ -76,6 +109,7 @@ impl CostCalculator {
}
/// 尝试计算成本,如果模型未知则返回 None
#[allow(dead_code)]
pub fn try_calculate(
usage: &TokenUsage,
pricing: Option<&ModelPricing>,
@@ -83,6 +117,15 @@ impl CostCalculator {
) -> Option<CostBreakdown> {
pricing.map(|p| Self::calculate(usage, p, cost_multiplier))
}
pub fn try_calculate_for_app(
app_type: &str,
usage: &TokenUsage,
pricing: Option<&ModelPricing>,
cost_multiplier: Decimal,
) -> Option<CostBreakdown> {
pricing.map(|p| Self::calculate_for_app(app_type, usage, p, cost_multiplier))
}
}
impl ModelPricing {
@@ -122,8 +165,9 @@ mod tests {
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
// input: (1000 - 200) * 3.0 / 1M = 0.0024 (只计算非缓存部分)
assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap());
// Claude/Anthropic 语义:input_tokens 已经不含 cache_read_tokens
// input: 1000 * 3.0 / 1M = 0.003
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap());
// output: 500 * 15.0 / 1M = 0.0075
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
// cache_read: 200 * 0.3 / 1M = 0.00006
@@ -133,7 +177,34 @@ mod tests {
cost.cache_creation_cost,
Decimal::from_str("0.000375").unwrap()
);
// total: 0.0024 + 0.0075 + 0.00006 + 0.000375 = 0.010335
// total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935
assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap());
}
#[test]
fn test_cost_calculation_for_cache_inclusive_app() {
let usage = TokenUsage {
input_tokens: 1000,
output_tokens: 500,
cache_read_tokens: 200,
cache_creation_tokens: 100,
model: None,
message_id: None,
};
let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap();
let multiplier = Decimal::from_str("1.0").unwrap();
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());
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());
}
+13 -3
View File
@@ -4,7 +4,7 @@ use super::calculator::{CostBreakdown, CostCalculator, ModelPricing};
use super::parser::TokenUsage;
use crate::database::Database;
use crate::error::AppError;
use crate::services::usage_stats::find_model_pricing_row;
use crate::services::usage_stats::{find_model_pricing_row, is_placeholder_pricing_model};
use rust_decimal::Decimal;
use std::{str::FromStr, time::SystemTime};
@@ -303,11 +303,21 @@ impl<'a> UsageLogger<'a> {
) -> Result<(), AppError> {
let pricing = self.get_model_pricing(&pricing_model)?;
if pricing.is_none() {
let has_usage = usage.input_tokens > 0
|| usage.output_tokens > 0
|| usage.cache_read_tokens > 0
|| usage.cache_creation_tokens > 0;
if pricing.is_none() && has_usage && !is_placeholder_pricing_model(&pricing_model) {
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0: {pricing_model}");
}
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
let cost = CostCalculator::try_calculate_for_app(
&app_type,
&usage,
pricing.as_ref(),
cost_multiplier,
);
let log = RequestLog {
request_id,
@@ -480,7 +480,7 @@ fn insert_codex_session_entry(
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing
{
Some(p) => {
let cost = CostCalculator::calculate(&usage, &p, multiplier);
let cost = CostCalculator::calculate_for_app("codex", &usage, &p, multiplier);
(
cost.input_cost.to_string(),
cost.output_cost.to_string(),
@@ -282,7 +282,7 @@ fn insert_gemini_session_entry(
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing
{
Some(p) => {
let cost = CostCalculator::calculate(&usage, &p, multiplier);
let cost = CostCalculator::calculate_for_app("gemini", &usage, &p, multiplier);
(
cost.input_cost.to_string(),
cost.output_cost.to_string(),
+412 -90
View File
@@ -8,7 +8,6 @@ use crate::services::sql_helpers::fresh_input_sql;
use chrono::{Local, NaiveDate, TimeZone, Timelike};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::str::FromStr;
@@ -688,7 +687,11 @@ impl Database {
}
summaries.push(item);
}
summaries.sort_by(|a, b| b.summary.real_total_tokens.cmp(&a.summary.real_total_tokens));
summaries.sort_by(|a, b| {
b.summary
.real_total_tokens
.cmp(&a.summary.real_total_tokens)
});
Ok(summaries)
}
@@ -1289,17 +1292,11 @@ impl Database {
let rows = stmt.query_map(params_refs.as_slice(), row_to_request_log_detail)?;
let mut logs = Vec::new();
let mut provider_cache = HashMap::new();
let mut pricing_cache = HashMap::new();
for row in rows {
let mut log = row?;
Self::maybe_backfill_log_costs(
&conn,
&mut log,
&mut provider_cache,
&mut pricing_cache,
)?;
Self::maybe_backfill_log_costs(&conn, &mut log, &mut pricing_cache)?;
logs.push(log);
}
@@ -1334,14 +1331,8 @@ impl Database {
match result {
Ok(mut detail) => {
let mut provider_cache = HashMap::new();
let mut pricing_cache = HashMap::new();
Self::maybe_backfill_log_costs(
&conn,
&mut detail,
&mut provider_cache,
&mut pricing_cache,
)?;
Self::maybe_backfill_log_costs(&conn, &mut detail, &mut pricing_cache)?;
Ok(Some(detail))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
@@ -1496,10 +1487,9 @@ impl Database {
.map_err(|e| AppError::Database(format!("启动用量成本回填事务失败: {e}")))?;
let mut updated = 0u64;
let mut provider_cache = HashMap::new();
let mut pricing_cache = HashMap::new();
for log in &mut logs {
if Self::maybe_backfill_log_costs(&tx, log, &mut provider_cache, &mut pricing_cache)? {
if Self::maybe_backfill_log_costs(&tx, log, &mut pricing_cache)? {
updated += 1;
}
}
@@ -1517,7 +1507,6 @@ impl Database {
fn maybe_backfill_log_costs(
conn: &Connection,
log: &mut RequestLogDetail,
provider_cache: &mut HashMap<(String, String), rust_decimal::Decimal>,
pricing_cache: &mut HashMap<String, PricingInfo>,
) -> Result<bool, AppError> {
let existing_cost = rust_decimal::Decimal::from_str(&log.total_cost_usd)
@@ -1532,25 +1521,32 @@ impl Database {
return Ok(false);
}
let pricing = match Self::get_model_pricing_cached(conn, pricing_cache, &log.model)? {
let pricing = match Self::get_log_model_pricing_cached(conn, pricing_cache, log)? {
Some(info) => info,
None => return Ok(false),
};
let multiplier = Self::get_cost_multiplier_cached(
conn,
provider_cache,
&log.provider_id,
&log.app_type,
)?;
let multiplier =
rust_decimal::Decimal::from_str(&log.cost_multiplier).unwrap_or_else(|e| {
log::warn!(
"历史用量倍率解析失败 request_id={}: {} - {e}",
log.request_id,
log.cost_multiplier
);
rust_decimal::Decimal::ONE
});
let million = rust_decimal::Decimal::from(1_000_000u64);
// 与 CostCalculator::calculate 保持一致的计算逻辑:
// 1. input_cost 需要扣除 cache_read_tokens(避免缓存部分被重复计费
// 2. 各项成本是基础成本(不含倍率)
// 3. 倍率只作用于最终总价
let billable_input_tokens =
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64);
// 与 CostCalculator::calculate_for_app 保持一致的计算逻辑:
// 1. Codex/Gemini 的 input_tokens 包含 cache_read_tokens,需要扣除后按输入价计费
// 2. Claude/Anthropic 的 input_tokens 已经是 fresh input,不能再次扣减
// 3. 各项成本是基础成本(不含倍率),倍率只作用于最终总价
let input_includes_cache_read = matches!(log.app_type.as_str(), "codex" | "gemini");
let billable_input_tokens = if input_includes_cache_read {
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64)
} else {
log.input_tokens as u64
};
let input_cost =
rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;
let output_cost =
@@ -1593,39 +1589,6 @@ impl Database {
Ok(true)
}
fn get_cost_multiplier_cached(
conn: &Connection,
cache: &mut HashMap<(String, String), rust_decimal::Decimal>,
provider_id: &str,
app_type: &str,
) -> Result<rust_decimal::Decimal, AppError> {
let key = (provider_id.to_string(), app_type.to_string());
if let Some(multiplier) = cache.get(&key) {
return Ok(*multiplier);
}
let meta_json: Option<String> = conn
.query_row(
"SELECT meta FROM providers WHERE id = ? AND app_type = ?",
params![provider_id, app_type],
|row| row.get(0),
)
.optional()
.map_err(|e| AppError::Database(format!("查询 provider meta 失败: {e}")))?;
let multiplier = meta_json
.and_then(|meta| serde_json::from_str::<Value>(&meta).ok())
.and_then(|value| value.get("costMultiplier").cloned())
.and_then(|val| {
val.as_str()
.and_then(|s| rust_decimal::Decimal::from_str(s).ok())
})
.unwrap_or(rust_decimal::Decimal::ONE);
cache.insert(key, multiplier);
Ok(multiplier)
}
fn get_model_pricing_cached(
conn: &Connection,
cache: &mut HashMap<String, PricingInfo>,
@@ -1654,15 +1617,143 @@ impl Database {
cache.insert(model.to_string(), pricing.clone());
Ok(Some(pricing))
}
fn get_log_model_pricing_cached(
conn: &Connection,
cache: &mut HashMap<String, PricingInfo>,
log: &RequestLogDetail,
) -> Result<Option<PricingInfo>, AppError> {
if let Some(pricing) = Self::get_model_pricing_cached(conn, cache, &log.model)? {
return Ok(Some(pricing));
}
let Some(request_model) = log.request_model.as_deref() else {
return Ok(None);
};
if request_model == log.model {
return Ok(None);
}
Self::get_model_pricing_cached(conn, cache, request_model)
}
}
pub(crate) fn find_model_pricing_row(
conn: &Connection,
model_id: &str,
) -> Result<Option<(String, String, String, String)>, AppError> {
// 清洗模型名称:去前缀(/)、去后缀(:)、@ 替换为 -
// 例如 moonshotai/gpt-5.2-codex@low:v2 → gpt-5.2-codex-low
let cleaned = model_id
let candidates = model_pricing_candidates(model_id);
if candidates.is_empty() {
return Ok(None);
}
for candidate in &candidates {
if let Some(row) = query_model_pricing_exact(conn, candidate)? {
return Ok(Some(row));
}
}
for candidate in &candidates {
if should_try_pricing_prefix_match(candidate) {
if let Some(row) = query_model_pricing_prefix(conn, candidate)? {
return Ok(Some(row));
}
}
}
Ok(None)
}
pub(crate) fn is_placeholder_pricing_model(model_id: &str) -> bool {
let normalized = model_id.trim().to_ascii_lowercase();
normalized.is_empty() || matches!(normalized.as_str(), "unknown" | "null" | "none")
}
fn query_model_pricing_exact(
conn: &Connection,
model_id: &str,
) -> Result<Option<(String, String, String, String)>, AppError> {
conn.query_row(
"SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing
WHERE model_id = ?1",
[model_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.optional()
.map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))
}
fn query_model_pricing_prefix(
conn: &Connection,
model_id: &str,
) -> Result<Option<(String, String, String, String)>, AppError> {
let pattern = format!("{model_id}-%");
conn.query_row(
"SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing
WHERE model_id LIKE ?1
ORDER BY LENGTH(model_id) ASC
LIMIT 1",
[pattern],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.optional()
.map_err(|e| AppError::Database(format!("查询模型前缀定价失败: {e}")))
}
fn model_pricing_candidates(model_id: &str) -> Vec<String> {
let cleaned = clean_model_id_for_pricing(model_id);
if is_placeholder_pricing_model(&cleaned) {
return Vec::new();
}
let mut candidates = Vec::new();
let mut queue = vec![cleaned];
while let Some(candidate) = queue.pop() {
if !push_unique_candidate(&mut candidates, candidate.clone()) {
continue;
}
if let Some(stripped) = strip_known_model_namespace(&candidate) {
queue.push(stripped);
}
if let Some(stripped) = strip_claude_desktop_non_anthropic_prefix(&candidate) {
queue.push(stripped);
}
if let Some(stripped) = strip_bedrock_model_version_suffix(&candidate) {
queue.push(stripped);
}
if let Some(stripped) = strip_reasoning_effort_suffix(&candidate) {
queue.push(stripped);
}
if candidate.starts_with("claude-") && candidate.contains('.') {
queue.push(candidate.replace('.', "-"));
}
}
candidates
}
fn clean_model_id_for_pricing(model_id: &str) -> String {
let normalized = model_id
.rsplit_once('/')
.map_or(model_id, |(_, r)| r)
.split(':')
@@ -1672,31 +1763,110 @@ pub(crate) fn find_model_pricing_row(
.replace('@', "-")
.to_ascii_lowercase();
// 精确匹配清洗后的名称
let exact = conn
.query_row(
"SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing
WHERE model_id = ?1",
[&cleaned],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.optional()
.map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?;
normalized
.trim_end_matches(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)
.trim()
.to_string()
}
if exact.is_none() {
log::warn!("模型 {model_id}(清洗后: {cleaned})未找到定价信息,成本将记录为 0");
fn push_unique_candidate(candidates: &mut Vec<String>, candidate: String) -> bool {
if candidate.is_empty() || candidates.iter().any(|existing| existing == &candidate) {
return false;
}
candidates.push(candidate);
true
}
fn strip_known_model_namespace(model_id: &str) -> Option<String> {
if let Some(pos) = model_id.rfind("claude-") {
if pos > 0 {
return Some(model_id[pos..].to_string());
}
}
Ok(exact)
for marker in [
"openai.",
"anthropic.",
"google.",
"moonshot.",
"moonshotai.",
"bedrock.",
"global.",
] {
if let Some(stripped) = model_id.strip_prefix(marker) {
return Some(stripped.to_string());
}
}
None
}
fn strip_claude_desktop_non_anthropic_prefix(model_id: &str) -> Option<String> {
const NON_ANTHROPIC_MARKERS: &[&str] = &[
"abab",
"ark-code",
"arctic",
"astron",
"codex",
"command-r",
"deepseek",
"doubao",
"ernie",
"gemini",
"gemma",
"glm",
"gpt",
"grok",
"hermes",
"hy3",
"hunyuan",
"jamba",
"kimi",
"lfm",
"llama",
"longcat",
"mercury",
"mimo",
"minimax",
"mistral",
"mixtral",
"moonshot",
"nemotron",
"nova-",
"openai",
"qianfan",
"qwen",
"seed-",
"solar",
"stepfun",
];
let rest = model_id.strip_prefix("claude-")?;
NON_ANTHROPIC_MARKERS
.iter()
.any(|marker| rest.starts_with(marker))
.then(|| rest.to_string())
}
fn strip_bedrock_model_version_suffix(model_id: &str) -> Option<String> {
let (base, suffix) = model_id.rsplit_once("-v")?;
(!base.is_empty() && !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()))
.then(|| base.to_string())
}
fn strip_reasoning_effort_suffix(model_id: &str) -> Option<String> {
for suffix in ["-minimal", "-low", "-medium", "-high", "-xhigh"] {
if let Some(stripped) = model_id.strip_suffix(suffix) {
if !stripped.is_empty() {
return Some(stripped.to_string());
}
}
}
None
}
fn should_try_pricing_prefix_match(model_id: &str) -> bool {
model_id.starts_with("claude-") && model_id.matches('-').count() >= 3
}
#[cfg(test)]
@@ -1857,6 +2027,125 @@ mod tests {
Ok(())
}
#[test]
fn test_backfill_missing_usage_costs_uses_stored_multiplier() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
insert_usage_log(
&conn,
"codex-gpt-5-5-multiplier",
"codex",
"_codex_session",
"gpt-5.5",
"codex_session",
1000,
1_000_000,
0,
0,
0,
200,
"0",
)?;
conn.execute(
"UPDATE proxy_request_logs
SET cost_multiplier = '1.5'
WHERE request_id = 'codex-gpt-5-5-multiplier'",
[],
)?;
}
assert_eq!(db.backfill_missing_usage_costs()?, 1);
let conn = lock_conn!(db.conn);
let (input_cost, total_cost): (String, String) = conn.query_row(
"SELECT input_cost_usd, total_cost_usd
FROM proxy_request_logs WHERE request_id = 'codex-gpt-5-5-multiplier'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(input_cost, "5.000000");
assert_eq!(total_cost, "7.500000");
Ok(())
}
#[test]
fn test_backfill_missing_usage_costs_falls_back_to_request_model() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd,
total_cost_usd, latency_ms, status_code, created_at, data_source
) VALUES (
'codex-request-model-fallback', '_codex_session', 'codex', 'unknown', 'gpt-5.5',
1000000, 0, 0, 0,
'0', '0', '0', '0',
'0', 100, 200, 1000, 'codex_session'
)",
[],
)?;
}
assert_eq!(db.backfill_missing_usage_costs()?, 1);
let conn = lock_conn!(db.conn);
let total_cost: String = conn.query_row(
"SELECT total_cost_usd
FROM proxy_request_logs WHERE request_id = 'codex-request-model-fallback'",
[],
|row| row.get(0),
)?;
assert_eq!(total_cost, "5.000000");
Ok(())
}
#[test]
fn test_backfill_missing_usage_costs_keeps_claude_fresh_input() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
insert_usage_log(
&conn,
"claude-cache-fresh-input",
"claude",
"_session",
"claude-haiku-4-5",
"session_log",
1000,
100,
0,
200,
0,
200,
"0",
)?;
}
assert_eq!(db.backfill_missing_usage_costs()?, 1);
let conn = lock_conn!(db.conn);
let (input_cost, cache_read_cost, total_cost): (String, String, String) = conn.query_row(
"SELECT input_cost_usd, cache_read_cost_usd, total_cost_usd
FROM proxy_request_logs WHERE request_id = 'claude-cache-fresh-input'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(input_cost, "0.000100");
assert_eq!(cache_read_cost, "0.000020");
assert_eq!(total_cost, "0.000120");
Ok(())
}
#[test]
fn test_get_usage_summary() -> Result<(), AppError> {
let db = Database::memory()?;
@@ -2793,6 +3082,39 @@ mod tests {
"大小写混合的 GPT-5.5 模型应能归一化匹配到 gpt-5.5-high"
);
// Claude Desktop route 短 ID:应通过前缀匹配到带日期的定价
let result = find_model_pricing_row(&conn, "claude-haiku-4-5")?;
assert!(
result.is_some(),
"Claude Desktop 短路由 claude-haiku-4-5 应能匹配到 claude-haiku-4-5-20251001"
);
// Claude Desktop 旧版/异常包装的非 Anthropic routeclaude-gpt-5.5 → gpt-5.5
let result = find_model_pricing_row(&conn, "claude-gpt-5.5")?;
assert!(
result.is_some(),
"带 claude- 包装的非 Anthropic 模型应能剥离后匹配到真实模型定价"
);
// Bedrock/Vertex 常见形态:provider 前缀 + -vN 后缀 + :0 修饰
let result =
find_model_pricing_row(&conn, "global.anthropic.claude-haiku-4-5-20251001-v1:0")?;
assert!(
result.is_some(),
"Bedrock/Vertex 风格 Claude 模型 ID 应能归一化到基础 Claude 模型定价"
);
// Reasoning effort 后缀:没有专门价格时回退到基础模型
let result = find_model_pricing_row(&conn, "gpt-5.4@low")?;
assert!(
result.is_some(),
"缺少专门 effort 价格时应回退到 gpt-5.4 基础模型定价"
);
// Kimi Code 是订阅/额度模型,不应伪装成公开按 token 计费模型
let result = find_model_pricing_row(&conn, "kimi-for-coding")?;
assert!(result.is_none(), "kimi-for-coding 没有固定 token 单价");
// 测试不存在的模型
let result = find_model_pricing_row(&conn, "unknown-model-123")?;
assert!(result.is_none(), "不应该匹配不存在的模型");