fix(usage): pricing routing, SSE lifecycle, and validation hardening

* model pricing routing: extend prefix-match families (gpt-/o1-o5/
  gemini-/deepseek-/qwen-/glm-/kimi-/minimax-) with per-family dash
  thresholds so short base IDs like gpt-5 no longer mis-match
  gpt-5-mini; strip ISO and 8-digit date suffixes via UTF-8-safe
  byte matching so claude-haiku-4-5-20251001 falls back to
  claude-haiku-4-5 pricing
* SSE collector: SseUsageFinishGuard (RAII) guarantees finish() on
  early return or panic; AtomicBool fast path lets push() skip the
  Mutex once first-event time is recorded
* validation: shared validate_cost_multiplier / validate_pricing_source
  helpers across DAO and service layers; PRICING_SOURCE_RESPONSE /
  PRICING_SOURCE_REQUEST constants replace string literals; price
  fields in update_model_pricing now reject empty / non-decimal /
  negative input before INSERT
* backfill: add backfill_missing_usage_costs_for_model so a single
  price edit only scans matching rows instead of the full log table;
  startup backfill remains full-scan
* session_usage{,_codex,_gemini}: share find_model_pricing helper from
  usage_stats; metadata_modified_nanos centralizes mtime precision
* frontend: NON_NEGATIVE_DECIMAL_REGEX + isNonNegativeDecimalString
  replace three copies of the same multiplier regex; isUnpricedUsage
  surfaces zero-cost rows that have usage tokens (cached per row to
  avoid double evaluation); invalidate usageKeys.all on pricing mutate
  so backfilled rows refresh
