mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -3,6 +3,8 @@
|
||||
use crate::error::AppError;
|
||||
use crate::services::usage_stats::*;
|
||||
use crate::store::AppState;
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
use tauri::State;
|
||||
|
||||
/// 获取使用量汇总
|
||||
@@ -149,23 +151,67 @@ pub fn update_model_pricing(
|
||||
cache_creation_cost: String,
|
||||
) -> Result<(), AppError> {
|
||||
let db = state.db.clone();
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let model_id = model_id.trim().to_string();
|
||||
let display_name = display_name.trim().to_string();
|
||||
if model_id.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"usage.modelIdRequired",
|
||||
"模型 ID 不能为空",
|
||||
"Model ID is required",
|
||||
));
|
||||
}
|
||||
if display_name.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"usage.displayNameRequired",
|
||||
"显示名称不能为空",
|
||||
"Display name is required",
|
||||
));
|
||||
}
|
||||
|
||||
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 (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
|
||||
for (label, value) in [
|
||||
("input_cost", &input_cost),
|
||||
("output_cost", &output_cost),
|
||||
("cache_read_cost", &cache_read_cost),
|
||||
("cache_creation_cost", &cache_creation_cost),
|
||||
] {
|
||||
let parsed = Decimal::from_str(value.trim()).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage.invalidPrice",
|
||||
format!("{label} 价格无效: {value} - {e}"),
|
||||
format!("{label} price is invalid: {value} - {e}"),
|
||||
)
|
||||
})?;
|
||||
if parsed < Decimal::ZERO {
|
||||
return Err(AppError::localized(
|
||||
"usage.invalidPrice",
|
||||
format!("{label} 价格必须为非负数: {value}"),
|
||||
format!("{label} price must be non-negative: {value}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let conn = crate::database::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 (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input_cost.trim(),
|
||||
output_cost.trim(),
|
||||
cache_read_cost.trim(),
|
||||
cache_creation_cost.trim()
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
|
||||
}
|
||||
|
||||
if let Err(e) = db.backfill_missing_usage_costs_for_model(&model_id) {
|
||||
log::warn!("模型定价更新后回填历史用量成本失败 (model_id={model_id}): {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,12 +2,56 @@
|
||||
//!
|
||||
//! 处理代理配置、Provider健康状态和使用统计的数据库操作
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::types::*;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use super::super::{lock_conn, Database};
|
||||
|
||||
pub(crate) const PRICING_SOURCE_RESPONSE: &str = "response";
|
||||
pub(crate) const PRICING_SOURCE_REQUEST: &str = "request";
|
||||
|
||||
pub(crate) fn validate_cost_multiplier(value: &str) -> Result<Decimal, AppError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"error.multiplierEmpty",
|
||||
"倍率不能为空",
|
||||
"Multiplier cannot be empty",
|
||||
));
|
||||
}
|
||||
let parsed = Decimal::from_str(trimmed).map_err(|e| {
|
||||
AppError::localized(
|
||||
"error.invalidMultiplier",
|
||||
format!("无效倍率: {value} - {e}"),
|
||||
format!("Invalid multiplier: {value} - {e}"),
|
||||
)
|
||||
})?;
|
||||
if parsed < Decimal::ZERO {
|
||||
return Err(AppError::localized(
|
||||
"error.invalidMultiplier",
|
||||
format!("无效倍率: {value} - 倍率不能为负数"),
|
||||
format!("Invalid multiplier: {value} - multiplier cannot be negative"),
|
||||
));
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_pricing_source(value: &str) -> Result<&str, AppError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed == PRICING_SOURCE_RESPONSE || trimmed == PRICING_SOURCE_REQUEST {
|
||||
Ok(trimmed)
|
||||
} else {
|
||||
Err(AppError::localized(
|
||||
"error.invalidPricingMode",
|
||||
format!("无效计费模式: {value}"),
|
||||
format!("Invalid pricing mode: {value}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Database {
|
||||
// ==================== Global Proxy Config ====================
|
||||
|
||||
@@ -103,21 +147,8 @@ impl Database {
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
validate_cost_multiplier(value)?;
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"error.multiplierEmpty",
|
||||
"倍率不能为空",
|
||||
"Multiplier cannot be empty",
|
||||
));
|
||||
}
|
||||
trimmed.parse::<Decimal>().map_err(|e| {
|
||||
AppError::localized(
|
||||
"error.invalidMultiplier",
|
||||
format!("无效倍率: {value} - {e}"),
|
||||
format!("Invalid multiplier: {value} - {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 确保行存在
|
||||
self.ensure_proxy_config_row_exists(app_type)?;
|
||||
@@ -150,7 +181,7 @@ impl Database {
|
||||
Ok(value) => Ok(value),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
self.init_proxy_config_rows().await?;
|
||||
Ok("response".to_string())
|
||||
Ok(PRICING_SOURCE_RESPONSE.to_string())
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
@@ -162,14 +193,7 @@ impl Database {
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let trimmed = value.trim();
|
||||
if !matches!(trimmed, "response" | "request") {
|
||||
return Err(AppError::localized(
|
||||
"error.invalidPricingMode",
|
||||
format!("无效计费模式: {value}"),
|
||||
format!("Invalid pricing mode: {value}"),
|
||||
));
|
||||
}
|
||||
let trimmed = validate_pricing_source(value)?;
|
||||
|
||||
// 确保行存在
|
||||
self.ensure_proxy_config_row_exists(app_type)?;
|
||||
@@ -911,6 +935,18 @@ mod tests {
|
||||
}
|
||||
));
|
||||
|
||||
let err = db
|
||||
.set_default_cost_multiplier("claude", "-0.5")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
AppError::Localized {
|
||||
key: "error.invalidMultiplier",
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ mod tests;
|
||||
|
||||
// DAO 类型导出供外部使用
|
||||
pub(crate) use dao::providers_seed::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID;
|
||||
pub(crate) use dao::proxy::{
|
||||
validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST,
|
||||
PRICING_SOURCE_RESPONSE,
|
||||
};
|
||||
pub use dao::FailoverQueueItem;
|
||||
|
||||
use crate::config::get_app_config_dir;
|
||||
|
||||
@@ -33,6 +33,7 @@ use super::{
|
||||
ProxyError,
|
||||
};
|
||||
use crate::app_config::AppType;
|
||||
use crate::database::PRICING_SOURCE_REQUEST;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::BodyExt;
|
||||
@@ -880,7 +881,7 @@ async fn log_usage(
|
||||
|
||||
let (multiplier, pricing_model_source) =
|
||||
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||
let pricing_model = if pricing_model_source == "request" {
|
||||
let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST {
|
||||
request_model
|
||||
} else {
|
||||
model
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::{
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
};
|
||||
use crate::database::PRICING_SOURCE_REQUEST;
|
||||
use axum::http::{header::HeaderMap, HeaderName};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use bytes::Bytes;
|
||||
@@ -372,6 +373,7 @@ pub struct SseUsageCollector {
|
||||
struct SseUsageCollectorInner {
|
||||
events: Mutex<Vec<Value>>,
|
||||
first_event_time: Mutex<Option<std::time::Instant>>,
|
||||
first_event_set: AtomicBool,
|
||||
start_time: std::time::Instant,
|
||||
on_complete: UsageCallbackWithTiming,
|
||||
should_collect: Option<StreamUsageEventFilter>,
|
||||
@@ -390,6 +392,7 @@ impl SseUsageCollector {
|
||||
inner: Arc::new(SseUsageCollectorInner {
|
||||
events: Mutex::new(Vec::new()),
|
||||
first_event_time: Mutex::new(None),
|
||||
first_event_set: AtomicBool::new(false),
|
||||
start_time,
|
||||
on_complete,
|
||||
should_collect,
|
||||
@@ -405,15 +408,21 @@ impl SseUsageCollector {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// 标记首个被收集的 SSE 事件时间,沿用 `first_token_ms` 的既有近似语义。
|
||||
async fn mark_first_collected_event_time(&self) {
|
||||
if self.inner.first_event_set.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let mut first_time = self.inner.first_event_time.lock().await;
|
||||
if first_time.is_none() {
|
||||
*first_time = Some(std::time::Instant::now());
|
||||
self.inner.first_event_set.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// 推送 SSE 事件
|
||||
pub async fn push(&self, event: Value) {
|
||||
// 记录首个事件时间
|
||||
{
|
||||
let mut first_time = self.inner.first_event_time.lock().await;
|
||||
if first_time.is_none() {
|
||||
*first_time = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
self.mark_first_collected_event_time().await;
|
||||
let mut events = self.inner.events.lock().await;
|
||||
events.push(event);
|
||||
}
|
||||
@@ -438,6 +447,36 @@ impl SseUsageCollector {
|
||||
}
|
||||
}
|
||||
|
||||
struct SseUsageFinishGuard {
|
||||
collector: Option<SseUsageCollector>,
|
||||
}
|
||||
|
||||
impl SseUsageFinishGuard {
|
||||
fn new(collector: SseUsageCollector) -> Self {
|
||||
Self {
|
||||
collector: Some(collector),
|
||||
}
|
||||
}
|
||||
|
||||
fn disarm(&mut self) {
|
||||
self.collector = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SseUsageFinishGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(collector) = self.collector.take() {
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
collector.finish().await;
|
||||
});
|
||||
} else {
|
||||
log::warn!("SSE 用量收尾保护触发时 Tokio runtime 不可用,跳过异步 finish");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 内部辅助函数
|
||||
// ============================================================================
|
||||
@@ -598,7 +637,7 @@ async fn log_usage_internal(
|
||||
let logger = UsageLogger::new(&state.db);
|
||||
let (multiplier, pricing_model_source) =
|
||||
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||
let pricing_model = if pricing_model_source == "request" {
|
||||
let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST {
|
||||
request_model
|
||||
} else {
|
||||
model
|
||||
@@ -648,6 +687,7 @@ pub fn create_logged_passthrough_stream(
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
let mut collector = usage_collector;
|
||||
let mut finish_guard = collector.clone().map(SseUsageFinishGuard::new);
|
||||
let inspect_sse_events =
|
||||
collector.is_some() || log::log_enabled!(log::Level::Debug);
|
||||
let mut is_first_chunk = true;
|
||||
@@ -753,6 +793,9 @@ pub fn create_logged_passthrough_stream(
|
||||
if let Some(c) = collector.take() {
|
||||
c.finish().await;
|
||||
}
|
||||
if let Some(guard) = &mut finish_guard {
|
||||
guard.disarm();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
use super::calculator::{CostBreakdown, CostCalculator, ModelPricing};
|
||||
use super::parser::TokenUsage;
|
||||
use crate::database::Database;
|
||||
use crate::database::{Database, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE};
|
||||
use crate::error::AppError;
|
||||
use crate::services::usage_stats::{find_model_pricing_row, is_placeholder_pricing_model};
|
||||
use rust_decimal::Decimal;
|
||||
use std::{str::FromStr, time::SystemTime};
|
||||
use std::str::FromStr;
|
||||
|
||||
/// 请求日志
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -64,13 +64,7 @@ impl<'a> UsageLogger<'a> {
|
||||
)
|
||||
};
|
||||
|
||||
let created_at = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("SystemTime is before UNIX_EPOCH, falling back to 0: {e}");
|
||||
0
|
||||
});
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO proxy_request_logs (
|
||||
@@ -227,18 +221,19 @@ impl<'a> UsageLogger<'a> {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
|
||||
"response".to_string()
|
||||
PRICING_SOURCE_RESPONSE.to_string()
|
||||
}
|
||||
};
|
||||
let default_pricing_source =
|
||||
if matches!(default_pricing_source_raw.as_str(), "response" | "request") {
|
||||
default_pricing_source_raw
|
||||
} else {
|
||||
log::warn!(
|
||||
let default_pricing_source = if default_pricing_source_raw == PRICING_SOURCE_RESPONSE
|
||||
|| default_pricing_source_raw == PRICING_SOURCE_REQUEST
|
||||
{
|
||||
default_pricing_source_raw
|
||||
} else {
|
||||
log::warn!(
|
||||
"[USG-003] 默认计费模式无效 (app_type={app_type}): {default_pricing_source_raw}"
|
||||
);
|
||||
"response".to_string()
|
||||
};
|
||||
PRICING_SOURCE_RESPONSE.to_string()
|
||||
};
|
||||
|
||||
let provider = self
|
||||
.db
|
||||
@@ -271,7 +266,9 @@ impl<'a> UsageLogger<'a> {
|
||||
};
|
||||
|
||||
let pricing_model_source = match provider_pricing_source {
|
||||
Some(value) if matches!(value, "response" | "request") => value.to_string(),
|
||||
Some(value) if value == PRICING_SOURCE_RESPONSE || value == PRICING_SOURCE_REQUEST => {
|
||||
value.to_string()
|
||||
}
|
||||
Some(value) => {
|
||||
log::warn!("[USG-003] 供应商计费模式无效 (provider_id={provider_id}): {value}");
|
||||
default_pricing_source.clone()
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/// 查询数据来源分布统计
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -297,6 +297,7 @@ export function ProviderAdvancedConfig({
|
||||
id="cost-multiplier"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
inputMode="decimal"
|
||||
value={pricingConfig.costMultiplier || ""}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
hasApiKeyField,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||
import { isNonNegativeDecimalString } from "@/types/usage";
|
||||
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||
import CodexConfigEditor from "./CodexConfigEditor";
|
||||
import { CommonConfigEditor } from "./CommonConfigEditor";
|
||||
@@ -814,6 +815,20 @@ function ProviderFormFull({
|
||||
);
|
||||
}
|
||||
|
||||
const costMultiplier = pricingConfig.costMultiplier?.trim();
|
||||
if (
|
||||
pricingConfig.enabled &&
|
||||
costMultiplier &&
|
||||
!isNonNegativeDecimalString(costMultiplier)
|
||||
) {
|
||||
toast.error(
|
||||
t("settings.globalProxy.defaultCostMultiplierInvalid", {
|
||||
defaultValue: "成本倍率必须为非负数",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// opencode / openclaw / hermes: providerKey 相关
|
||||
// A 类(空)归到 issues;B 类(正则不合法 / 重复 / 状态加载中)仍硬拒绝
|
||||
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage";
|
||||
import { PricingEditModal } from "./PricingEditModal";
|
||||
import type { ModelPricing } from "@/types/usage";
|
||||
import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage";
|
||||
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { proxyApi } from "@/lib/api/proxy";
|
||||
@@ -143,7 +143,7 @@ export function PricingConfigPanel() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!/^-?\d+(?:\.\d+)?$/.test(trimmed)) {
|
||||
if (!isNonNegativeDecimalString(trimmed)) {
|
||||
toast.error(
|
||||
`${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierInvalid")}`,
|
||||
);
|
||||
@@ -281,6 +281,7 @@ export function PricingConfigPanel() {
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
inputMode="decimal"
|
||||
value={appConfigs[app].multiplier}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useUpdateModelPricing } from "@/lib/query/usage";
|
||||
import type { ModelPricing } from "@/types/usage";
|
||||
import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage";
|
||||
|
||||
interface PricingEditModalProps {
|
||||
open: boolean;
|
||||
@@ -52,8 +52,7 @@ export function PricingEditModal({
|
||||
];
|
||||
|
||||
for (const value of values) {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num) || num < 0) {
|
||||
if (!isNonNegativeDecimalString(value)) {
|
||||
toast.error(t("usage.invalidPrice", "价格必须为非负数"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useRequestDetail } from "@/lib/query/usage";
|
||||
import { getFreshInputTokens } from "@/types/usage";
|
||||
import { getFreshInputTokens, isUnpricedUsage } from "@/types/usage";
|
||||
|
||||
interface RequestDetailPanelProps {
|
||||
requestId: string;
|
||||
@@ -53,6 +53,7 @@ export function RequestDetailPanel({
|
||||
|
||||
const freshInput = getFreshInputTokens(request);
|
||||
const isCacheInclusive = request.inputTokens !== freshInput;
|
||||
const unpriced = isUnpricedUsage(request);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
@@ -255,8 +256,14 @@ export function RequestDetailPanel({
|
||||
</span>
|
||||
)}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-primary">
|
||||
${parseFloat(request.totalCostUsd).toFixed(6)}
|
||||
<dd
|
||||
className={`text-lg font-semibold ${
|
||||
unpriced ? "text-muted-foreground" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
{unpriced
|
||||
? t("usage.unpriced", "未定价")
|
||||
: `$${parseFloat(request.totalCostUsd).toFixed(6)}`}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useRequestLogs } from "@/lib/query/usage";
|
||||
import {
|
||||
KNOWN_APP_TYPES,
|
||||
getFreshInputTokens,
|
||||
isUnpricedUsage,
|
||||
type LogFilters,
|
||||
type UsageRangeSelection,
|
||||
} from "@/types/usage";
|
||||
@@ -293,115 +294,127 @@ export function RequestLogTable({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.requestId}>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs px-1.5">
|
||||
{new Date(log.createdAt * 1000).toLocaleString(locale, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{log.providerName || t("usage.unknownProvider")}
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono text-xs max-w-[200px]">
|
||||
<div
|
||||
className="truncate"
|
||||
title={
|
||||
log.requestModel && log.requestModel !== log.model
|
||||
? `${log.requestModel} → ${log.model}`
|
||||
: log.model
|
||||
}
|
||||
>
|
||||
{log.requestModel &&
|
||||
log.requestModel !== log.model ? (
|
||||
<span>
|
||||
{log.requestModel}
|
||||
<span className="text-muted-foreground">
|
||||
{" → "}
|
||||
{log.model}
|
||||
logs.map((log) => {
|
||||
const unpriced = isUnpricedUsage(log);
|
||||
return (
|
||||
<TableRow key={log.requestId}>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs px-1.5">
|
||||
{new Date(log.createdAt * 1000).toLocaleString(
|
||||
locale,
|
||||
{
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{log.providerName || t("usage.unknownProvider")}
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono text-xs max-w-[200px]">
|
||||
<div
|
||||
className="truncate"
|
||||
title={
|
||||
log.requestModel && log.requestModel !== log.model
|
||||
? `${log.requestModel} → ${log.model}`
|
||||
: log.model
|
||||
}
|
||||
>
|
||||
{log.requestModel &&
|
||||
log.requestModel !== log.model ? (
|
||||
<span>
|
||||
{log.requestModel}
|
||||
<span className="text-muted-foreground">
|
||||
{" → "}
|
||||
{log.model}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
log.model
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center px-1.5">
|
||||
{(() => {
|
||||
const freshInput = getFreshInputTokens(log);
|
||||
const isCacheInclusive =
|
||||
log.inputTokens !== freshInput;
|
||||
return (
|
||||
<div
|
||||
className="tabular-nums"
|
||||
title={
|
||||
isCacheInclusive
|
||||
? `Raw: ${log.inputTokens.toLocaleString()}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{fmtInt(freshInput, locale)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{(log.cacheReadTokens > 0 ||
|
||||
log.cacheCreationTokens > 0) && (
|
||||
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{[
|
||||
log.cacheReadTokens > 0 &&
|
||||
`R${fmtInt(log.cacheReadTokens, locale)}`,
|
||||
log.cacheCreationTokens > 0 &&
|
||||
`W${fmtInt(log.cacheCreationTokens, locale)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("·")}
|
||||
) : (
|
||||
log.model
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{fmtInt(log.outputTokens, locale)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center px-1.5">
|
||||
<div className="font-medium tabular-nums">
|
||||
{fmtUsd(log.totalCostUsd, 4)}
|
||||
</div>
|
||||
{parseFiniteNumber(log.costMultiplier) != null &&
|
||||
parseFiniteNumber(log.costMultiplier) !== 1 && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
×
|
||||
{parseFiniteNumber(log.costMultiplier)?.toFixed(
|
||||
2,
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center px-1.5">
|
||||
{(() => {
|
||||
const freshInput = getFreshInputTokens(log);
|
||||
const isCacheInclusive =
|
||||
log.inputTokens !== freshInput;
|
||||
return (
|
||||
<div
|
||||
className="tabular-nums"
|
||||
title={
|
||||
isCacheInclusive
|
||||
? `Raw: ${log.inputTokens.toLocaleString()}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{fmtInt(freshInput, locale)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{(log.cacheReadTokens > 0 ||
|
||||
log.cacheCreationTokens > 0) && (
|
||||
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{[
|
||||
log.cacheReadTokens > 0 &&
|
||||
`R${fmtInt(log.cacheReadTokens, locale)}`,
|
||||
log.cacheCreationTokens > 0 &&
|
||||
`W${fmtInt(log.cacheCreationTokens, locale)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("·")}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs tabular-nums">
|
||||
{(log.latencyMs / 1000).toFixed(1)}s
|
||||
{log.firstTokenMs != null && (
|
||||
<span className="text-muted-foreground">
|
||||
/{(log.firstTokenMs / 1000).toFixed(1)}s
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{fmtInt(log.outputTokens, locale)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center px-1.5">
|
||||
<div
|
||||
className={`font-medium tabular-nums ${
|
||||
unpriced ? "text-muted-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{unpriced
|
||||
? t("usage.unpriced", "未定价")
|
||||
: fmtUsd(log.totalCostUsd, 4)}
|
||||
</div>
|
||||
{parseFiniteNumber(log.costMultiplier) != null &&
|
||||
parseFiniteNumber(log.costMultiplier) !== 1 && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
×
|
||||
{parseFiniteNumber(log.costMultiplier)?.toFixed(
|
||||
2,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs tabular-nums">
|
||||
{(log.latencyMs / 1000).toFixed(1)}s
|
||||
{log.firstTokenMs != null && (
|
||||
<span className="text-muted-foreground">
|
||||
/{(log.firstTokenMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span
|
||||
className={
|
||||
log.statusCode >= 200 && log.statusCode < 300
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{log.statusCode}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span
|
||||
className={
|
||||
log.statusCode >= 200 && log.statusCode < 300
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{log.statusCode}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs text-muted-foreground">
|
||||
{log.dataSource || "proxy"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs text-muted-foreground">
|
||||
{log.dataSource || "proxy"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -302,7 +302,7 @@ export function useUpdateModelPricing() {
|
||||
params.cacheCreationCost,
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.pricing() });
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -313,7 +313,7 @@ export function useDeleteModelPricing() {
|
||||
return useMutation({
|
||||
mutationFn: (modelId: string) => usageApi.deleteModelPricing(modelId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.pricing() });
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,6 +195,50 @@ export function getFreshInputTokens(log: CacheNormalizableLog): number {
|
||||
return log.inputTokens;
|
||||
}
|
||||
|
||||
export const NON_NEGATIVE_DECIMAL_REGEX = /^\d+(?:\.\d+)?$/;
|
||||
|
||||
export function isNonNegativeDecimalString(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!NON_NEGATIVE_DECIMAL_REGEX.test(trimmed)) return false;
|
||||
return Number.isFinite(Number(trimmed));
|
||||
}
|
||||
|
||||
type UsageCostLog = Pick<
|
||||
RequestLog,
|
||||
| "inputTokens"
|
||||
| "outputTokens"
|
||||
| "cacheReadTokens"
|
||||
| "cacheCreationTokens"
|
||||
| "totalCostUsd"
|
||||
| "statusCode"
|
||||
> &
|
||||
Partial<Pick<RequestLog, "costMultiplier">>;
|
||||
|
||||
export function hasUsageTokens(log: UsageCostLog): boolean {
|
||||
return (
|
||||
log.inputTokens > 0 ||
|
||||
log.outputTokens > 0 ||
|
||||
log.cacheReadTokens > 0 ||
|
||||
log.cacheCreationTokens > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function isUnpricedUsage(log: UsageCostLog): boolean {
|
||||
const totalCost = Number.parseFloat(log.totalCostUsd);
|
||||
const multiplier =
|
||||
log.costMultiplier == null
|
||||
? undefined
|
||||
: Number.parseFloat(log.costMultiplier);
|
||||
return (
|
||||
log.statusCode >= 200 &&
|
||||
log.statusCode < 300 &&
|
||||
hasUsageTokens(log) &&
|
||||
Number.isFinite(totalCost) &&
|
||||
(!Number.isFinite(multiplier) || multiplier !== 0) &&
|
||||
totalCost === 0
|
||||
);
|
||||
}
|
||||
|
||||
export interface StatsFilters {
|
||||
timeRange: UsageRangePreset;
|
||||
providerId?: string;
|
||||
|
||||
Reference in New Issue
Block a user