Files
CC-Switch/src-tauri/src/services/usage_stats.rs
T
Jason 13e7c1fcc4 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.
2026-07-13 16:48:21 +08:00

4213 lines
152 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 使用统计服务
//!
//! 提供使用量数据的聚合查询功能
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::proxy::usage::calculator::ModelPricing;
use crate::services::sql_helpers::{
fresh_input_sql, INPUT_TOKEN_SEMANTICS_FRESH, INPUT_TOKEN_SEMANTICS_TOTAL,
};
use chrono::{Local, NaiveDate, TimeZone, Timelike};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
/// 使用量汇总
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageSummary {
pub total_requests: u64,
pub total_cost: String,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub total_cache_creation_tokens: u64,
pub total_cache_read_tokens: u64,
pub success_rate: f32,
/// input + output + cache_creation + cache_read — the total tokens
/// actually processed by the model (including cache hits). Used as the
/// headline "real consumption" number in the usage hero.
pub real_total_tokens: u64,
/// cache_read / (input + cache_creation + cache_read). Range 0.01.0.
/// Reported as a fraction; multiply by 100 in UI for percentage display.
pub cache_hit_rate: f64,
}
/// Per-app-type usage summary used by the dashboard breakdown rail.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageSummaryByApp {
pub app_type: String,
pub summary: UsageSummary,
}
/// Helper: compute (real_total, hit_rate) from the four token counters.
/// All inputs must already be cache-normalized (i.e. input excludes cache).
fn derive_real_total_and_hit_rate(
fresh_input: u64,
output: u64,
cache_creation: u64,
cache_read: u64,
) -> (u64, f64) {
let real_total = fresh_input + output + cache_creation + cache_read;
let cacheable_input = fresh_input + cache_creation + cache_read;
let hit_rate = if cacheable_input > 0 {
cache_read as f64 / cacheable_input as f64
} else {
0.0
};
(real_total, hit_rate)
}
/// 每日统计
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyStats {
pub date: String,
pub request_count: u64,
pub total_cost: String,
pub total_tokens: u64,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub total_cache_creation_tokens: u64,
pub total_cache_read_tokens: u64,
}
/// Provider 统计
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderStats {
pub provider_id: String,
pub provider_name: String,
pub request_count: u64,
pub total_tokens: u64,
pub total_cost: String,
pub success_rate: f32,
pub avg_latency_ms: u64,
}
/// 模型统计
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelStats {
pub model: String,
pub request_count: u64,
pub total_tokens: u64,
pub total_cost: String,
pub avg_cost_per_request: String,
}
/// 请求日志过滤器
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LogFilters {
pub app_type: Option<String>,
pub provider_name: Option<String>,
pub model: Option<String>,
pub status_code: Option<u16>,
pub start_date: Option<i64>,
pub end_date: Option<i64>,
}
/// 分页请求日志响应
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PaginatedLogs {
pub data: Vec<RequestLogDetail>,
pub total: u32,
pub page: u32,
pub page_size: u32,
}
/// 请求日志详情
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestLogDetail {
pub request_id: String,
pub provider_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_name: Option<String>,
pub app_type: String,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_model: Option<String>,
pub cost_multiplier: String,
pub input_tokens: u32,
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,
pub input_cost_usd: String,
pub output_cost_usd: String,
pub cache_read_cost_usd: String,
pub cache_creation_cost_usd: String,
pub total_cost_usd: String,
pub is_streaming: bool,
pub latency_ms: u64,
pub first_token_ms: Option<u64>,
pub duration_ms: Option<u64>,
pub status_code: u16,
pub error_message: Option<String>,
pub created_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_source: Option<String>,
/// 写入时实际用于计价的模型名。None = v11 前的历史行,"" = 未计价的错误行。
#[serde(skip_serializing_if = "Option::is_none")]
pub pricing_model: Option<String>,
}
/// 把 27 列的查询结果映射为 `RequestLogDetail`。
///
/// 调用方的 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, 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`
///
/// 不需要 provider_name 时(如 backfillSELECT `NULL AS provider_name` 占位即可。
fn row_to_request_log_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result<RequestLogDetail> {
Ok(RequestLogDetail {
request_id: row.get(0)?,
provider_id: row.get(1)?,
provider_name: row.get(2)?,
app_type: row.get(3)?,
model: row.get(4)?,
request_model: row.get(5)?,
cost_multiplier: row
.get::<_, Option<String>>(6)?
.unwrap_or_else(|| "1".to_string()),
input_tokens: row.get::<_, i64>(7)? as u32,
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,
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)?,
})
}
/// SQL fragment: resolve provider_name with fallback for session-based entries.
/// Session logs use placeholder provider_ids (e.g., `_session`, `_<app>_session`)
/// that don't exist in the providers table — the CASE expression below is the
/// authoritative mapping from placeholder to readable name.
fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String {
format!(
"COALESCE({provider_alias}.name, CASE {log_alias}.provider_id \
WHEN '_session' THEN 'Claude (Session)' \
WHEN '_codex_session' THEN 'Codex (Session)' \
WHEN '_gemini_session' THEN 'Gemini (Session)' \
WHEN '_opencode_session' THEN 'OpenCode (Session)' \
ELSE {log_alias}.provider_id END)"
)
}
pub(crate) const SESSION_PROXY_DEDUP_WINDOW_SECONDS: i64 = 10 * 60;
/// SQL 片段:把指定别名的 `data_source` 包成 COALESCENULL 视作 'proxy'。
///
/// 防御 schema v9 之前可能写入的 NULL data_source 行(见
/// `tests::create_legacy_nullable_logs_table`)。所有用到 data_source 的查询
/// 都应通过此 helper 生成片段,避免遗漏。
fn data_source_expr(log_alias: &str) -> String {
format!("COALESCE({log_alias}.data_source, 'proxy')")
}
/// SQL 标量表达式:把 Claude Desktop 网关的 `claude-desktop` app_type 在“展示口径”
/// 上折叠进 `claude`,其余 app_type 原样返回。
///
/// 背景:Desktop 网关流量在记账层按各自入口写为 `app_type='claude-desktop'`
/// 以保留路由接管的账单审计精度(不要回退这一点)。但 Dashboard 把它当作
/// Claude Code 呈现——它本质就是跑在 Desktop 壳里的内嵌 Claude Code 运行时,
/// 且 Desktop 聊天用量永远不经过本软件,单列只会让用户误以为是“桌面版全部用量”。
///
/// 用法:把任一参与“按应用筛选/分组”的 `app_type` 列包进此表达式即可,
/// 这样 `= 'claude'` 过滤会同时命中 `claude-desktop`、`GROUP BY` 会把两者合并,
/// 而不改动任何已存储的行(详情面板仍读原始 `app_type`)。
///
/// 注意:包裹后该列上的索引在此比较中失效,但这些都是已带时间过滤的聚合扫描,
/// app_type 本就不是主访问路径,可接受。仅用于读侧;去重匹配(`has_matching_
/// proxy_usage_log`)与额度检查(`check_provider_limits`)必须保留原始精确比较。
fn folded_app_type_sql(column: &str) -> String {
format!("CASE WHEN {column} = 'claude-desktop' THEN 'claude' ELSE {column} END")
}
/// SQL 片段:把日志/汇总行 LEFT JOIN 到 providers 表以取得供应商名称。
/// `proxy_request_logs` 与 `usage_daily_rollups` 的 (provider_id, app_type)
/// 形状相同,两者皆可作为 `log_alias`。providers 主键即 (id, app_type)
/// 连接至多 1:1,不会放大行数。
fn providers_join(log_alias: &str, provider_alias: &str) -> String {
format!(
"LEFT JOIN providers {provider_alias} \
ON {log_alias}.provider_id = {provider_alias}.id \
AND {log_alias}.app_type = {provider_alias}.app_type"
)
}
/// SQL 标量表达式:行的「有效计价模型」—— pricing_model 非空优先,NULL/'' 回落
/// model。这是 `get_model_stats` 的分组键,也是 Dashboard 模型筛选的匹配口径:
/// 筛选值来自模型统计列表,两边必须用同一表达式才能选得中。
fn effective_model_sql(alias: &str) -> String {
format!("COALESCE(NULLIF({alias}.pricing_model, ''), {alias}.model)")
}
/// 把 Dashboard 顶部的 Provider/模型筛选追加到查询条件。
///
/// Provider 按展示名精确匹配(复用 [`provider_name_coalesce`],会话占位行的
/// 可读名如 "Claude (Session)" 也能选中);模型按 [`effective_model_sql`] 匹配。
/// 注意:传入 `provider_name` 时调用方必须把 [`providers_join`] 拼进 FROM
/// 否则 `{provider_alias}.name` 无法解析。
fn push_provider_model_filters(
conditions: &mut Vec<String>,
params: &mut Vec<Box<dyn rusqlite::ToSql>>,
log_alias: &str,
provider_alias: &str,
provider_name: Option<&str>,
model: Option<&str>,
) {
if let Some(name) = provider_name {
conditions.push(format!(
"{} = ?",
provider_name_coalesce(log_alias, provider_alias)
));
params.push(Box::new(name.to_string()));
}
if let Some(m) = model {
conditions.push(format!("{} = ?", effective_model_sql(log_alias)));
params.push(Box::new(m.to_string()));
}
}
pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String {
let data_source = data_source_expr(log_alias);
let proxy_data_source = data_source_expr("proxy_dedup");
format!(
"NOT (
{data_source} IN ('session_log', 'codex_session', 'gemini_session', 'opencode_session')
AND EXISTS (
SELECT 1
FROM proxy_request_logs proxy_dedup
WHERE {proxy_data_source} = 'proxy'
AND proxy_dedup.app_type = {log_alias}.app_type
AND proxy_dedup.status_code >= 200
AND proxy_dedup.status_code < 300
AND proxy_dedup.input_tokens = {log_alias}.input_tokens
AND proxy_dedup.output_tokens = {log_alias}.output_tokens
AND proxy_dedup.cache_read_tokens = {log_alias}.cache_read_tokens
AND (
proxy_dedup.cache_creation_tokens = {log_alias}.cache_creation_tokens
OR (
{log_alias}.cache_creation_tokens = 0
AND {data_source} IN ('codex_session', 'gemini_session', 'opencode_session')
)
)
AND proxy_dedup.created_at BETWEEN
{log_alias}.created_at - {SESSION_PROXY_DEDUP_WINDOW_SECONDS}
AND {log_alias}.created_at + {SESSION_PROXY_DEDUP_WINDOW_SECONDS}
AND (
LOWER(proxy_dedup.model) = LOWER({log_alias}.model)
OR LOWER(proxy_dedup.model) = 'unknown'
OR LOWER({log_alias}.model) = 'unknown'
)
)
)"
)
}
/// 跨源去重指纹键。
///
/// `cache_creation_tokens`Codex/Gemini session 日志不暴露该字段,调用方传 0
/// 表示"未知",匹配器会放行 proxy 侧任意 cache_creation_tokens 值。
#[derive(Debug, Clone, Copy)]
pub(crate) struct DedupKey<'a> {
pub app_type: &'a str,
pub model: &'a str,
pub input_tokens: u32,
pub output_tokens: u32,
pub cache_read_tokens: u32,
pub cache_creation_tokens: u32,
pub created_at: i64,
}
/// session 日志写入前的统一去重判定。
///
/// 命中以下任一条件即跳过插入:① `request_id` 已存在;② 时间窗口内存在
/// 与 `key` 匹配的 proxy 日志(指纹去重)。
pub(crate) fn should_skip_session_insert(
conn: &Connection,
request_id: &str,
key: &DedupKey,
) -> Result<bool, AppError> {
if proxy_request_id_exists(conn, request_id)? {
return Ok(true);
}
has_matching_proxy_usage_log(conn, key)
}
fn proxy_request_id_exists(conn: &Connection, request_id: &str) -> Result<bool, AppError> {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM proxy_request_logs WHERE request_id = ?1)",
params![request_id],
|row| row.get::<_, bool>(0),
)
.map_err(|e| AppError::Database(format!("查询 request_id 失败: {e}")))
}
pub(crate) fn has_matching_proxy_usage_log(
conn: &Connection,
key: &DedupKey,
) -> Result<bool, AppError> {
let allow_missing_cache_creation =
matches!(key.app_type, "codex" | "gemini" | "opencode") && key.cache_creation_tokens == 0;
let l_data_source = data_source_expr("l");
let sql = format!(
"SELECT EXISTS (
SELECT 1
FROM proxy_request_logs l
WHERE {l_data_source} = 'proxy'
AND l.app_type = ?1
AND l.status_code >= 200
AND l.status_code < 300
AND l.input_tokens = ?3
AND l.output_tokens = ?4
AND l.cache_read_tokens = ?5
AND (l.cache_creation_tokens = ?6 OR ?9 = 1)
AND l.created_at BETWEEN ?7 - ?8 AND ?7 + ?8
AND (
LOWER(l.model) = LOWER(?2)
OR LOWER(l.model) = 'unknown'
OR LOWER(?2) = 'unknown'
)
)"
);
conn.query_row(
&sql,
params![
key.app_type,
key.model,
key.input_tokens as i64,
key.output_tokens as i64,
key.cache_read_tokens as i64,
key.cache_creation_tokens as i64,
key.created_at,
SESSION_PROXY_DEDUP_WINDOW_SECONDS,
allow_missing_cache_creation as i64,
],
|row| row.get::<_, bool>(0),
)
.map_err(|e| AppError::Database(format!("查询重复代理用量日志失败: {e}")))
}
#[derive(Debug, Clone, Default)]
struct RollupDateBounds {
start: Option<String>,
end: Option<String>,
is_empty: bool,
}
fn local_datetime_from_timestamp(ts: i64) -> Result<chrono::DateTime<Local>, AppError> {
Local
.timestamp_opt(ts, 0)
.single()
.ok_or_else(|| AppError::Database(format!("无法解析本地时间戳: {ts}")))
}
fn compute_rollup_date_bounds(
start_ts: Option<i64>,
end_ts: Option<i64>,
) -> Result<RollupDateBounds, AppError> {
let start = match start_ts {
Some(ts) => {
let local = local_datetime_from_timestamp(ts)?;
let day = local.date_naive();
if local.time().num_seconds_from_midnight() == 0 {
Some(day.format("%Y-%m-%d").to_string())
} else {
day.succ_opt()
.map(|next| next.format("%Y-%m-%d").to_string())
}
}
None => None,
};
let end = match end_ts {
Some(ts) => {
let local = local_datetime_from_timestamp(ts)?;
let day = local.date_naive();
if local.time().hour() == 23 && local.time().minute() == 59 {
Some(day.format("%Y-%m-%d").to_string())
} else {
day.pred_opt()
.map(|prev| prev.format("%Y-%m-%d").to_string())
}
}
None => None,
};
let is_empty = matches!((&start, &end), (Some(start), Some(end)) if start > end);
Ok(RollupDateBounds {
start,
end,
is_empty,
})
}
fn push_rollup_date_filters(
conditions: &mut Vec<String>,
params: &mut Vec<Box<dyn rusqlite::ToSql>>,
column: &str,
bounds: &RollupDateBounds,
) {
if bounds.is_empty {
conditions.push("1 = 0".to_string());
return;
}
if let Some(start) = &bounds.start {
conditions.push(format!("{column} >= ?"));
params.push(Box::new(start.clone()));
}
if let Some(end) = &bounds.end {
conditions.push(format!("{column} <= ?"));
params.push(Box::new(end.clone()));
}
}
fn local_day_start_rfc3339(day: NaiveDate) -> String {
let local_midnight = day
.and_hms_opt(0, 0, 0)
.and_then(|naive| match Local.from_local_datetime(&naive) {
chrono::LocalResult::Single(dt) => Some(dt),
chrono::LocalResult::Ambiguous(earliest, _) => Some(earliest),
chrono::LocalResult::None => None,
})
.unwrap_or_else(Local::now);
local_midnight.to_rfc3339()
}
impl Database {
/// 获取使用量汇总
pub fn get_usage_summary(
&self,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<&str>,
provider_name: Option<&str>,
model: Option<&str>,
) -> Result<UsageSummary, AppError> {
let conn = lock_conn!(self.conn);
// Build detail WHERE clause
let mut conditions = vec![effective_usage_log_filter("l")];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date {
conditions.push("l.created_at >= ?".to_string());
params_vec.push(Box::new(start));
}
if let Some(end) = end_date {
conditions.push("l.created_at <= ?".to_string());
params_vec.push(Box::new(end));
}
if let Some(at) = app_type {
conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type")));
params_vec.push(Box::new(at.to_string()));
}
push_provider_model_filters(
&mut conditions,
&mut params_vec,
"l",
"p",
provider_name,
model,
);
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let detail_join = if provider_name.is_some() {
providers_join("l", "p")
} else {
String::new()
};
// Only include rolled-up rows for full local days that are fully covered by the range.
let mut rollup_conditions: Vec<String> = Vec::new();
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?;
push_rollup_date_filters(
&mut rollup_conditions,
&mut rollup_params,
"r.date",
&rollup_bounds,
);
if let Some(at) = app_type {
rollup_conditions.push(format!("{} = ?", folded_app_type_sql("r.app_type")));
rollup_params.push(Box::new(at.to_string()));
}
push_provider_model_filters(
&mut rollup_conditions,
&mut rollup_params,
"r",
"p2",
provider_name,
model,
);
let rollup_where = if rollup_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", rollup_conditions.join(" AND "))
};
let rollup_join = if provider_name.is_some() {
providers_join("r", "p2")
} else {
String::new()
};
let fresh_input_detail = fresh_input_sql("l");
let fresh_input_rollup = fresh_input_sql("r");
let sql = format!(
"SELECT
COALESCE(d.total_requests, 0) + COALESCE(r.total_requests, 0),
COALESCE(d.total_cost, 0) + COALESCE(r.total_cost, 0),
COALESCE(d.total_input_tokens, 0) + COALESCE(r.total_input_tokens, 0),
COALESCE(d.total_output_tokens, 0) + COALESCE(r.total_output_tokens, 0),
COALESCE(d.total_cache_creation_tokens, 0) + COALESCE(r.total_cache_creation_tokens, 0),
COALESCE(d.total_cache_read_tokens, 0) + COALESCE(r.total_cache_read_tokens, 0),
COALESCE(d.success_count, 0) + COALESCE(r.success_count, 0)
FROM
(SELECT
COUNT(*) as total_requests,
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM({fresh_input_detail}), 0) as total_input_tokens,
COALESCE(SUM(l.output_tokens), 0) as total_output_tokens,
COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens,
COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count
FROM proxy_request_logs l {detail_join} {where_clause}) d,
(SELECT
COALESCE(SUM(r.request_count), 0) as total_requests,
COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM({fresh_input_rollup}), 0) as total_input_tokens,
COALESCE(SUM(r.output_tokens), 0) as total_output_tokens,
COALESCE(SUM(r.cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(r.cache_read_tokens), 0) as total_cache_read_tokens,
COALESCE(SUM(r.success_count), 0) as success_count
FROM usage_daily_rollups r {rollup_join} {rollup_where}) r"
);
// Combine params: detail params first, then rollup params
let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = params_vec;
all_params.extend(rollup_params);
let param_refs: Vec<&dyn rusqlite::ToSql> = all_params.iter().map(|p| p.as_ref()).collect();
let result = conn.query_row(&sql, param_refs.as_slice(), |row| {
let total_requests: i64 = row.get(0)?;
let total_cost: f64 = row.get(1)?;
let total_input_tokens: i64 = row.get(2)?;
let total_output_tokens: i64 = row.get(3)?;
let total_cache_creation_tokens: i64 = row.get(4)?;
let total_cache_read_tokens: i64 = row.get(5)?;
let success_count: i64 = row.get(6)?;
let success_rate = if total_requests > 0 {
(success_count as f32 / total_requests as f32) * 100.0
} else {
0.0
};
let (real_total_tokens, cache_hit_rate) = derive_real_total_and_hit_rate(
total_input_tokens as u64,
total_output_tokens as u64,
total_cache_creation_tokens as u64,
total_cache_read_tokens as u64,
);
Ok(UsageSummary {
total_requests: total_requests as u64,
total_cost: format!("{total_cost:.6}"),
total_input_tokens: total_input_tokens as u64,
total_output_tokens: total_output_tokens as u64,
total_cache_creation_tokens: total_cache_creation_tokens as u64,
total_cache_read_tokens: total_cache_read_tokens as u64,
success_rate,
real_total_tokens,
cache_hit_rate,
})
})?;
Ok(result)
}
/// 按 app_type 维度拆分的使用量汇总,用于 Dashboard 的分应用展示条。
/// 返回所有有数据的 app_type,按 real_total_tokens 降序。
///
/// Single SQL with `GROUP BY app_type` — avoids the N+1 round-trip that
/// would result from invoking `get_usage_summary` once per app_type.
pub fn get_usage_summary_by_app(
&self,
start_date: Option<i64>,
end_date: Option<i64>,
provider_name: Option<&str>,
model: Option<&str>,
) -> Result<Vec<UsageSummaryByApp>, AppError> {
let conn = lock_conn!(self.conn);
let mut detail_conditions = vec![effective_usage_log_filter("l")];
let mut detail_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date {
detail_conditions.push("l.created_at >= ?".to_string());
detail_params.push(Box::new(start));
}
if let Some(end) = end_date {
detail_conditions.push("l.created_at <= ?".to_string());
detail_params.push(Box::new(end));
}
push_provider_model_filters(
&mut detail_conditions,
&mut detail_params,
"l",
"p",
provider_name,
model,
);
let detail_where = format!("WHERE {}", detail_conditions.join(" AND "));
let detail_join = if provider_name.is_some() {
providers_join("l", "p")
} else {
String::new()
};
let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?;
let mut rollup_conditions: Vec<String> = Vec::new();
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
push_rollup_date_filters(
&mut rollup_conditions,
&mut rollup_params,
"r.date",
&rollup_bounds,
);
push_provider_model_filters(
&mut rollup_conditions,
&mut rollup_params,
"r",
"p2",
provider_name,
model,
);
let rollup_where = if rollup_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", rollup_conditions.join(" AND "))
};
let rollup_join = if provider_name.is_some() {
providers_join("r", "p2")
} else {
String::new()
};
let fresh_input_detail = fresh_input_sql("l");
let fresh_input_rollup = fresh_input_sql("r");
// 折叠 claude-desktop → claude:内层投影成同一桶名,外层 GROUP BY 自然合并。
let detail_app_type = folded_app_type_sql("l.app_type");
let rollup_app_type = folded_app_type_sql("r.app_type");
let sql = format!(
"SELECT app_type,
SUM(req_count) as req_count,
SUM(cost) as cost,
SUM(input_t) as input_t,
SUM(output_t) as output_t,
SUM(cache_create_t) as cache_create_t,
SUM(cache_read_t) as cache_read_t,
SUM(success_count) as success_count
FROM (
SELECT {detail_app_type} as app_type,
COUNT(*) as req_count,
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as cost,
COALESCE(SUM({fresh_input_detail}), 0) as input_t,
COALESCE(SUM(l.output_tokens), 0) as output_t,
COALESCE(SUM(l.cache_creation_tokens), 0) as cache_create_t,
COALESCE(SUM(l.cache_read_tokens), 0) as cache_read_t,
COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count
FROM proxy_request_logs l {detail_join} {detail_where}
GROUP BY l.app_type
UNION ALL
SELECT {rollup_app_type} as app_type,
COALESCE(SUM(r.request_count), 0),
COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0),
COALESCE(SUM({fresh_input_rollup}), 0),
COALESCE(SUM(r.output_tokens), 0),
COALESCE(SUM(r.cache_creation_tokens), 0),
COALESCE(SUM(r.cache_read_tokens), 0),
COALESCE(SUM(r.success_count), 0)
FROM usage_daily_rollups r {rollup_join} {rollup_where}
GROUP BY r.app_type
)
GROUP BY app_type"
);
let mut combined: Vec<Box<dyn rusqlite::ToSql>> = detail_params;
combined.extend(rollup_params);
let refs: Vec<&dyn rusqlite::ToSql> = combined.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(refs.as_slice(), |row| {
let app_type: String = row.get(0)?;
let total_requests: i64 = row.get(1)?;
let total_cost: f64 = row.get(2)?;
let total_input_tokens: i64 = row.get(3)?;
let total_output_tokens: i64 = row.get(4)?;
let total_cache_creation_tokens: i64 = row.get(5)?;
let total_cache_read_tokens: i64 = row.get(6)?;
let success_count: i64 = row.get(7)?;
let success_rate = if total_requests > 0 {
(success_count as f32 / total_requests as f32) * 100.0
} else {
0.0
};
let (real_total_tokens, cache_hit_rate) = derive_real_total_and_hit_rate(
total_input_tokens as u64,
total_output_tokens as u64,
total_cache_creation_tokens as u64,
total_cache_read_tokens as u64,
);
Ok(UsageSummaryByApp {
app_type,
summary: UsageSummary {
total_requests: total_requests as u64,
total_cost: format!("{total_cost:.6}"),
total_input_tokens: total_input_tokens as u64,
total_output_tokens: total_output_tokens as u64,
total_cache_creation_tokens: total_cache_creation_tokens as u64,
total_cache_read_tokens: total_cache_read_tokens as u64,
success_rate,
real_total_tokens,
cache_hit_rate,
},
})
})?;
let mut summaries = Vec::new();
for row in rows {
let item = row?;
if item.summary.total_requests == 0 && item.summary.real_total_tokens == 0 {
continue;
}
summaries.push(item);
}
summaries.sort_by(|a, b| {
b.summary
.real_total_tokens
.cmp(&a.summary.real_total_tokens)
});
Ok(summaries)
}
/// 获取每日趋势(滑动窗口,<=24h 按小时,>24h 按天,窗口与汇总一致)
pub fn get_daily_trends(
&self,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<&str>,
provider_name: Option<&str>,
model: Option<&str>,
) -> Result<Vec<DailyStats>, AppError> {
let conn = lock_conn!(self.conn);
let end_ts = end_date.unwrap_or_else(|| Local::now().timestamp());
let mut start_ts = start_date.unwrap_or_else(|| end_ts - 24 * 60 * 60);
if start_ts >= end_ts {
start_ts = end_ts - 24 * 60 * 60;
}
let duration = end_ts - start_ts;
if duration <= 24 * 60 * 60 {
let bucket_seconds: i64 = 60 * 60;
let mut bucket_count: i64 = if duration <= 0 {
1
} else {
(duration + bucket_seconds - 1) / bucket_seconds
};
if bucket_count < 1 {
bucket_count = 1;
}
let mut extra_conditions: Vec<String> = Vec::new();
let mut extra_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(at) = app_type {
extra_conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type")));
extra_params.push(Box::new(at.to_string()));
}
push_provider_model_filters(
&mut extra_conditions,
&mut extra_params,
"l",
"p",
provider_name,
model,
);
let extra_filter = extra_conditions
.iter()
.map(|c| format!("AND {c}"))
.collect::<Vec<_>>()
.join(" ");
let detail_join = if provider_name.is_some() {
providers_join("l", "p")
} else {
String::new()
};
let effective_filter = effective_usage_log_filter("l");
let fresh_input = fresh_input_sql("l");
let sql = format!(
"SELECT
CAST((l.created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
COUNT(*) as request_count,
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM({fresh_input} + l.output_tokens), 0) as total_tokens,
COALESCE(SUM({fresh_input}), 0) as total_input_tokens,
COALESCE(SUM(l.output_tokens), 0) as total_output_tokens,
COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs l {detail_join}
WHERE l.created_at >= ?1 AND l.created_at <= ?2
AND {effective_filter} {extra_filter}
GROUP BY bucket_idx
ORDER BY bucket_idx ASC"
);
let mut stmt = conn.prepare(&sql)?;
let row_mapper = |row: &rusqlite::Row| {
Ok((
row.get::<_, i64>(0)?,
DailyStats {
date: String::new(),
request_count: row.get::<_, i64>(1)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
total_tokens: row.get::<_, i64>(3)? as u64,
total_input_tokens: row.get::<_, i64>(4)? as u64,
total_output_tokens: row.get::<_, i64>(5)? as u64,
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
},
))
};
let mut map: HashMap<i64, DailyStats> = HashMap::new();
let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(start_ts),
Box::new(end_ts),
Box::new(bucket_seconds),
];
all_params.extend(extra_params);
let param_refs: Vec<&dyn rusqlite::ToSql> =
all_params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?;
for row in rows {
let (mut bucket_idx, stat) = row?;
if bucket_idx < 0 {
continue;
}
if bucket_idx >= bucket_count {
bucket_idx = bucket_count - 1;
}
map.insert(bucket_idx, stat);
}
let mut stats = Vec::with_capacity(bucket_count as usize);
for i in 0..bucket_count {
let bucket_start_ts = start_ts + i * bucket_seconds;
let bucket_start = local_datetime_from_timestamp(bucket_start_ts)?;
let date = bucket_start.to_rfc3339();
if let Some(mut stat) = map.remove(&i) {
stat.date = date;
stats.push(stat);
} else {
stats.push(DailyStats {
date,
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
}
}
return Ok(stats);
}
let start_day = local_datetime_from_timestamp(start_ts)?.date_naive();
let end_day = local_datetime_from_timestamp(end_ts)?.date_naive();
let bucket_count = (end_day.signed_duration_since(start_day).num_days() + 1) as usize;
let mut extra_conditions: Vec<String> = Vec::new();
let mut extra_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(at) = app_type {
extra_conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type")));
extra_params.push(Box::new(at.to_string()));
}
push_provider_model_filters(
&mut extra_conditions,
&mut extra_params,
"l",
"p",
provider_name,
model,
);
let extra_filter = extra_conditions
.iter()
.map(|c| format!("AND {c}"))
.collect::<Vec<_>>()
.join(" ");
let detail_join = if provider_name.is_some() {
providers_join("l", "p")
} else {
String::new()
};
let effective_filter = effective_usage_log_filter("l");
let fresh_input = fresh_input_sql("l");
let detail_sql = format!(
"SELECT
date(l.created_at, 'unixepoch', 'localtime') as bucket_date,
COUNT(*) as request_count,
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM({fresh_input} + l.output_tokens), 0) as total_tokens,
COALESCE(SUM({fresh_input}), 0) as total_input_tokens,
COALESCE(SUM(l.output_tokens), 0) as total_output_tokens,
COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs l {detail_join}
WHERE l.created_at >= ?1 AND l.created_at <= ?2
AND {effective_filter} {extra_filter}
GROUP BY bucket_date
ORDER BY bucket_date ASC"
);
let mut detail_stmt = conn.prepare(&detail_sql)?;
let detail_row_mapper = |row: &rusqlite::Row| {
Ok((
row.get::<_, String>(0)?,
DailyStats {
date: String::new(),
request_count: row.get::<_, i64>(1)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
total_tokens: row.get::<_, i64>(3)? as u64,
total_input_tokens: row.get::<_, i64>(4)? as u64,
total_output_tokens: row.get::<_, i64>(5)? as u64,
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
},
))
};
let mut map: HashMap<NaiveDate, DailyStats> = HashMap::new();
let mut detail_all_params: Vec<Box<dyn rusqlite::ToSql>> =
vec![Box::new(start_ts), Box::new(end_ts)];
detail_all_params.extend(extra_params);
let detail_param_refs: Vec<&dyn rusqlite::ToSql> =
detail_all_params.iter().map(|p| p.as_ref()).collect();
let detail_rows = detail_stmt.query_map(detail_param_refs.as_slice(), detail_row_mapper)?;
for row in detail_rows {
let (bucket_date, stat) = row?;
let date = NaiveDate::parse_from_str(&bucket_date, "%Y-%m-%d")
.map_err(|err| AppError::Database(format!("解析趋势日期失败: {err}")))?;
map.insert(date, stat);
}
let rollup_bounds = compute_rollup_date_bounds(Some(start_ts), Some(end_ts))?;
let mut rollup_conditions = Vec::new();
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
push_rollup_date_filters(
&mut rollup_conditions,
&mut rollup_params,
"r.date",
&rollup_bounds,
);
if let Some(at) = app_type {
rollup_conditions.push(format!("{} = ?", folded_app_type_sql("r.app_type")));
rollup_params.push(Box::new(at.to_string()));
}
push_provider_model_filters(
&mut rollup_conditions,
&mut rollup_params,
"r",
"p2",
provider_name,
model,
);
let rollup_where = if rollup_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", rollup_conditions.join(" AND "))
};
let rollup_join = if provider_name.is_some() {
providers_join("r", "p2")
} else {
String::new()
};
let fresh_input_rollup = fresh_input_sql("r");
let rollup_sql = format!(
"SELECT
r.date,
COALESCE(SUM(r.request_count), 0),
COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0),
COALESCE(SUM({fresh_input_rollup} + r.output_tokens), 0),
COALESCE(SUM({fresh_input_rollup}), 0),
COALESCE(SUM(r.output_tokens), 0),
COALESCE(SUM(r.cache_creation_tokens), 0),
COALESCE(SUM(r.cache_read_tokens), 0)
FROM usage_daily_rollups r {rollup_join}
{rollup_where}
GROUP BY r.date
ORDER BY r.date ASC"
);
let mut rollup_stmt = conn.prepare(&rollup_sql)?;
let rollup_row_mapper = |row: &rusqlite::Row| {
Ok((
row.get::<_, String>(0)?,
(
row.get::<_, i64>(1)? as u64,
row.get::<_, f64>(2)?,
row.get::<_, i64>(3)? as u64,
row.get::<_, i64>(4)? as u64,
row.get::<_, i64>(5)? as u64,
row.get::<_, i64>(6)? as u64,
row.get::<_, i64>(7)? as u64,
),
))
};
let rollup_param_refs: Vec<&dyn rusqlite::ToSql> =
rollup_params.iter().map(|param| param.as_ref()).collect();
let rollup_rows = rollup_stmt.query_map(rollup_param_refs.as_slice(), rollup_row_mapper)?;
for row in rollup_rows {
let (bucket_date, (req, cost, tok, inp, out, cc, cr)) = row?;
let date = NaiveDate::parse_from_str(&bucket_date, "%Y-%m-%d")
.map_err(|err| AppError::Database(format!("解析 rollup 趋势日期失败: {err}")))?;
let entry = map.entry(date).or_insert_with(|| DailyStats {
date: String::new(),
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
entry.request_count += req;
let existing_cost: f64 = entry.total_cost.parse().unwrap_or(0.0);
entry.total_cost = format!("{:.6}", existing_cost + cost);
entry.total_tokens += tok;
entry.total_input_tokens += inp;
entry.total_output_tokens += out;
entry.total_cache_creation_tokens += cc;
entry.total_cache_read_tokens += cr;
}
let mut stats = Vec::with_capacity(bucket_count);
let mut current_day = start_day;
for _ in 0..bucket_count {
let date = local_day_start_rfc3339(current_day);
if let Some(mut stat) = map.remove(&current_day) {
stat.date = date;
stats.push(stat);
} else {
stats.push(DailyStats {
date,
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
}
current_day = current_day.succ_opt().unwrap_or(current_day);
}
Ok(stats)
}
/// 获取 Provider 统计
pub fn get_provider_stats(
&self,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<&str>,
provider_name: Option<&str>,
model: Option<&str>,
) -> Result<Vec<ProviderStats>, AppError> {
let conn = lock_conn!(self.conn);
let mut detail_conditions = vec![effective_usage_log_filter("l")];
let mut detail_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date {
detail_conditions.push("l.created_at >= ?".to_string());
detail_params.push(Box::new(start));
}
if let Some(end) = end_date {
detail_conditions.push("l.created_at <= ?".to_string());
detail_params.push(Box::new(end));
}
if let Some(at) = app_type {
detail_conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type")));
detail_params.push(Box::new(at.to_string()));
}
push_provider_model_filters(
&mut detail_conditions,
&mut detail_params,
"l",
"p",
provider_name,
model,
);
let detail_where = if detail_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", detail_conditions.join(" AND "))
};
let mut rollup_conditions = Vec::new();
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?;
push_rollup_date_filters(
&mut rollup_conditions,
&mut rollup_params,
"r.date",
&rollup_bounds,
);
if let Some(at) = app_type {
rollup_conditions.push(format!("{} = ?", folded_app_type_sql("r.app_type")));
rollup_params.push(Box::new(at.to_string()));
}
push_provider_model_filters(
&mut rollup_conditions,
&mut rollup_params,
"r",
"p2",
provider_name,
model,
);
let rollup_where = if rollup_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", rollup_conditions.join(" AND "))
};
// UNION detail logs + rollup data, then aggregate
let detail_pname = provider_name_coalesce("l", "p");
let rollup_pname = provider_name_coalesce("r", "p2");
let fresh_input_detail = fresh_input_sql("l");
let fresh_input_rollup = fresh_input_sql("r");
let sql = format!(
"SELECT
provider_id, app_type, provider_name,
SUM(request_count) as request_count,
SUM(total_tokens) as total_tokens,
SUM(total_cost) as total_cost,
SUM(success_count) as success_count,
CASE WHEN SUM(request_count) > 0
THEN SUM(latency_sum) / SUM(request_count)
ELSE 0 END as avg_latency
FROM (
SELECT l.provider_id, l.app_type,
{detail_pname} as provider_name,
COUNT(*) as request_count,
COALESCE(SUM({fresh_input_detail} + l.output_tokens), 0) as total_tokens,
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count,
COALESCE(SUM(l.latency_ms), 0) as latency_sum
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
{detail_where}
GROUP BY l.provider_id, l.app_type
UNION ALL
SELECT r.provider_id, r.app_type,
{rollup_pname} as provider_name,
COALESCE(SUM(r.request_count), 0),
COALESCE(SUM({fresh_input_rollup} + r.output_tokens), 0),
COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0),
COALESCE(SUM(r.success_count), 0),
COALESCE(SUM(r.avg_latency_ms * r.request_count), 0)
FROM usage_daily_rollups r
LEFT JOIN providers p2 ON r.provider_id = p2.id AND r.app_type = p2.app_type
{rollup_where}
GROUP BY r.provider_id, r.app_type
)
GROUP BY provider_id, app_type
ORDER BY total_cost DESC"
);
let mut stmt = conn.prepare(&sql)?;
let mut params: Vec<Box<dyn rusqlite::ToSql>> = detail_params;
params.extend(rollup_params);
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let row_mapper = |row: &rusqlite::Row| {
let request_count: i64 = row.get(3)?;
let success_count: i64 = row.get(6)?;
let success_rate = if request_count > 0 {
(success_count as f32 / request_count as f32) * 100.0
} else {
0.0
};
Ok(ProviderStats {
provider_id: row.get(0)?,
provider_name: row.get(2)?,
request_count: request_count as u64,
total_tokens: row.get::<_, i64>(4)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(5)?),
success_rate,
avg_latency_ms: row.get::<_, f64>(7)? as u64,
})
};
let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?;
let mut stats = Vec::new();
for row in rows {
stats.push(row?);
}
Ok(stats)
}
/// 获取模型统计
pub fn get_model_stats(
&self,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<&str>,
provider_name: Option<&str>,
model: Option<&str>,
) -> Result<Vec<ModelStats>, AppError> {
let conn = lock_conn!(self.conn);
let mut detail_conditions = vec![effective_usage_log_filter("l")];
let mut detail_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date {
detail_conditions.push("l.created_at >= ?".to_string());
detail_params.push(Box::new(start));
}
if let Some(end) = end_date {
detail_conditions.push("l.created_at <= ?".to_string());
detail_params.push(Box::new(end));
}
if let Some(at) = app_type {
detail_conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type")));
detail_params.push(Box::new(at.to_string()));
}
push_provider_model_filters(
&mut detail_conditions,
&mut detail_params,
"l",
"p",
provider_name,
model,
);
let detail_where = if detail_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", detail_conditions.join(" AND "))
};
let detail_join = if provider_name.is_some() {
providers_join("l", "p")
} else {
String::new()
};
let mut rollup_conditions = Vec::new();
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?;
push_rollup_date_filters(
&mut rollup_conditions,
&mut rollup_params,
"r.date",
&rollup_bounds,
);
if let Some(at) = app_type {
rollup_conditions.push(format!("{} = ?", folded_app_type_sql("r.app_type")));
rollup_params.push(Box::new(at.to_string()));
}
push_provider_model_filters(
&mut rollup_conditions,
&mut rollup_params,
"r",
"p2",
provider_name,
model,
);
let rollup_where = if rollup_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", rollup_conditions.join(" AND "))
};
let rollup_join = if provider_name.is_some() {
providers_join("r", "p2")
} else {
String::new()
};
// UNION detail logs + rollup data
//
// 分组键用「有效计价模型」:pricing_model 非空时优先(成本就是按它的
// 定价算的,金额与定价表自洽),NULL/'' 回落 model。默认 response 计价
// 模式下两者相同,行为不变;request 模式 + 路由接管下,钱挂在实际计价
// 基准名下,而不是上游回显/客户端别名名下。
let fresh_input_detail = fresh_input_sql("l");
let fresh_input_rollup = fresh_input_sql("r");
let detail_model = effective_model_sql("l");
let rollup_model = effective_model_sql("r");
let sql = format!(
"SELECT
model,
SUM(request_count) as request_count,
SUM(total_tokens) as total_tokens,
SUM(total_cost) as total_cost
FROM (
SELECT {detail_model} as model,
COUNT(*) as request_count,
COALESCE(SUM({fresh_input_detail} + l.output_tokens), 0) as total_tokens,
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost
FROM proxy_request_logs l
{detail_join}
{detail_where}
GROUP BY {detail_model}
UNION ALL
SELECT {rollup_model},
COALESCE(SUM(r.request_count), 0),
COALESCE(SUM({fresh_input_rollup} + r.output_tokens), 0),
COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0)
FROM usage_daily_rollups r
{rollup_join}
{rollup_where}
GROUP BY {rollup_model}
)
GROUP BY model
ORDER BY total_cost DESC"
);
let mut stmt = conn.prepare(&sql)?;
let mut params: Vec<Box<dyn rusqlite::ToSql>> = detail_params;
params.extend(rollup_params);
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let row_mapper = |row: &rusqlite::Row| {
let request_count: i64 = row.get(1)?;
let total_cost: f64 = row.get(3)?;
let avg_cost = if request_count > 0 {
total_cost / request_count as f64
} else {
0.0
};
Ok(ModelStats {
model: row.get(0)?,
request_count: request_count as u64,
total_tokens: row.get::<_, i64>(2)? as u64,
total_cost: format!("{total_cost:.6}"),
avg_cost_per_request: format!("{avg_cost:.6}"),
})
};
let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?;
let mut stats = Vec::new();
for row in rows {
stats.push(row?);
}
Ok(stats)
}
/// 获取请求日志列表(分页)
pub fn get_request_logs(
&self,
filters: &LogFilters,
page: u32,
page_size: u32,
) -> Result<PaginatedLogs, AppError> {
let conn = lock_conn!(self.conn);
let mut conditions = vec![effective_usage_log_filter("l")];
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(ref app_type) = filters.app_type {
// 仅过滤口径折叠 claude-desktop→claude;行投影仍返回原始 app_type
// 详情面板据此展示真实入口(路由接管账单审计需要)。
conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type")));
params.push(Box::new(app_type.clone()));
}
// 与 Dashboard 顶部下拉筛选同口径:Provider 按展示名精确匹配(会话占位
// 行如 "Claude (Session)" 也能命中),模型按有效计价模型匹配。
push_provider_model_filters(
&mut conditions,
&mut params,
"l",
"p",
filters.provider_name.as_deref(),
filters.model.as_deref(),
);
if let Some(status) = filters.status_code {
conditions.push("l.status_code = ?".to_string());
params.push(Box::new(status as i64));
}
if let Some(start) = filters.start_date {
conditions.push("l.created_at >= ?".to_string());
params.push(Box::new(start));
}
if let Some(end) = filters.end_date {
conditions.push("l.created_at <= ?".to_string());
params.push(Box::new(end));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
// 获取总数
let count_sql = format!(
"SELECT COUNT(*) FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
{where_clause}"
);
let count_params: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let total: u32 = conn.query_row(&count_sql, count_params.as_slice(), |row| {
row.get::<_, i64>(0).map(|v| v as u32)
})?;
// 获取数据
let offset = page * page_size;
params.push(Box::new(page_size as i64));
params.push(Box::new(offset as i64));
let logs_pname = provider_name_coalesce("l", "p");
let sql = format!(
"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,
l.input_token_semantics
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
{where_clause}
ORDER BY l.created_at DESC
LIMIT ? OFFSET ?"
);
let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(params_refs.as_slice(), row_to_request_log_detail)?;
let mut logs = Vec::new();
let mut pricing_cache = HashMap::new();
for row in rows {
let mut log = row?;
Self::maybe_backfill_log_costs(&conn, &mut log, &mut pricing_cache)?;
logs.push(log);
}
Ok(PaginatedLogs {
data: logs,
total,
page,
page_size,
})
}
/// 获取单个请求详情
pub fn get_request_detail(
&self,
request_id: &str,
) -> Result<Option<RequestLogDetail>, AppError> {
let conn = lock_conn!(self.conn);
let detail_pname = provider_name_coalesce("l", "p");
let detail_sql = format!(
"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,
l.input_token_semantics
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
WHERE l.request_id = ?"
);
let result = conn.query_row(&detail_sql, [request_id], row_to_request_log_detail);
match result {
Ok(mut detail) => {
let mut pricing_cache = HashMap::new();
Self::maybe_backfill_log_costs(&conn, &mut detail, &mut pricing_cache)?;
Ok(Some(detail))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 检查 Provider 使用限额
pub fn check_provider_limits(
&self,
provider_id: &str,
app_type: &str,
) -> Result<ProviderLimitStatus, AppError> {
let conn = lock_conn!(self.conn);
// 获取 provider 的限额设置
let (limit_daily, limit_monthly) = conn
.query_row(
"SELECT meta FROM providers WHERE id = ? AND app_type = ?",
params![provider_id, app_type],
|row| {
let meta_str: String = row.get(0)?;
Ok(meta_str)
},
)
.ok()
.and_then(|meta_str| serde_json::from_str::<serde_json::Value>(&meta_str).ok())
.map(|meta| {
let daily = meta
.get("limitDailyUsd")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok());
let monthly = meta
.get("limitMonthlyUsd")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok());
(daily, monthly)
})
.unwrap_or((None, None));
// 计算今日使用量 (detail logs + rollup)
let daily_usage: f64 = conn
.query_row(
"SELECT COALESCE(SUM(cost), 0) FROM (
SELECT CAST(total_cost_usd AS REAL) as cost
FROM proxy_request_logs
WHERE provider_id = ? AND app_type = ?
AND date(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')
UNION ALL
SELECT CAST(total_cost_usd AS REAL)
FROM usage_daily_rollups
WHERE provider_id = ? AND app_type = ?
AND date = date('now', 'localtime')
)",
params![provider_id, app_type, provider_id, app_type],
|row| row.get(0),
)
.unwrap_or(0.0);
// 计算本月使用量 (detail logs + rollup)
let monthly_usage: f64 = conn
.query_row(
"SELECT COALESCE(SUM(cost), 0) FROM (
SELECT CAST(total_cost_usd AS REAL) as cost
FROM proxy_request_logs
WHERE provider_id = ? AND app_type = ?
AND strftime('%Y-%m', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')
UNION ALL
SELECT CAST(total_cost_usd AS REAL)
FROM usage_daily_rollups
WHERE provider_id = ? AND app_type = ?
AND strftime('%Y-%m', date) = strftime('%Y-%m', 'now', 'localtime')
)",
params![provider_id, app_type, provider_id, app_type],
|row| row.get(0),
)
.unwrap_or(0.0);
let daily_exceeded = limit_daily
.map(|limit| daily_usage >= limit)
.unwrap_or(false);
let monthly_exceeded = limit_monthly
.map(|limit| monthly_usage >= limit)
.unwrap_or(false);
Ok(ProviderLimitStatus {
provider_id: provider_id.to_string(),
daily_usage: format!("{daily_usage:.6}"),
daily_limit: limit_daily.map(|l| format!("{l:.2}")),
daily_exceeded,
monthly_usage: format!("{monthly_usage:.6}"),
monthly_limit: limit_monthly.map(|l| format!("{l:.2}")),
monthly_exceeded,
})
}
}
/// Provider 限额状态
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderLimitStatus {
pub provider_id: String,
pub daily_usage: String,
pub daily_limit: Option<String>,
pub daily_exceeded: bool,
pub monthly_usage: String,
pub monthly_limit: Option<String>,
pub monthly_exceeded: bool,
}
#[derive(Clone)]
struct PricingInfo {
input: rust_decimal::Decimal,
output: rust_decimal::Decimal,
cache_read: rust_decimal::Decimal,
cache_creation: rust_decimal::Decimal,
}
impl Database {
/// Recalculate stored zero-cost usage rows once pricing becomes available.
pub(crate) fn backfill_missing_usage_costs(&self) -> Result<u64, AppError> {
let conn = lock_conn!(self.conn);
Self::backfill_missing_usage_costs_on_conn(&conn, None)
}
/// 仅回填指定 model_id 相关的零成本行;用于单条定价更新后的精准回填。
pub(crate) fn backfill_missing_usage_costs_for_model(
&self,
model_id: &str,
) -> Result<u64, AppError> {
let conn = lock_conn!(self.conn);
Self::backfill_missing_usage_costs_on_conn(&conn, Some(model_id))
}
pub(crate) fn backfill_missing_usage_costs_on_conn(
conn: &Connection,
only_model_id: Option<&str>,
) -> Result<u64, AppError> {
const BASE_SQL: &str =
"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,
data_source, pricing_model, input_token_semantics
FROM proxy_request_logs
WHERE CAST(total_cost_usd AS REAL) <= 0
AND (input_tokens > 0 OR output_tokens > 0
OR cache_read_tokens > 0 OR cache_creation_tokens > 0)";
let mut logs = {
let mut stmt = conn.prepare(BASE_SQL)?;
let rows = stmt.query_map([], row_to_request_log_detail)?;
rows.collect::<Result<Vec<_>, _>>()?
};
// 精准回填的行筛选必须与查价层共用 candidates 归一化:SQL 精确匹配会漏掉
// 以原始别名落库的行(如 openrouter/anthropic/claude-sonnet-4.5:free),
// 这些行查价时能归一化命中新定价,却在筛选层被挡掉,导致导入定价后
// 历史成本要等下次全量回填才更新。误纳无害——查不到价的行会被跳过。
if let Some(model_id) = only_model_id {
let target = model_pricing_candidates(model_id);
logs.retain(|log| log_pricing_scope_matches(log, &target));
}
if logs.is_empty() {
return Ok(0);
}
let tx = conn
.unchecked_transaction()
.map_err(|e| AppError::Database(format!("启动用量成本回填事务失败: {e}")))?;
let mut updated = 0u64;
let mut pricing_cache = HashMap::new();
for log in &mut logs {
if Self::maybe_backfill_log_costs(&tx, log, &mut pricing_cache)? {
updated += 1;
}
}
tx.commit()
.map_err(|e| AppError::Database(format!("提交用量成本回填事务失败: {e}")))?;
if updated > 0 {
log::info!("已回填 {updated} 条缺失的用量成本");
}
Ok(updated)
}
/// 尝试为单条 log 回填成本字段。返回是否实际写入(true=已 UPDATEfalse=跳过)。
fn maybe_backfill_log_costs(
conn: &Connection,
log: &mut RequestLogDetail,
pricing_cache: &mut HashMap<String, PricingInfo>,
) -> Result<bool, AppError> {
let existing_cost = rust_decimal::Decimal::from_str(&log.total_cost_usd)
.unwrap_or(rust_decimal::Decimal::ZERO);
let has_cost = existing_cost > rust_decimal::Decimal::ZERO;
let has_usage = log.input_tokens > 0
|| log.output_tokens > 0
|| log.cache_read_tokens > 0
|| log.cache_creation_tokens > 0;
if has_cost || !has_usage {
return Ok(false);
}
let pricing = match Self::get_log_model_pricing_cached(conn, pricing_cache, log)? {
Some(info) => info,
None => return Ok(false),
};
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_for_app 保持一致的计算逻辑:
// 1. 历史 Codex/Gemini 行只包含 cache read;新 total 行还包含 cache write。
// 2. Claude/Anthropic 的 input_tokens 已经是 fresh input,不能再次扣减
// 3. 各项成本是基础成本(不含倍率),倍率只作用于最终总价
let cache_inclusive_app = matches!(log.app_type.as_str(), "codex" | "gemini");
let billable_input_tokens =
if !cache_inclusive_app || log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH {
log.input_tokens as u64
} else if log.input_token_semantics == INPUT_TOKEN_SEMANTICS_TOTAL {
(log.input_tokens as u64)
.saturating_sub(log.cache_read_tokens as u64)
.saturating_sub(log.cache_creation_tokens as u64)
} else {
// v12 and earlier: input included cache reads but excluded cache writes.
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64)
};
let input_cost =
rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;
let output_cost =
rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output / million;
let cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64)
* pricing.cache_read
/ million;
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;
let total_cost = base_total * multiplier;
log.input_cost_usd = format!("{input_cost:.6}");
log.output_cost_usd = format!("{output_cost:.6}");
log.cache_read_cost_usd = format!("{cache_read_cost:.6}");
log.cache_creation_cost_usd = format!("{cache_creation_cost:.6}");
log.total_cost_usd = format!("{total_cost:.6}");
conn.execute(
"UPDATE proxy_request_logs
SET input_cost_usd = ?1,
output_cost_usd = ?2,
cache_read_cost_usd = ?3,
cache_creation_cost_usd = ?4,
total_cost_usd = ?5
WHERE request_id = ?6",
params![
log.input_cost_usd,
log.output_cost_usd,
log.cache_read_cost_usd,
log.cache_creation_cost_usd,
log.total_cost_usd,
log.request_id
],
)
.map_err(|e| AppError::Database(format!("更新请求成本失败: {e}")))?;
Ok(true)
}
fn get_model_pricing_cached(
conn: &Connection,
cache: &mut HashMap<String, PricingInfo>,
model: &str,
) -> Result<Option<PricingInfo>, AppError> {
if let Some(info) = cache.get(model) {
return Ok(Some(info.clone()));
}
let row = find_model_pricing_row(conn, model)?;
let Some((input, output, cache_read, cache_creation)) = row else {
return Ok(None);
};
let pricing = PricingInfo {
input: rust_decimal::Decimal::from_str(&input)
.map_err(|e| AppError::Database(format!("解析输入价格失败: {e}")))?,
output: rust_decimal::Decimal::from_str(&output)
.map_err(|e| AppError::Database(format!("解析输出价格失败: {e}")))?,
cache_read: rust_decimal::Decimal::from_str(&cache_read)
.map_err(|e| AppError::Database(format!("解析缓存读取价格失败: {e}")))?,
cache_creation: rust_decimal::Decimal::from_str(&cache_creation)
.map_err(|e| AppError::Database(format!("解析缓存写入价格失败: {e}")))?,
};
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> {
// 写入时的计价基准已落库(v11+):回填只按它重算,找不到就保持 0 成本
// 等补价。不能换用 model/request_model 猜——路由接管 + request 计价模式下
// 三者可能各不相同(model=上游回显、request_model=客户端别名、
// pricing_model=实际出站模型),换基准会按错误价格永久固化。
// 占位符("" = 未计价错误行 / "unknown")视同缺失,走历史行逻辑。
if let Some(pricing_model) = log
.pricing_model
.as_deref()
.filter(|pm| !is_placeholder_pricing_model(pm))
{
return Self::get_model_pricing_cached(conn, cache, pricing_model);
}
if let Some(pricing) = Self::get_model_pricing_cached(conn, cache, &log.model)? {
return Ok(Some(pricing));
}
// 仅当 model 列是占位符(解析失败留下的 ""/"unknown" 等)时才回退到
// request_model 定价。model 是真实模型名但缺定价时必须保持 0 成本等待
// 补价:路由接管下 request_model 是客户端别名(如 claude-sonnet-4-6),
// 按别名回填会把真实上游模型的 tokens 按错误价格永久固化(行一旦有成本
// 就不再进入回填范围)。
if !is_placeholder_pricing_model(&log.model) {
return Ok(None);
}
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(conn: &Connection, model_id: &str) -> Option<ModelPricing> {
find_model_pricing_row(conn, model_id)
.ok()
.flatten()
.and_then(|(input, output, cache_read, cache_creation)| {
ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation).ok()
})
}
pub(crate) fn find_model_pricing_row(
conn: &Connection,
model_id: &str,
) -> Result<Option<(String, String, String, String)>, AppError> {
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)
}
/// 精准回填的行筛选:log 的任一模型字段归一化后与目标模型的 candidates 相交,
/// 或可按查价层的前缀规则命中目标,即视为相关。镜像 find_model_pricing_row 的
/// 匹配语义,宁可误纳(后续查价会兜底)不可漏筛。
fn log_pricing_scope_matches(log: &RequestLogDetail, target_candidates: &[String]) -> bool {
[
Some(log.model.as_str()),
log.request_model.as_deref(),
log.pricing_model.as_deref(),
]
.into_iter()
.flatten()
.any(|field| {
model_pricing_candidates(field).iter().any(|candidate| {
target_candidates.iter().any(|target| {
target == candidate
|| (should_try_pricing_prefix_match(candidate)
&& target
.strip_prefix(candidate.as_str())
.is_some_and(|rest| rest.starts_with('-')))
})
})
})
}
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_model_date_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(':')
.next()
.unwrap_or(model_id)
.trim()
.replace('@', "-")
.to_ascii_lowercase();
normalized
.trim_end_matches(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)
.trim()
.to_string()
}
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());
}
}
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_model_date_suffix(model_id: &str) -> Option<String> {
let bytes = model_id.as_bytes();
if bytes.len() > 11 {
let start = bytes.len() - 11;
let suffix = &bytes[start..];
let is_iso_date = suffix[0] == b'-'
&& suffix[1..5].iter().all(|b| b.is_ascii_digit())
&& suffix[5] == b'-'
&& suffix[6..8].iter().all(|b| b.is_ascii_digit())
&& suffix[8] == b'-'
&& suffix[9..11].iter().all(|b| b.is_ascii_digit());
if is_iso_date {
return Some(model_id[..start].to_string());
}
}
let (base, suffix) = model_id.rsplit_once('-')?;
if base.is_empty() || !suffix.chars().all(|c| c.is_ascii_digit()) {
return None;
}
// 8 位 YYYYMMDD(如 -20250615OpenAI / Claude / 通义千问等)。
if suffix.len() == 8 {
return Some(base.to_string());
}
// 6 位 YYMMDD(如 -260628;火山方舟 doubao-seed-*、部分国产厂商)。
// 6 位比 8 位更易误伤非日期尾巴(如 -123456 的版本号),故额外校验
// 月 01-12、日 01-31 才剥离;剥不动时退回 None 由上层精确匹配兜底。
if suffix.len() == 6 {
let month: u32 = suffix[2..4].parse().unwrap_or(0);
let day: u32 = suffix[4..6].parse().unwrap_or(0);
if (1..=12).contains(&month) && (1..=31).contains(&day) {
return Some(base.to_string());
}
}
None
}
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 {
let dash_count = model_id.matches('-').count();
if model_id.starts_with("claude-") {
return dash_count >= 3;
}
if ["o1", "o3", "o4", "o5"]
.iter()
.any(|prefix| model_id.starts_with(prefix))
{
return dash_count >= 1;
}
const PREFIX_MATCH_FAMILIES: &[&str] = &[
"gpt-",
"gemini-",
"deepseek-",
"qwen-",
"glm-",
"kimi-",
"minimax-",
];
PREFIX_MATCH_FAMILIES
.iter()
.any(|prefix| model_id.starts_with(prefix))
&& dash_count >= 2
}
#[cfg(test)]
mod tests {
use super::*;
fn local_ts(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> i64 {
match Local.with_ymd_and_hms(year, month, day, hour, minute, second) {
chrono::LocalResult::Single(dt) => dt.timestamp(),
chrono::LocalResult::Ambiguous(earliest, _) => earliest.timestamp(),
chrono::LocalResult::None => panic!("valid local datetime"),
}
}
#[allow(clippy::too_many_arguments)]
fn insert_usage_log(
conn: &Connection,
request_id: &str,
app_type: &str,
provider_id: &str,
model: &str,
data_source: &str,
created_at: i64,
input_tokens: i64,
output_tokens: i64,
cache_read_tokens: i64,
cache_creation_tokens: i64,
status_code: i64,
total_cost_usd: &str,
) -> Result<(), AppError> {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, '0', '0', '0', '0', ?, 100, ?, ?, ?)",
params![
request_id,
provider_id,
app_type,
model,
model,
input_tokens,
output_tokens,
cache_read_tokens,
cache_creation_tokens,
total_cost_usd,
status_code,
created_at,
data_source
],
)?;
Ok(())
}
fn create_legacy_nullable_logs_table(conn: &Connection) -> Result<(), AppError> {
conn.execute(
"CREATE TABLE proxy_request_logs (
request_id TEXT PRIMARY KEY,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
cache_read_tokens INTEGER NOT NULL,
cache_creation_tokens INTEGER NOT NULL,
status_code INTEGER NOT NULL,
created_at INTEGER NOT NULL,
data_source TEXT
)",
[],
)?;
Ok(())
}
#[test]
fn test_effective_filter_keeps_legacy_null_data_source_proxy_rows() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
create_legacy_nullable_logs_table(&conn)?;
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, app_type, model, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source
) VALUES ('legacy-proxy', 'codex', 'gpt-5.5', 10, 2, 1, 0, 200, 1000, NULL)",
[],
)?;
let filter = effective_usage_log_filter("l");
let sql = format!("SELECT COUNT(*) FROM proxy_request_logs l WHERE {filter}");
let count: i64 = conn.query_row(&sql, [], |row| row.get(0))?;
assert_eq!(count, 1);
Ok(())
}
#[test]
fn test_matching_proxy_log_treats_legacy_null_data_source_as_proxy() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
create_legacy_nullable_logs_table(&conn)?;
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, app_type, model, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source
) VALUES ('legacy-proxy', 'codex', 'gpt-5.5', 10, 2, 1, 0, 200, 1000, NULL)",
[],
)?;
let key = DedupKey {
app_type: "codex",
model: "gpt-5.5",
input_tokens: 10,
output_tokens: 2,
cache_read_tokens: 1,
cache_creation_tokens: 0,
created_at: 1000,
};
assert!(has_matching_proxy_usage_log(&conn, &key)?);
Ok(())
}
#[test]
fn test_claude_desktop_folds_into_claude_for_display() -> Result<(), AppError> {
let db = Database::memory()?;
let ts = local_ts(2026, 6, 10, 12, 0, 0);
{
let conn = lock_conn!(db.conn);
// 一条 Claude Code 行 + 一条 Claude Desktop 网关行,同一时间窗。
insert_usage_log(
&conn,
"cc-1",
"claude",
"p-claude",
"claude-sonnet-4-5",
"proxy",
ts,
100,
10,
0,
0,
200,
"0.5",
)?;
insert_usage_log(
&conn,
"cd-1",
"claude-desktop",
"p-desktop",
"claude-opus-4-8",
"proxy",
ts,
200,
20,
0,
0,
200,
"1.5",
)?;
}
// ① 分应用汇总:desktop 折叠进 claude,不再单列 claude-desktop 桶。
let by_app = db.get_usage_summary_by_app(None, None, None, None)?;
assert_eq!(by_app.len(), 1, "应只剩一个合并后的 claude 桶");
assert_eq!(by_app[0].app_type, "claude");
assert_eq!(by_app[0].summary.total_requests, 2, "两条行都计入 claude");
assert!(
!by_app.iter().any(|a| a.app_type == "claude-desktop"),
"不应再出现 claude-desktop 桶"
);
// ② 选中 claude 过滤:汇总应同时覆盖 desktop 行。
let claude_summary = db.get_usage_summary(None, None, Some("claude"), None, None)?;
assert_eq!(claude_summary.total_requests, 2);
// ③ 请求日志按 claude 过滤返回两行,且 desktop 行投影仍是原始 app_type。
let logs = db.get_request_logs(
&LogFilters {
app_type: Some("claude".to_string()),
..Default::default()
},
0, // 页码从 0 开始
50,
)?;
assert_eq!(logs.total, 2, "claude 过滤含 desktop 行");
assert!(
logs.data.iter().any(|r| r.app_type == "claude-desktop"),
"详情面板需要看到真实入口,行投影不可被折叠"
);
// ④ 折叠不外溢:codex 过滤为空。
let codex_summary = db.get_usage_summary(None, None, Some("codex"), None, None)?;
assert_eq!(codex_summary.total_requests, 0);
Ok(())
}
#[test]
fn test_backfill_missing_usage_costs_uses_new_gpt_5_5_pricing() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
insert_usage_log(
&conn,
"codex-gpt-5-5-zero-cost",
"codex",
"_codex_session",
"gpt-5.5",
"codex_session",
1000,
1_000_000,
1_000_000,
0,
0,
200,
"0",
)?;
}
assert_eq!(db.backfill_missing_usage_costs()?, 1);
let conn = lock_conn!(db.conn);
let (input_cost, output_cost, total_cost): (String, String, String) = conn.query_row(
"SELECT input_cost_usd, output_cost_usd, total_cost_usd
FROM proxy_request_logs WHERE request_id = 'codex-gpt-5-5-zero-cost'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(input_cost, "5.000000");
assert_eq!(output_cost, "30.000000");
assert_eq!(total_cost, "35.000000");
Ok(())
}
#[test]
fn test_backfill_distinguishes_legacy_and_total_cache_semantics() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
// v12 mirror row: input = fresh + read; creation was reported separately.
insert_usage_log(
&conn,
"legacy-cache-semantics",
"codex",
"p1",
"gpt-5.5",
"proxy",
1000,
800_000,
0,
600_000,
200_000,
200,
"0",
)?;
// v13 proxy row: input = fresh + read + creation.
insert_usage_log(
&conn,
"total-cache-semantics",
"codex",
"p1",
"gpt-5.5",
"proxy",
1001,
1_000_000,
0,
600_000,
200_000,
200,
"0",
)?;
conn.execute(
"UPDATE proxy_request_logs
SET input_token_semantics = ?1
WHERE request_id = 'total-cache-semantics'",
[INPUT_TOKEN_SEMANTICS_TOTAL],
)?;
}
assert_eq!(db.backfill_missing_usage_costs()?, 2);
let conn = lock_conn!(db.conn);
let mut stmt = conn.prepare(
"SELECT request_id, input_cost_usd
FROM proxy_request_logs
WHERE request_id IN ('legacy-cache-semantics', 'total-cache-semantics')
ORDER BY request_id",
)?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(
rows,
vec![
("legacy-cache-semantics".to_string(), "1.000000".to_string()),
("total-cache-semantics".to_string(), "1.000000".to_string()),
]
);
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_skips_request_model_fallback_for_real_unpriced_model() -> Result<(), AppError>
{
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
// 路由接管场景:model 是上游回显的真实模型(缺定价),request_model
// 是客户端别名(有定价)。回填不得按别名定价,必须保持 0 成本等待补价。
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 (
'takeover-unpriced-model', 'provider-1', 'claude',
'takeover-real-model-unpriced', 'claude-sonnet-4-6',
1000000, 0, 0, 0,
'0', '0', '0', '0',
'0', 100, 200, 1000, 'proxy'
)",
[],
)?;
}
// request_modelclaude-sonnet-4-6)有定价,但 model 是真实模型名:不得回退
assert_eq!(db.backfill_missing_usage_costs()?, 0);
{
let conn = lock_conn!(db.conn);
let total_cost: String = conn.query_row(
"SELECT total_cost_usd
FROM proxy_request_logs WHERE request_id = 'takeover-unpriced-model'",
[],
|row| row.get(0),
)?;
assert_eq!(total_cost, "0");
// 补上真实模型定价后,回填必须按真实模型价格修复(0 成本行未被污染固化)
conn.execute(
"INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES ('takeover-real-model-unpriced', 'Takeover Real Model', '0.6', '2.5')",
[],
)?;
}
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 = 'takeover-unpriced-model'",
[],
|row| row.get(0),
)?;
assert_eq!(total_cost, "0.600000");
Ok(())
}
#[test]
fn test_backfill_uses_persisted_pricing_model() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
// request 计价模式 + 接管:写入时锚定出站模型 kimi-k2-novel(当时缺价),
// 但上游回显了别名 → model/request_model 都是 claude-sonnet-4-6(有定价)。
// 回填必须按落库的 pricing_model 重算,不得换用 model 列的别名价格。
conn.execute(
"INSERT 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_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 (
'persisted-pricing-model', 'provider-1', 'claude',
'claude-sonnet-4-6', 'claude-sonnet-4-6', 'kimi-k2-novel',
1000000, 0, 0, 0,
'0', '0', '0', '0',
'0', 100, 200, 1000, 'proxy'
)",
[],
)?;
}
// pricing_modelkimi-k2-novel)缺价:不得回退到 model 列的别名价格
assert_eq!(db.backfill_missing_usage_costs()?, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES ('kimi-k2-novel', 'Kimi K2 Novel', '0.6', '2.5')",
[],
)?;
}
// 按 pricing_model 也能定位到该行(model/request_model 都不是 kimi-k2-novel
assert_eq!(
db.backfill_missing_usage_costs_for_model("kimi-k2-novel")?,
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 = 'persisted-pricing-model'",
[],
|row| row.get(0),
)?;
assert_eq!(total_cost, "0.600000");
Ok(())
}
#[test]
fn test_scoped_backfill_matches_raw_alias_rows() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
// 代理日志按上游原文落库:带路由前缀和 :free 后缀的别名形式。
// 精准回填的筛选必须归一化后匹配,否则这类行要等全量回填才更新。
insert_usage_log(
&conn,
"openrouter-alias-zero-cost",
"claude",
"provider-1",
"openrouter/moonshot/kimi-k2-novel:free",
"proxy",
1000,
1_000_000,
0,
0,
0,
200,
"0",
)?;
}
// 定价缺失时不应回填
assert_eq!(db.backfill_missing_usage_costs()?, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES ('kimi-k2-novel', 'Kimi K2 Novel', '0.6', '2.5')",
[],
)?;
}
// 按归一化 ID 精准回填,应命中以原始别名落库的行
assert_eq!(
db.backfill_missing_usage_costs_for_model("kimi-k2-novel")?,
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 = 'openrouter-alias-zero-cost'",
[],
|row| row.get(0),
)?;
assert_eq!(total_cost, "0.600000");
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_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()?;
// 插入测试数据
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params!["req1", "p1", "claude", "claude-3", 100, 50, "0.01", 100, 200, 1000],
)?;
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params!["req2", "p1", "claude", "claude-3", 200, 100, "0.02", 150, 200, 2000],
)?;
}
let summary = db.get_usage_summary(None, None, None, None, None)?;
assert_eq!(summary.total_requests, 2);
assert_eq!(summary.success_rate, 100.0);
Ok(())
}
#[test]
fn test_get_usage_summary_excludes_partial_rollup_boundary_days() -> Result<(), AppError> {
let db = Database::memory()?;
let start = local_ts(2024, 1, 1, 12, 0, 0);
let end = local_ts(2024, 1, 3, 12, 0, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-01-01",
"claude",
"p1",
"claude-3",
10,
10,
1000,
500,
0,
0,
"1.00",
100
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-01-02",
"claude",
"p1",
"claude-3",
20,
19,
2000,
1000,
0,
0,
"2.00",
120
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-01-03",
"claude",
"p1",
"claude-3",
30,
29,
3000,
1500,
0,
0,
"3.00",
140
],
)?;
}
let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"), None, None)?;
assert_eq!(summary.total_requests, 20);
assert_eq!(summary.total_input_tokens, 2000);
assert_eq!(summary.total_output_tokens, 1000);
Ok(())
}
#[test]
fn test_provider_and_model_filters_cover_detail_and_rollup() -> Result<(), AppError> {
let db = Database::memory()?;
let detail_ts = local_ts(2026, 6, 10, 12, 0, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO providers (id, app_type, name, settings_config) VALUES
('prov-a', 'claude', 'Packy', '{}'),
('prov-b', 'claude', 'DeepSeek', '{}')",
[],
)?;
insert_usage_log(
&conn,
"a-1",
"claude",
"prov-a",
"claude-sonnet-4-6",
"proxy",
detail_ts,
100,
10,
0,
0,
200,
"1.0",
)?;
insert_usage_log(
&conn,
"b-1",
"claude",
"prov-b",
"deepseek-v3",
"proxy",
detail_ts,
200,
20,
0,
0,
200,
"2.0",
)?;
// 会话占位行:providers 表无此 id,展示名走 CASE 映射。
insert_usage_log(
&conn,
"s-1",
"claude",
"_session",
"claude-sonnet-4-6",
"session_log",
detail_ts,
999,
99,
0,
0,
200,
"0.5",
)?;
// 计价模型与请求模型不同的行:模型筛选必须按有效计价模型命中。
insert_usage_log(
&conn,
"a-2",
"claude",
"prov-a",
"alias-model",
"proxy",
detail_ts,
50,
5,
0,
0,
200,
"0.3",
)?;
conn.execute(
"UPDATE proxy_request_logs SET pricing_model = 'real-model' WHERE request_id = 'a-2'",
[],
)?;
// rollup 历史日行:无范围过滤时全部计入。
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES
('2026-06-08', 'claude', 'prov-a', 'claude-sonnet-4-6', 5, 5, 500, 50, 0, 0, '5.0', 100),
('2026-06-08', 'claude', 'prov-b', 'deepseek-v3', 7, 7, 700, 70, 0, 0, '7.0', 100)",
[],
)?;
}
// ① 汇总按 Provider 展示名过滤:明细 + rollup 都命中。
let packy = db.get_usage_summary(None, None, None, Some("Packy"), None)?;
assert_eq!(packy.total_requests, 7, "a-1 + a-2 + rollup 5");
// ② 汇总按模型过滤(有效计价模型口径)。
let deepseek = db.get_usage_summary(None, None, None, None, Some("deepseek-v3"))?;
assert_eq!(deepseek.total_requests, 8, "b-1 + rollup 7");
// ③ pricing_model 优先于 modelalias-model 查不到,real-model 查得到。
let by_alias = db.get_usage_summary(None, None, None, None, Some("alias-model"))?;
assert_eq!(by_alias.total_requests, 0);
let by_real = db.get_usage_summary(None, None, None, None, Some("real-model"))?;
assert_eq!(by_real.total_requests, 1);
// ④ 会话占位行可按可读名选中。
let session = db.get_usage_summary(None, None, None, Some("Claude (Session)"), None)?;
assert_eq!(session.total_requests, 1);
// ⑤ Provider 统计 + 模型过滤:只剩 DeepSeek 一行。
let provider_stats = db.get_provider_stats(None, None, None, None, Some("deepseek-v3"))?;
assert_eq!(provider_stats.len(), 1);
assert_eq!(provider_stats[0].provider_name, "DeepSeek");
assert_eq!(provider_stats[0].request_count, 8);
// ⑥ 模型统计 + Provider 过滤:只剩 Packy 名下的模型。
let model_stats = db.get_model_stats(None, None, None, Some("Packy"), None)?;
let models: Vec<&str> = model_stats.iter().map(|m| m.model.as_str()).collect();
assert!(models.contains(&"claude-sonnet-4-6"));
assert!(models.contains(&"real-model"));
assert!(!models.contains(&"deepseek-v3"));
// ⑦ 分应用汇总(Hero 卡片数据源)同样受过滤影响。
let by_app = db.get_usage_summary_by_app(None, None, Some("Packy"), None)?;
assert_eq!(by_app.len(), 1);
assert_eq!(by_app[0].app_type, "claude");
assert_eq!(by_app[0].summary.total_requests, 7);
// ⑧ 趋势(>24h 走天分桶 + rollup 分支)。
let t_start = local_ts(2026, 6, 8, 0, 0, 0);
let t_end = local_ts(2026, 6, 10, 23, 59, 0);
let trends = db.get_daily_trends(Some(t_start), Some(t_end), None, Some("Packy"), None)?;
let total_req: u64 = trends.iter().map(|d| d.request_count).sum();
assert_eq!(total_req, 7, "明细 2 + rollup 5");
// ⑨ 趋势 ≤24h 走小时分桶分支(?1/?2/?3 编号参数与追加过滤混用的路径),
// 同时验证 Provider + 模型组合过滤。
let h_start = local_ts(2026, 6, 10, 0, 0, 0);
let h_end = local_ts(2026, 6, 10, 20, 0, 0);
let hourly = db.get_daily_trends(
Some(h_start),
Some(h_end),
None,
Some("Packy"),
Some("claude-sonnet-4-6"),
)?;
let hourly_req: u64 = hourly.iter().map(|d| d.request_count).sum();
assert_eq!(hourly_req, 1, "仅 a-1 命中(a-2 计价模型不同)");
// ⑩ 请求日志列表与下拉同口径:精确名 + 有效计价模型。
let logs = db.get_request_logs(
&LogFilters {
provider_name: Some("Packy".to_string()),
model: Some("real-model".to_string()),
..Default::default()
},
0,
10,
)?;
assert_eq!(logs.total, 1);
assert_eq!(logs.data[0].request_id, "a-2");
Ok(())
}
#[test]
fn test_get_usage_summary_includes_end_day_rollup_for_minute_precision_end_time(
) -> Result<(), AppError> {
let db = Database::memory()?;
let start = local_ts(2024, 1, 1, 0, 0, 0);
let end = local_ts(2024, 1, 2, 23, 59, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-01-01",
"claude",
"p1",
"claude-3",
10,
10,
1000,
500,
0,
0,
"1.00",
100
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-01-02",
"claude",
"p1",
"claude-3",
20,
19,
2000,
1000,
0,
0,
"2.00",
120
],
)?;
}
let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"), None, None)?;
assert_eq!(summary.total_requests, 30);
assert_eq!(summary.total_input_tokens, 3000);
assert_eq!(summary.total_output_tokens, 1500);
Ok(())
}
#[test]
fn test_effective_usage_dedup_prefers_proxy_for_session_sources() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
insert_usage_log(
&conn,
"codex-proxy",
"codex",
"openai",
"GPT-5.4",
"proxy",
10_000,
100,
20,
10,
7,
200,
"0.10",
)?;
insert_usage_log(
&conn,
"codex-session-dup",
"codex",
"_codex_session",
"gpt-5.4",
"codex_session",
10_060,
100,
20,
10,
0,
200,
"0.10",
)?;
insert_usage_log(
&conn,
"claude-proxy",
"claude",
"openai-compatible",
"claude-sonnet-4-5",
"proxy",
25_000,
300,
60,
20,
5,
200,
"0.30",
)?;
insert_usage_log(
&conn,
"claude-session-dup",
"claude",
"_session",
"claude-sonnet-4-5",
"session_log",
25_060,
300,
60,
20,
5,
200,
"0.30",
)?;
insert_usage_log(
&conn,
"gemini-proxy",
"gemini",
"google",
"gemini-2.5-pro",
"proxy",
20_000,
200,
40,
30,
0,
200,
"0.20",
)?;
insert_usage_log(
&conn,
"gemini-session-dup",
"gemini",
"_gemini_session",
"gemini-2.5-pro",
"gemini_session",
20_060,
200,
40,
30,
0,
200,
"0.20",
)?;
insert_usage_log(
&conn,
"codex-session-only",
"codex",
"_codex_session",
"gpt-5.4",
"codex_session",
30_000,
50,
5,
0,
0,
200,
"0.02",
)?;
}
let summary = db.get_usage_summary(None, None, None, None, None)?;
assert_eq!(summary.total_requests, 4);
// codex-proxy contributes 100-10=90; gemini-proxy contributes 200-30=170
// (both cache-inclusive providers). claude-proxy=300, codex-session-only=50.
// 90 + 170 + 300 + 50 = 610.
assert_eq!(summary.total_input_tokens, 610);
assert_eq!(summary.total_output_tokens, 125);
assert_eq!(summary.total_cache_read_tokens, 60);
assert_eq!(summary.total_cache_creation_tokens, 12);
// real_total = fresh_input(610) + output(125) + cache_create(12) + cache_read(60) = 807
assert_eq!(summary.real_total_tokens, 807);
// hit_rate = 60 / (610 + 12 + 60) = 60 / 682
let expected_hit_rate = 60.0_f64 / 682.0_f64;
assert!((summary.cache_hit_rate - expected_hit_rate).abs() < 1e-9);
let trends = db.get_daily_trends(Some(0), Some(40_000), None, None, None)?;
assert_eq!(trends.iter().map(|stat| stat.request_count).sum::<u64>(), 4);
let provider_stats = db.get_provider_stats(None, None, None, None, None)?;
assert_eq!(
provider_stats
.iter()
.map(|stat| stat.request_count)
.sum::<u64>(),
4
);
assert!(provider_stats
.iter()
.any(|stat| stat.provider_id == "_codex_session" && stat.request_count == 1));
assert!(!provider_stats
.iter()
.any(|stat| stat.provider_id == "_gemini_session"));
assert!(!provider_stats
.iter()
.any(|stat| stat.provider_id == "_session"));
let model_stats = db.get_model_stats(None, None, None, None, None)?;
assert_eq!(
model_stats
.iter()
.map(|stat| stat.request_count)
.sum::<u64>(),
4
);
let logs = db.get_request_logs(&LogFilters::default(), 0, 10)?;
let request_ids: Vec<&str> = logs
.data
.iter()
.map(|log| log.request_id.as_str())
.collect();
assert_eq!(logs.total, 4);
assert!(request_ids.contains(&"codex-proxy"));
assert!(request_ids.contains(&"claude-proxy"));
assert!(request_ids.contains(&"gemini-proxy"));
assert!(request_ids.contains(&"codex-session-only"));
assert!(!request_ids.contains(&"codex-session-dup"));
assert!(!request_ids.contains(&"claude-session-dup"));
assert!(!request_ids.contains(&"gemini-session-dup"));
let breakdown = crate::services::session_usage::get_data_source_breakdown(&db)?;
let proxy_count = breakdown
.iter()
.find(|item| item.data_source == "proxy")
.map(|item| item.request_count);
let codex_session_count = breakdown
.iter()
.find(|item| item.data_source == "codex_session")
.map(|item| item.request_count);
let gemini_session_count = breakdown
.iter()
.find(|item| item.data_source == "gemini_session")
.map(|item| item.request_count);
let session_log_count = breakdown
.iter()
.find(|item| item.data_source == "session_log")
.map(|item| item.request_count);
assert_eq!(proxy_count, Some(3));
assert_eq!(codex_session_count, Some(1));
assert_eq!(gemini_session_count, None);
assert_eq!(session_log_count, None);
Ok(())
}
#[test]
fn test_effective_usage_dedup_keeps_non_matching_session_rows() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
insert_usage_log(
&conn,
"proxy-base",
"codex",
"openai",
"gpt-5.4",
"proxy",
10_000,
100,
20,
10,
0,
200,
"0.10",
)?;
insert_usage_log(
&conn,
"session-outside-window",
"codex",
"_codex_session",
"gpt-5.4",
"codex_session",
10_601,
100,
20,
10,
0,
200,
"0.10",
)?;
insert_usage_log(
&conn,
"session-token-mismatch",
"codex",
"_codex_session",
"gpt-5.4",
"codex_session",
10_060,
101,
20,
10,
0,
200,
"0.10",
)?;
insert_usage_log(
&conn,
"session-app-mismatch",
"gemini",
"_gemini_session",
"gpt-5.4",
"gemini_session",
10_060,
100,
20,
10,
0,
200,
"0.10",
)?;
insert_usage_log(
&conn,
"session-model-mismatch",
"codex",
"_codex_session",
"different-model",
"codex_session",
10_060,
100,
20,
10,
0,
200,
"0.10",
)?;
insert_usage_log(
&conn,
"proxy-error",
"codex",
"openai",
"gpt-5.4",
"proxy",
20_000,
300,
60,
0,
0,
500,
"0.00",
)?;
insert_usage_log(
&conn,
"session-matches-error-proxy",
"codex",
"_codex_session",
"gpt-5.4",
"codex_session",
20_060,
300,
60,
0,
0,
200,
"0.30",
)?;
insert_usage_log(
&conn,
"claude-proxy-cache-creation",
"claude",
"anthropic",
"claude-sonnet-4-5",
"proxy",
30_000,
100,
20,
10,
5,
200,
"0.10",
)?;
insert_usage_log(
&conn,
"claude-session-cache-creation-mismatch",
"claude",
"_session",
"claude-sonnet-4-5",
"session_log",
30_060,
100,
20,
10,
0,
200,
"0.10",
)?;
}
let summary = db.get_usage_summary(None, None, None, None, None)?;
assert_eq!(summary.total_requests, 9);
let logs = db.get_request_logs(&LogFilters::default(), 0, 10)?;
let request_ids: Vec<&str> = logs
.data
.iter()
.map(|log| log.request_id.as_str())
.collect();
assert_eq!(logs.total, 9);
assert!(request_ids.contains(&"session-outside-window"));
assert!(request_ids.contains(&"session-token-mismatch"));
assert!(request_ids.contains(&"session-app-mismatch"));
assert!(request_ids.contains(&"session-model-mismatch"));
assert!(request_ids.contains(&"session-matches-error-proxy"));
assert!(request_ids.contains(&"claude-session-cache-creation-mismatch"));
Ok(())
}
#[test]
fn test_get_model_stats() -> 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,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"req1",
"p1",
"claude",
"claude-3-sonnet",
100,
50,
"0.01",
100,
200,
1000
],
)?;
}
let stats = db.get_model_stats(None, None, None, None, None)?;
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].model, "claude-3-sonnet");
assert_eq!(stats[0].request_count, 1);
Ok(())
}
#[test]
fn test_get_provider_stats_with_time_filter() -> 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,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params!["old", "p1", "claude", "claude-3", 100, 50, "0.01", 100, 200, 1000],
)?;
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params!["new", "p1", "claude", "claude-3", 200, 75, "0.02", 120, 200, 2000],
)?;
}
let stats = db.get_provider_stats(Some(1500), Some(2500), Some("claude"), None, None)?;
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].provider_id, "p1");
assert_eq!(stats[0].request_count, 1);
assert_eq!(stats[0].total_tokens, 275);
Ok(())
}
#[test]
fn test_get_provider_stats_labels_opencode_session_provider() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
insert_usage_log(
&conn,
"opencode-session",
"opencode",
"_opencode_session",
"opencode-model",
"opencode_session",
1000,
100,
50,
0,
0,
200,
"0.01",
)?;
}
let stats = db.get_provider_stats(None, None, Some("opencode"), None, None)?;
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].provider_id, "_opencode_session");
assert_eq!(stats[0].provider_name, "OpenCode (Session)");
Ok(())
}
#[test]
fn test_get_provider_stats_excludes_partial_rollup_boundary_days() -> Result<(), AppError> {
let db = Database::memory()?;
let start = local_ts(2024, 2, 1, 12, 0, 0);
let end = local_ts(2024, 2, 3, 12, 0, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-02-01",
"claude",
"p-rollup",
"claude-3",
5,
5,
500,
250,
0,
0,
"0.50",
100
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-02-02",
"claude",
"p-rollup",
"claude-3",
8,
7,
800,
400,
0,
0,
"0.80",
120
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-02-03",
"claude",
"p-rollup",
"claude-3",
12,
11,
1200,
600,
0,
0,
"1.20",
140
],
)?;
}
let stats = db.get_provider_stats(Some(start), Some(end), Some("claude"), None, None)?;
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].provider_id, "p-rollup");
assert_eq!(stats[0].request_count, 8);
assert_eq!(stats[0].total_tokens, 1200);
Ok(())
}
#[test]
fn test_get_daily_trends_respects_shorter_than_24_hours() -> 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,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"req-short",
"p1",
"claude",
"claude-3",
100,
50,
"0.01",
100,
200,
10_800
],
)?;
}
let stats = db.get_daily_trends(Some(0), Some(15 * 60 * 60), Some("claude"), None, None)?;
assert_eq!(stats.len(), 15);
assert_eq!(stats[3].request_count, 1);
Ok(())
}
#[test]
fn test_get_daily_trends_groups_ranges_longer_than_24_hours_by_local_day(
) -> Result<(), AppError> {
let db = Database::memory()?;
let start = local_ts(2024, 3, 1, 12, 0, 0);
let end = local_ts(2024, 3, 3, 12, 0, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"day-1-detail",
"p1",
"claude",
"claude-3",
100,
50,
"0.01",
100,
200,
local_ts(2024, 3, 1, 13, 0, 0)
],
)?;
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"day-3-detail",
"p1",
"claude",
"claude-3",
200,
75,
"0.02",
110,
200,
local_ts(2024, 3, 3, 10, 0, 0)
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-03-02",
"claude",
"p1",
"claude-3",
4,
4,
400,
200,
0,
0,
"0.40",
120
],
)?;
}
let stats = db.get_daily_trends(Some(start), Some(end), Some("claude"), None, None)?;
assert_eq!(stats.len(), 3);
assert_eq!(stats[0].request_count, 1);
assert_eq!(stats[0].total_tokens, 150);
assert_eq!(stats[1].request_count, 4);
assert_eq!(stats[1].total_tokens, 600);
assert_eq!(stats[2].request_count, 1);
assert_eq!(stats[2].total_tokens, 275);
Ok(())
}
#[test]
fn test_get_model_stats_excludes_partial_rollup_boundary_days() -> Result<(), AppError> {
let db = Database::memory()?;
let start = local_ts(2024, 4, 1, 12, 0, 0);
let end = local_ts(2024, 4, 3, 12, 0, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-04-01",
"claude",
"p1",
"claude-3-haiku",
6,
6,
600,
300,
0,
0,
"0.60",
100
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-04-02",
"claude",
"p1",
"claude-3-haiku",
9,
8,
900,
450,
0,
0,
"0.90",
110
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-04-03",
"claude",
"p1",
"claude-3-haiku",
12,
11,
1200,
600,
0,
0,
"1.20",
130
],
)?;
}
let stats = db.get_model_stats(Some(start), Some(end), Some("claude"), None, None)?;
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].model, "claude-3-haiku");
assert_eq!(stats[0].request_count, 9);
assert_eq!(stats[0].total_tokens, 1350);
Ok(())
}
#[test]
fn test_strip_model_date_suffix_is_utf8_safe() {
assert_eq!(
strip_model_date_suffix("模型-2026-05-14").as_deref(),
Some("模型")
);
assert_eq!(strip_model_date_suffix("abc🚀12345678"), None);
}
#[test]
fn test_strip_model_date_suffix_handles_six_digit_yymmdd() {
// 火山方舟 6 位 YYMMDD 后缀应被剥离(doubao 全系都用这种格式)。
assert_eq!(
strip_model_date_suffix("doubao-seed-2-1-pro-260628").as_deref(),
Some("doubao-seed-2-1-pro")
);
assert_eq!(
strip_model_date_suffix("doubao-seed-1-6-250615").as_deref(),
Some("doubao-seed-1-6")
);
// 8 位 YYYYMMDD 仍照旧剥离。
assert_eq!(
strip_model_date_suffix("claude-3-5-sonnet-20241022").as_deref(),
Some("claude-3-5-sonnet")
);
// 月/日非法的 6 位尾巴(版本号等)不剥离,避免误伤。
assert_eq!(strip_model_date_suffix("foo-bar-123456"), None); // 月=34
assert_eq!(strip_model_date_suffix("widget-209900"), None); // 月=99
assert_eq!(strip_model_date_suffix("gizmo-251200"), None); // 日=00
}
#[test]
fn test_pricing_resolves_volcengine_dated_model_to_bare_seed_row() -> Result<(), AppError> {
// 回归:火山真实用量带 6 位日期后缀(doubao-seed-2-1-pro-260628),
// 必须能归一化命中定价表里的裸名 seed 行(doubao-seed-2-1-pro),否则成本显示 $0。
let db = Database::memory()?;
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES ('doubao-seed-2-1-pro', 'Doubao Seed 2.1 Pro', '0.84', '4.2', '0.17', '0')",
[],
)?;
let row = find_model_pricing_row(&conn, "doubao-seed-2-1-pro-260628")?;
assert!(
row.is_some(),
"带日期的火山模型应通过 6 位日期剥离命中裸名定价行"
);
let (input, output, ..) = row.unwrap();
assert_eq!(input, "0.84");
assert_eq!(output, "4.2");
Ok(())
}
#[test]
fn test_prefix_pricing_does_not_match_short_base_model_to_variant() -> Result<(), AppError> {
let db = Database::memory()?;
let conn = lock_conn!(db.conn);
conn.execute("DELETE FROM model_pricing WHERE model_id LIKE 'gpt-5%'", [])?;
for (model_id, display_name) in [("gpt-5-mini", "GPT-5 Mini"), ("gpt-5-pro", "GPT-5 Pro")] {
conn.execute(
"INSERT INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?1, ?2, '1', '2', '0', '0')",
params![model_id, display_name],
)?;
}
let result = find_model_pricing_row(&conn, "gpt-5")?;
assert!(
result.is_none(),
"缺少 gpt-5 基础定价时,不应前缀误匹配到 gpt-5-mini/gpt-5-pro"
);
Ok(())
}
#[test]
fn test_model_pricing_matching() -> Result<(), AppError> {
let db = Database::memory()?;
let conn = lock_conn!(db.conn);
// 准备额外定价数据,覆盖前缀/后缀清洗场景
conn.execute(
"INSERT OR REPLACE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?, ?, ?, ?, ?, ?)",
params![
"claude-haiku-4.5",
"Claude Haiku 4.5",
"1.0",
"2.0",
"0.0",
"0.0"
],
)?;
// 测试精确匹配(seed_model_pricing 已预置 claude-sonnet-4-5-20250929
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20250929")?;
assert!(
result.is_some(),
"应该能精确匹配 claude-sonnet-4-5-20250929"
);
// 清洗:去除前缀和冒号后缀
let result = find_model_pricing_row(&conn, "anthropic/claude-haiku-4.5")?;
assert!(
result.is_some(),
"带前缀的模型 anthropic/claude-haiku-4.5 应能匹配到 claude-haiku-4.5"
);
let result = find_model_pricing_row(&conn, "moonshotai/kimi-k2-0905:exa")?;
assert!(
result.is_some(),
"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905"
);
// 清洗:@ 替换为 -seed_model_pricing 已预置 gpt-5.2-codex-low
let result = find_model_pricing_row(&conn, "gpt-5.2-codex@low")?;
assert!(
result.is_some(),
"带 @ 分隔符的模型 gpt-5.2-codex@low 应能匹配到 gpt-5.2-codex-low"
);
let result = find_model_pricing_row(&conn, "OpenAI/GPT-5.5@HIGH")?;
assert!(
result.is_some(),
"大小写混合的 GPT-5.5 模型应能归一化匹配到 gpt-5.5-high"
);
let result = find_model_pricing_row(&conn, "OpenAI/GPT-5.5-2026-05-14")?;
assert!(
result.is_some(),
"OpenAI 日期后缀模型应能回退到 gpt-5.5 基础定价"
);
let result = find_model_pricing_row(&conn, "google/gemini-3-pro-preview-20260514")?;
assert!(
result.is_some(),
"Gemini 日期后缀模型应能回退到 gemini-3-pro-preview 基础定价"
);
// 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"
);
let result = find_model_pricing_row(&conn, "anthropic/claude-opus-4.8")?;
assert!(
result.is_some(),
"聚合商点号格式 anthropic/claude-opus-4.8 应能匹配到 claude-opus-4-8"
);
// 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 模型定价"
);
let result = find_model_pricing_row(&conn, "global.anthropic.claude-opus-4-8-v1:0")?;
assert!(
result.is_some(),
"Bedrock 风格 Claude Opus 4.8 模型 ID 应能归一化到基础 Claude 模型定价"
);
let result = find_model_pricing_row(&conn, "claude-opus-4-8@20260527")?;
assert!(
result.is_some(),
"Vertex 风格 Claude Opus 4.8 模型 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(), "不应该匹配不存在的模型");
Ok(())
}
}