This commit is contained in:
Jason
2026-05-14 11:53:51 +08:00
parent 206125b4e3
commit 402570ce31
19 changed files with 590 additions and 375 deletions
+36
View File
@@ -13,6 +13,7 @@ use serde::Deserialize;
use serde_json::Value;
use crate::app_config::AppType;
use crate::database::{validate_cost_multiplier, validate_pricing_source};
use crate::error::AppError;
use crate::provider::{Provider, UsageResult};
use crate::services::mcp::McpService;
@@ -261,6 +262,35 @@ mod tests {
);
}
#[test]
fn validate_provider_settings_rejects_negative_cost_multiplier() {
let mut provider = Provider::with_id(
"claude".into(),
"Claude".into(),
json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "token",
"ANTHROPIC_BASE_URL": "https://claude.example"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
cost_multiplier: Some("-1".to_string()),
..ProviderMeta::default()
});
let err = ProviderService::validate_provider_settings(&AppType::Claude, &provider)
.expect_err("negative multiplier should be rejected");
assert!(matches!(
err,
AppError::Localized {
key: "error.invalidMultiplier",
..
}
));
}
#[test]
fn extract_credentials_returns_expected_values() {
let provider = Provider::with_id(
@@ -2145,6 +2175,12 @@ impl ProviderService {
// Validate and clean UsageScript configuration (common for all app types)
if let Some(meta) = &provider.meta {
if let Some(multiplier) = meta.cost_multiplier.as_deref() {
validate_cost_multiplier(multiplier)?;
}
if let Some(source) = meta.pricing_model_source.as_deref() {
validate_pricing_source(source)?;
}
if let Some(usage_script) = &meta.usage_script {
validate_usage_script(usage_script)?;
}
+16 -78
View File
@@ -14,7 +14,7 @@ use crate::error::AppError;
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
use crate::proxy::usage::parser::TokenUsage;
use crate::services::usage_stats::{
effective_usage_log_filter, should_skip_session_insert, DedupKey,
effective_usage_log_filter, find_model_pricing, should_skip_session_insert, DedupKey,
};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
@@ -142,12 +142,7 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr
// 获取文件元数据
let metadata = fs::metadata(file_path)
.map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?;
let file_modified = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let file_modified = metadata_modified_nanos(&metadata);
// 检查同步状态
let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?;
@@ -321,6 +316,19 @@ pub(crate) fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64
Ok(result.unwrap_or((0, 0)))
}
/// 返回文件 mtime 的纳秒时间戳。
///
/// `session_log_sync.last_modified` 旧数据是秒级时间戳;新写入纳秒值不需要
/// schema 迁移,旧值会自然触发一次增量重扫,并继续依赖行 offset 避免重复导入。
pub(crate) fn metadata_modified_nanos(metadata: &fs::Metadata) -> i64 {
metadata
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_nanos().min(i64::MAX as u128) as i64)
.unwrap_or(0)
}
/// 更新 session_log_sync 表中某条目的同步进度。
///
/// Shared by all session_usage_* parsers.
@@ -459,77 +467,7 @@ fn find_model_pricing_for_session(
conn: &rusqlite::Connection,
model_id: &str,
) -> Option<ModelPricing> {
// 精确匹配
if let Ok(Some(pricing)) = try_find_pricing(conn, model_id) {
return Some(pricing);
}
// 模糊匹配:去掉日期后缀
// 例如 "claude-opus-4-6-20260206" -> "claude-opus-4-6"
let parts: Vec<&str> = model_id.rsplitn(2, '-').collect();
if parts.len() == 2 {
if let Some(suffix) = parts.first() {
if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
if let Ok(Some(pricing)) = try_find_pricing(conn, parts[1]) {
return Some(pricing);
}
}
}
}
// 尝试 LIKE 匹配
let pattern = format!("{model_id}%");
let result = 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 LIMIT 1",
rusqlite::params![pattern],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
);
match result {
Ok((input, output, cache_read, cache_creation)) => {
ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation).ok()
}
Err(_) => None,
}
}
fn try_find_pricing(
conn: &rusqlite::Connection,
model_id: &str,
) -> Result<Option<ModelPricing>, AppError> {
let result = 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",
rusqlite::params![model_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
);
match result {
Ok((input, output, cache_read, cache_creation)) => {
ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation)
.map(Some)
.map_err(|e| AppError::Database(format!("解析定价失败: {e}")))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(format!("查询定价失败: {e}"))),
}
find_model_pricing(conn, model_id)
}
/// 查询数据来源分布统计
+6 -53
View File
@@ -18,8 +18,10 @@ use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
use crate::proxy::usage::parser::TokenUsage;
use crate::services::session_usage::{get_sync_state, update_sync_state, SessionSyncResult};
use crate::services::usage_stats::{should_skip_session_insert, DedupKey};
use crate::services::session_usage::{
get_sync_state, metadata_modified_nanos, update_sync_state, SessionSyncResult,
};
use crate::services::usage_stats::{find_model_pricing, should_skip_session_insert, DedupKey};
use rust_decimal::Decimal;
use std::fs;
use std::io::{BufRead, BufReader};
@@ -232,12 +234,7 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32),
// 获取文件元数据
let metadata = fs::metadata(file_path)
.map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?;
let file_modified = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let file_modified = metadata_modified_nanos(&metadata);
// 检查同步状态
let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?;
@@ -540,51 +537,7 @@ fn insert_codex_session_entry(
/// 查找 Codex 模型定价(带归一化)
fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
let normalized = normalize_codex_model(model_id);
// 1. 精确匹配(归一化后的名称)
if let Some(pricing) = try_find_pricing(conn, &normalized) {
return Some(pricing);
}
// 2. LIKE 模糊匹配(兜底)
let pattern = format!("{normalized}%");
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 LIMIT 1",
rusqlite::params![pattern],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.ok()
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
}
/// 精确匹配定价查询
fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
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",
rusqlite::params![model_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.ok()
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
find_model_pricing(conn, &normalize_codex_model(model_id))
}
#[cfg(test)]
+6 -51
View File
@@ -18,8 +18,10 @@ use crate::error::AppError;
use crate::gemini_config::get_gemini_dir;
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
use crate::proxy::usage::parser::TokenUsage;
use crate::services::session_usage::{get_sync_state, update_sync_state, SessionSyncResult};
use crate::services::usage_stats::{should_skip_session_insert, DedupKey};
use crate::services::session_usage::{
get_sync_state, metadata_modified_nanos, update_sync_state, SessionSyncResult,
};
use crate::services::usage_stats::{find_model_pricing, should_skip_session_insert, DedupKey};
use rust_decimal::Decimal;
use std::fs;
use std::path::{Path, PathBuf};
@@ -126,12 +128,7 @@ fn sync_single_gemini_file(db: &Database, file_path: &Path) -> Result<(u32, u32)
// 获取文件元数据
let metadata = fs::metadata(file_path)
.map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?;
let file_modified = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let file_modified = metadata_modified_nanos(&metadata);
// 检查同步状态
let (last_modified, _last_offset) = get_sync_state(db, &file_path_str)?;
@@ -358,49 +355,7 @@ fn insert_gemini_session_entry(
/// 查找 Gemini 模型定价
fn find_gemini_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
// 精确匹配
if let Some(pricing) = try_find_pricing(conn, model_id) {
return Some(pricing);
}
// LIKE 模糊匹配(兜底)
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 LIMIT 1",
rusqlite::params![pattern],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.ok()
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
}
/// 精确匹配定价查询
fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
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",
rusqlite::params![model_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.ok()
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
find_model_pricing(conn, model_id)
}
#[cfg(test)]
+139 -13
View File
@@ -4,6 +4,7 @@
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::proxy::usage::calculator::ModelPricing;
use crate::services::sql_helpers::fresh_input_sql;
use chrono::{Local, NaiveDate, TimeZone, Timelike};
use rusqlite::{params, Connection, OptionalExtension};
@@ -1455,27 +1456,49 @@ 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)
Self::backfill_missing_usage_costs_on_conn(&conn, None)
}
fn backfill_missing_usage_costs_on_conn(conn: &Connection) -> Result<u64, AppError> {
let mut logs = {
let mut stmt = conn.prepare(
"SELECT request_id, provider_id, NULL AS provider_name, app_type, model, request_model,
/// 仅回填指定 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))
}
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,
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
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)",
)?;
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 rows = stmt.query_map([], row_to_request_log_detail)?;
rows.collect::<Result<Vec<_>, _>>()?
let mut logs = {
match only_model_id {
Some(model) => {
let sql = format!("{BASE_SQL} AND (model = ?1 OR request_model = ?1)");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([model], row_to_request_log_detail)?;
rows.collect::<Result<Vec<_>, _>>()?
}
None => {
let mut stmt = conn.prepare(BASE_SQL)?;
let rows = stmt.query_map([], row_to_request_log_detail)?;
rows.collect::<Result<Vec<_>, _>>()?
}
}
};
if logs.is_empty() {
@@ -1638,6 +1661,15 @@ impl Database {
}
}
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,
@@ -1741,6 +1773,9 @@ fn model_pricing_candidates(model_id: &str) -> Vec<String> {
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);
}
@@ -1854,6 +1889,27 @@ fn strip_bedrock_model_version_suffix(model_id: &str) -> Option<String> {
.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('-')?;
(!base.is_empty() && suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()))
.then(|| base.to_string())
}
fn strip_reasoning_effort_suffix(model_id: &str) -> Option<String> {
for suffix in ["-minimal", "-low", "-medium", "-high", "-xhigh"] {
if let Some(stripped) = model_id.strip_suffix(suffix) {
@@ -1866,7 +1922,33 @@ fn strip_reasoning_effort_suffix(model_id: &str) -> Option<String> {
}
fn should_try_pricing_prefix_match(model_id: &str) -> bool {
model_id.starts_with("claude-") && model_id.matches('-').count() >= 3
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)]
@@ -3030,6 +3112,40 @@ mod tests {
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_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()?;
@@ -3081,6 +3197,16 @@ mod tests {
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")?;