feat(usage): add cost analytics and refine dashboard layout
This commit is contained in:
1 parent
3d2a7f357e
commit
7b9566ef73
6 files changed
+1427
-22
No files matched your search
@@ -13756,6 +13756,10 @@ fn main() {
|
||||
usage::get_usage_overview,
|
||||
usage::get_usage_analysis,
|
||||
usage::get_usage_events,
|
||||
usage::get_usage_pricing,
|
||||
usage::save_usage_model_price,
|
||||
usage::delete_usage_model_price,
|
||||
usage::sync_usage_model_prices,
|
||||
start_core_process,
|
||||
stop_core_process,
|
||||
restart_core_process,
|
||||
|
||||
+847
-4
@@ -13,6 +13,7 @@ use sha2::{Digest, Sha256};
|
||||
#[cfg(test)]
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::Mutex,
|
||||
@@ -30,6 +31,11 @@ const USAGE_UPDATED_EVENT: &str = "usage-records-updated";
|
||||
const USAGE_SCHEMA_VERSION: u8 = 1;
|
||||
const USAGE_QUEUE_BATCH_SIZE: usize = 500;
|
||||
const SQLITE_BUSY_TIMEOUT_SECONDS: u64 = 5;
|
||||
const TOKENS_PER_PRICE_UNIT: f64 = 1_000_000.0;
|
||||
const LONG_CONTEXT_INPUT_TOKEN_THRESHOLD: u64 = 272_000;
|
||||
const BUNDLED_MODEL_PRICE_CATALOG: &str = include_str!("../resources/model_prices.json");
|
||||
const MODEL_PRICE_SYNC_URL: &str =
|
||||
"https://raw.githubusercontent.com/router-for-me/EasyCLIProxyAPI/main/src-tauri/resources/model_prices.json";
|
||||
|
||||
pub(crate) struct UsageCollectorState {
|
||||
inner: Mutex<UsageCollectorInner>,
|
||||
@@ -187,9 +193,107 @@ pub(crate) struct UsageOverview {
|
||||
tps: f64,
|
||||
average_latency_ms: f64,
|
||||
cache_hit_rate: f64,
|
||||
estimated_cost: f64,
|
||||
priced_requests: u64,
|
||||
timeline: Vec<UsageTimelinePoint>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ModelPrice {
|
||||
model: String,
|
||||
prompt: f64,
|
||||
completion: f64,
|
||||
cache: f64,
|
||||
cache_read: f64,
|
||||
cache_creation: f64,
|
||||
prompt_configured: bool,
|
||||
completion_configured: bool,
|
||||
cache_read_configured: bool,
|
||||
cache_creation_configured: bool,
|
||||
source: String,
|
||||
source_model_id: String,
|
||||
updated_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ModelPriceCatalog {
|
||||
schema_version: u8,
|
||||
#[serde(default)]
|
||||
updated_at: String,
|
||||
models: HashMap<String, CatalogModelPrice>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CatalogModelPrice {
|
||||
input_per_1_m: f64,
|
||||
output_per_1_m: f64,
|
||||
#[serde(default)]
|
||||
cache_read_per_1_m: Option<f64>,
|
||||
#[serde(default)]
|
||||
cache_creation_per_1_m: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CostTokens {
|
||||
input: u64,
|
||||
output: u64,
|
||||
cache_read: u64,
|
||||
cache_creation: u64,
|
||||
long_input: u64,
|
||||
long_output: u64,
|
||||
long_cache_read: u64,
|
||||
long_cache_creation: u64,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UsagePricing {
|
||||
rows: Vec<UsagePriceRow>,
|
||||
total_cost: f64,
|
||||
total_requests: u64,
|
||||
priced_requests: u64,
|
||||
saved_prices: usize,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UsagePriceRow {
|
||||
model: String,
|
||||
requests: u64,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cache_read_tokens: u64,
|
||||
cache_creation_tokens: u64,
|
||||
total_tokens: u64,
|
||||
estimated_cost: f64,
|
||||
price: Option<ModelPrice>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ModelPriceSyncResult {
|
||||
imported: usize,
|
||||
skipped: usize,
|
||||
unmatched: Vec<String>,
|
||||
used_builtin: bool,
|
||||
}
|
||||
|
||||
struct UsageCostGroup {
|
||||
model: String,
|
||||
alias: String,
|
||||
service_tier: String,
|
||||
response_service_tier: String,
|
||||
executor_type: String,
|
||||
provider: String,
|
||||
auth_type: String,
|
||||
requests: u64,
|
||||
tokens: CostTokens,
|
||||
total_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UsageTimelinePoint {
|
||||
@@ -350,7 +454,23 @@ fn initialize_usage_schema(connection: &Connection) -> Result<(), String> {
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_events_failed_timestamp
|
||||
ON usage_events(failed, timestamp_ms DESC);
|
||||
|
||||
PRAGMA user_version = 1;
|
||||
CREATE TABLE IF NOT EXISTS model_prices (
|
||||
model TEXT PRIMARY KEY NOT NULL,
|
||||
prompt_per_1m REAL NOT NULL DEFAULT 0,
|
||||
completion_per_1m REAL NOT NULL DEFAULT 0,
|
||||
cache_per_1m REAL NOT NULL DEFAULT 0,
|
||||
cache_read_per_1m REAL NOT NULL DEFAULT 0,
|
||||
cache_creation_per_1m REAL NOT NULL DEFAULT 0,
|
||||
prompt_configured INTEGER NOT NULL DEFAULT 0,
|
||||
completion_configured INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_configured INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_configured INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
source_model_id TEXT NOT NULL DEFAULT '',
|
||||
updated_at_ms INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
PRAGMA user_version = 2;
|
||||
"#,
|
||||
)
|
||||
.map_err(|error| format!("初始化 SQLite 使用记录结构失败: {error}"))
|
||||
@@ -917,6 +1037,8 @@ fn load_usage_overview(
|
||||
)
|
||||
.map_err(|error| format!("统计 SQLite 使用记录失败: {error}"))?;
|
||||
|
||||
let (estimated_cost, priced_requests) = load_estimated_cost(connection, &filter)?;
|
||||
|
||||
let timeline_sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -958,6 +1080,8 @@ fn load_usage_overview(
|
||||
cache_read_tokens: from_sql_i64(summary.6),
|
||||
cache_creation_tokens: from_sql_i64(summary.7),
|
||||
total_tokens: from_sql_i64(summary.8),
|
||||
estimated_cost,
|
||||
priced_requests,
|
||||
timeline,
|
||||
..UsageOverview::default()
|
||||
};
|
||||
@@ -978,6 +1102,625 @@ fn load_usage_overview(
|
||||
Ok(overview)
|
||||
}
|
||||
|
||||
fn load_estimated_cost(
|
||||
connection: &Connection,
|
||||
filter: &UsageSqlFilter,
|
||||
) -> Result<(f64, u64), String> {
|
||||
let prices = load_model_prices(connection)?;
|
||||
let groups = load_usage_cost_groups(connection, filter)?;
|
||||
Ok(sum_usage_cost(&groups, &prices))
|
||||
}
|
||||
|
||||
fn load_usage_cost_groups(
|
||||
connection: &Connection,
|
||||
filter: &UsageSqlFilter,
|
||||
) -> Result<Vec<UsageCostGroup>, String> {
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
model,
|
||||
alias,
|
||||
service_tier,
|
||||
response_service_tier,
|
||||
executor_type,
|
||||
provider,
|
||||
auth_type,
|
||||
COUNT(*),
|
||||
COALESCE(SUM(input_tokens), 0),
|
||||
COALESCE(SUM(output_tokens), 0),
|
||||
COALESCE(SUM(cache_read_tokens), 0),
|
||||
COALESCE(SUM(cache_creation_tokens), 0),
|
||||
COALESCE(SUM(CASE WHEN input_tokens > {LONG_CONTEXT_INPUT_TOKEN_THRESHOLD} THEN input_tokens ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN input_tokens > {LONG_CONTEXT_INPUT_TOKEN_THRESHOLD} THEN output_tokens ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN input_tokens > {LONG_CONTEXT_INPUT_TOKEN_THRESHOLD} THEN cache_read_tokens ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN input_tokens > {LONG_CONTEXT_INPUT_TOKEN_THRESHOLD} THEN cache_creation_tokens ELSE 0 END), 0),
|
||||
COALESCE(SUM(total_tokens), 0)
|
||||
FROM usage_events{}
|
||||
GROUP BY model, alias, service_tier, response_service_tier, executor_type, provider, auth_type
|
||||
"#,
|
||||
filter.clause
|
||||
);
|
||||
let mut statement = connection
|
||||
.prepare(&sql)
|
||||
.map_err(|error| format!("准备使用成本查询失败: {error}"))?;
|
||||
let groups = statement
|
||||
.query_map(params_from_iter(filter.params.iter()), |row| {
|
||||
Ok(UsageCostGroup {
|
||||
model: row.get(0)?,
|
||||
alias: row.get(1)?,
|
||||
service_tier: row.get(2)?,
|
||||
response_service_tier: row.get(3)?,
|
||||
executor_type: row.get(4)?,
|
||||
provider: row.get(5)?,
|
||||
auth_type: row.get(6)?,
|
||||
requests: from_sql_i64(row.get(7)?),
|
||||
tokens: CostTokens {
|
||||
input: from_sql_i64(row.get(8)?),
|
||||
output: from_sql_i64(row.get(9)?),
|
||||
cache_read: from_sql_i64(row.get(10)?),
|
||||
cache_creation: from_sql_i64(row.get(11)?),
|
||||
long_input: from_sql_i64(row.get(12)?),
|
||||
long_output: from_sql_i64(row.get(13)?),
|
||||
long_cache_read: from_sql_i64(row.get(14)?),
|
||||
long_cache_creation: from_sql_i64(row.get(15)?),
|
||||
},
|
||||
total_tokens: from_sql_i64(row.get(16)?),
|
||||
})
|
||||
})
|
||||
.map_err(|error| format!("查询使用成本失败: {error}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("读取使用成本失败: {error}"))?;
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
fn sum_usage_cost(groups: &[UsageCostGroup], prices: &HashMap<String, ModelPrice>) -> (f64, u64) {
|
||||
let mut total_cost = 0.0;
|
||||
let mut priced_requests = 0_u64;
|
||||
for group in groups {
|
||||
let Some((model, price)) = resolve_model_price(&group.model, &group.alias, prices) else {
|
||||
continue;
|
||||
};
|
||||
let identity = format!(
|
||||
"{} {} {}",
|
||||
group.executor_type, group.provider, group.auth_type
|
||||
)
|
||||
.to_ascii_lowercase();
|
||||
let service_tier =
|
||||
if identity.contains("codex") || group.response_service_tier.trim().is_empty() {
|
||||
group.service_tier.as_str()
|
||||
} else {
|
||||
group.response_service_tier.as_str()
|
||||
};
|
||||
total_cost += cost_for_price(model, service_tier, &group.tokens, &price);
|
||||
priced_requests = priced_requests.saturating_add(group.requests);
|
||||
}
|
||||
(total_cost, priced_requests)
|
||||
}
|
||||
|
||||
fn cost_for_price(model: &str, service_tier: &str, tokens: &CostTokens, price: &ModelPrice) -> f64 {
|
||||
let price = enriched_model_price(model, price);
|
||||
let short_cost = cost_for_token_segment(
|
||||
tokens.input.saturating_sub(tokens.long_input),
|
||||
tokens.output.saturating_sub(tokens.long_output),
|
||||
tokens.cache_read.saturating_sub(tokens.long_cache_read),
|
||||
tokens
|
||||
.cache_creation
|
||||
.saturating_sub(tokens.long_cache_creation),
|
||||
&price,
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
let long_cost = cost_for_token_segment(
|
||||
tokens.long_input,
|
||||
tokens.long_output,
|
||||
tokens.long_cache_read,
|
||||
tokens.long_cache_creation,
|
||||
&price,
|
||||
2.0,
|
||||
1.5,
|
||||
);
|
||||
let tier = service_tier.trim().to_ascii_lowercase();
|
||||
let multiplier = if tokens.long_input > 0 && matches!(tier.as_str(), "priority" | "fast") {
|
||||
1.0
|
||||
} else {
|
||||
match tier.as_str() {
|
||||
"flex" | "batch" => 0.5,
|
||||
"priority" | "fast" => service_tier_multiplier(model),
|
||||
_ => 1.0,
|
||||
}
|
||||
};
|
||||
(short_cost + long_cost) * multiplier
|
||||
}
|
||||
|
||||
fn cost_for_token_segment(
|
||||
input: u64,
|
||||
output: u64,
|
||||
cache_read: u64,
|
||||
cache_creation: u64,
|
||||
price: &ModelPrice,
|
||||
input_multiplier: f64,
|
||||
output_multiplier: f64,
|
||||
) -> f64 {
|
||||
let prompt = input.saturating_sub(cache_read.saturating_add(cache_creation));
|
||||
((prompt as f64 * price.prompt
|
||||
+ cache_read as f64 * price.cache_read
|
||||
+ cache_creation as f64 * price.cache_creation)
|
||||
* input_multiplier
|
||||
+ output as f64 * price.completion * output_multiplier)
|
||||
/ TOKENS_PER_PRICE_UNIT
|
||||
}
|
||||
|
||||
fn official_model_price(model: &str) -> Option<ModelPrice> {
|
||||
let prices = bundled_model_prices().ok()?;
|
||||
find_model_price(&prices, model).cloned()
|
||||
}
|
||||
|
||||
fn bundled_model_prices() -> Result<HashMap<String, ModelPrice>, String> {
|
||||
parse_model_price_catalog(BUNDLED_MODEL_PRICE_CATALOG, "builtin", 0)
|
||||
}
|
||||
|
||||
fn parse_model_price_catalog(
|
||||
content: &str,
|
||||
source: &str,
|
||||
updated_at_ms: i64,
|
||||
) -> Result<HashMap<String, ModelPrice>, String> {
|
||||
let catalog = serde_json::from_str::<ModelPriceCatalog>(content)
|
||||
.map_err(|error| format!("解析模型价格文件失败: {error}"))?;
|
||||
if catalog.schema_version != 1 {
|
||||
return Err(format!(
|
||||
"不支持的模型价格文件版本 {}",
|
||||
catalog.schema_version
|
||||
));
|
||||
}
|
||||
if catalog.models.is_empty() {
|
||||
return Err("模型价格文件不包含任何模型".to_string());
|
||||
}
|
||||
let _catalog_updated_at = catalog.updated_at;
|
||||
let mut prices = HashMap::with_capacity(catalog.models.len());
|
||||
for (model, entry) in catalog.models {
|
||||
let cache_read = entry.cache_read_per_1_m.unwrap_or(0.0);
|
||||
let cache_creation = entry.cache_creation_per_1_m.unwrap_or(0.0);
|
||||
let price = ModelPrice {
|
||||
model: model.trim().to_string(),
|
||||
prompt: entry.input_per_1_m,
|
||||
completion: entry.output_per_1_m,
|
||||
cache: cache_read,
|
||||
cache_read,
|
||||
cache_creation,
|
||||
prompt_configured: true,
|
||||
completion_configured: true,
|
||||
cache_read_configured: entry.cache_read_per_1_m.is_some(),
|
||||
cache_creation_configured: entry.cache_creation_per_1_m.is_some(),
|
||||
source: source.to_string(),
|
||||
source_model_id: String::new(),
|
||||
updated_at_ms,
|
||||
};
|
||||
validate_model_price(&price)?;
|
||||
prices.insert(price.model.clone(), price);
|
||||
}
|
||||
Ok(prices)
|
||||
}
|
||||
|
||||
fn find_model_price<'a>(
|
||||
prices: &'a HashMap<String, ModelPrice>,
|
||||
model: &str,
|
||||
) -> Option<&'a ModelPrice> {
|
||||
if let Some(price) = prices.get(model) {
|
||||
return Some(price);
|
||||
}
|
||||
let case_insensitive = prices
|
||||
.iter()
|
||||
.filter(|(key, _)| key.eq_ignore_ascii_case(model))
|
||||
.collect::<Vec<_>>();
|
||||
if case_insensitive.len() == 1 {
|
||||
return Some(case_insensitive[0].1);
|
||||
}
|
||||
let tail = canonical_model_tail(model);
|
||||
let exact_tail = prices
|
||||
.iter()
|
||||
.filter(|(key, _)| canonical_model_tail(key) == tail)
|
||||
.collect::<Vec<_>>();
|
||||
if exact_tail.len() == 1 {
|
||||
return Some(exact_tail[0].1);
|
||||
}
|
||||
let normalized_tail = normalized_model_tail(model);
|
||||
prices
|
||||
.iter()
|
||||
.filter_map(|(key, price)| {
|
||||
let key_tail = normalized_model_tail(key);
|
||||
normalized_tail
|
||||
.starts_with(&format!("{key_tail}-"))
|
||||
.then_some((key_tail.len(), price))
|
||||
})
|
||||
.max_by_key(|(length, _)| *length)
|
||||
.map(|(_, price)| price)
|
||||
}
|
||||
|
||||
fn resolve_model_price<'a>(
|
||||
model: &'a str,
|
||||
alias: &'a str,
|
||||
prices: &HashMap<String, ModelPrice>,
|
||||
) -> Option<(&'a str, ModelPrice)> {
|
||||
for candidate in [model, alias] {
|
||||
if candidate.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(price) = find_model_price(prices, candidate) {
|
||||
return Some((model, price.clone()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn enriched_model_price(model: &str, price: &ModelPrice) -> ModelPrice {
|
||||
let mut price = price.clone();
|
||||
if let Some(official) = official_model_price(model) {
|
||||
if !price.prompt_configured && price.prompt <= 0.0 {
|
||||
price.prompt = official.prompt;
|
||||
}
|
||||
if !price.completion_configured && price.completion <= 0.0 {
|
||||
price.completion = official.completion;
|
||||
}
|
||||
}
|
||||
if !price.cache_read_configured && price.cache_read <= 0.0 {
|
||||
price.cache_read = if price.cache > 0.0 {
|
||||
price.cache
|
||||
} else {
|
||||
price.prompt * 0.1
|
||||
};
|
||||
}
|
||||
if !price.cache_creation_configured && price.cache_creation <= 0.0 {
|
||||
price.cache_creation = price.prompt
|
||||
* if is_model_family(model, "gpt-5.6") {
|
||||
1.25
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
}
|
||||
price
|
||||
}
|
||||
|
||||
fn is_model_family(model: &str, family: &str) -> bool {
|
||||
let normalized = model
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
normalized == family || normalized.starts_with(&format!("{family}-"))
|
||||
}
|
||||
|
||||
fn service_tier_multiplier(model: &str) -> f64 {
|
||||
if is_model_family(model, "gpt-5.5") {
|
||||
2.5
|
||||
} else if is_model_family(model, "gpt-5.6")
|
||||
|| is_model_family(model, "gpt-5.4")
|
||||
|| is_model_family(model, "gpt-5.4-mini")
|
||||
|| is_model_family(model, "gpt-5.3-codex")
|
||||
{
|
||||
2.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
fn load_model_prices(connection: &Connection) -> Result<HashMap<String, ModelPrice>, String> {
|
||||
let mut merged = bundled_model_prices()?;
|
||||
let mut statement = connection
|
||||
.prepare(
|
||||
r#"
|
||||
SELECT model, prompt_per_1m, completion_per_1m, cache_per_1m,
|
||||
cache_read_per_1m, cache_creation_per_1m,
|
||||
prompt_configured, completion_configured,
|
||||
cache_read_configured, cache_creation_configured,
|
||||
source, source_model_id, updated_at_ms
|
||||
FROM model_prices ORDER BY model
|
||||
"#,
|
||||
)
|
||||
.map_err(|error| format!("准备模型价格查询失败: {error}"))?;
|
||||
let prices = statement
|
||||
.query_map([], |row| {
|
||||
Ok(ModelPrice {
|
||||
model: row.get(0)?,
|
||||
prompt: row.get(1)?,
|
||||
completion: row.get(2)?,
|
||||
cache: row.get(3)?,
|
||||
cache_read: row.get(4)?,
|
||||
cache_creation: row.get(5)?,
|
||||
prompt_configured: row.get(6)?,
|
||||
completion_configured: row.get(7)?,
|
||||
cache_read_configured: row.get(8)?,
|
||||
cache_creation_configured: row.get(9)?,
|
||||
source: row.get(10)?,
|
||||
source_model_id: row.get(11)?,
|
||||
updated_at_ms: row.get(12)?,
|
||||
})
|
||||
})
|
||||
.map_err(|error| format!("查询模型价格失败: {error}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("读取模型价格失败: {error}"))?;
|
||||
for price in prices {
|
||||
if price.source != "litellm" {
|
||||
if let Some(existing) = merged
|
||||
.keys()
|
||||
.find(|model| model.eq_ignore_ascii_case(&price.model))
|
||||
.cloned()
|
||||
{
|
||||
merged.remove(&existing);
|
||||
}
|
||||
merged.insert(price.model.clone(), price);
|
||||
}
|
||||
}
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn validate_model_price(price: &ModelPrice) -> Result<(), String> {
|
||||
if price.model.trim().is_empty() {
|
||||
return Err("模型名称不能为空".to_string());
|
||||
}
|
||||
for value in [
|
||||
price.prompt,
|
||||
price.completion,
|
||||
price.cache,
|
||||
price.cache_read,
|
||||
price.cache_creation,
|
||||
] {
|
||||
if !value.is_finite() || value < 0.0 {
|
||||
return Err(format!("模型 {} 包含无效价格", price.model));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_model_price(connection: &Connection, price: &ModelPrice) -> Result<(), String> {
|
||||
validate_model_price(price)?;
|
||||
connection
|
||||
.execute(
|
||||
r#"
|
||||
INSERT INTO model_prices (
|
||||
model, prompt_per_1m, completion_per_1m, cache_per_1m,
|
||||
cache_read_per_1m, cache_creation_per_1m,
|
||||
prompt_configured, completion_configured,
|
||||
cache_read_configured, cache_creation_configured,
|
||||
source, source_model_id, updated_at_ms
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
|
||||
ON CONFLICT(model) DO UPDATE SET
|
||||
prompt_per_1m = excluded.prompt_per_1m,
|
||||
completion_per_1m = excluded.completion_per_1m,
|
||||
cache_per_1m = excluded.cache_per_1m,
|
||||
cache_read_per_1m = excluded.cache_read_per_1m,
|
||||
cache_creation_per_1m = excluded.cache_creation_per_1m,
|
||||
prompt_configured = excluded.prompt_configured,
|
||||
completion_configured = excluded.completion_configured,
|
||||
cache_read_configured = excluded.cache_read_configured,
|
||||
cache_creation_configured = excluded.cache_creation_configured,
|
||||
source = excluded.source,
|
||||
source_model_id = excluded.source_model_id,
|
||||
updated_at_ms = excluded.updated_at_ms
|
||||
"#,
|
||||
params![
|
||||
price.model.trim(),
|
||||
price.prompt,
|
||||
price.completion,
|
||||
price.cache,
|
||||
price.cache_read,
|
||||
price.cache_creation,
|
||||
price.prompt_configured,
|
||||
price.completion_configured,
|
||||
price.cache_read_configured,
|
||||
price.cache_creation_configured,
|
||||
price.source,
|
||||
price.source_model_id,
|
||||
price.updated_at_ms,
|
||||
],
|
||||
)
|
||||
.map_err(|error| format!("保存模型价格失败: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_usage_pricing(query: UsageQuery) -> Result<UsagePricing, String> {
|
||||
let connection = open_usage_database()?;
|
||||
load_usage_pricing(&connection, &query)
|
||||
}
|
||||
|
||||
fn load_usage_pricing(connection: &Connection, query: &UsageQuery) -> Result<UsagePricing, String> {
|
||||
let filter = build_usage_filter(query);
|
||||
let prices = load_model_prices(connection)?;
|
||||
let groups = load_usage_cost_groups(connection, &filter)?;
|
||||
let mut rows = HashMap::<String, UsagePriceRow>::new();
|
||||
let mut total_requests = 0_u64;
|
||||
let mut priced_requests = 0_u64;
|
||||
let mut total_cost = 0.0;
|
||||
for group in &groups {
|
||||
total_requests = total_requests.saturating_add(group.requests);
|
||||
let entry = rows
|
||||
.entry(group.model.clone())
|
||||
.or_insert_with(|| UsagePriceRow {
|
||||
model: group.model.clone(),
|
||||
..UsagePriceRow::default()
|
||||
});
|
||||
entry.requests = entry.requests.saturating_add(group.requests);
|
||||
entry.input_tokens = entry.input_tokens.saturating_add(group.tokens.input);
|
||||
entry.output_tokens = entry.output_tokens.saturating_add(group.tokens.output);
|
||||
entry.cache_read_tokens = entry
|
||||
.cache_read_tokens
|
||||
.saturating_add(group.tokens.cache_read);
|
||||
entry.cache_creation_tokens = entry
|
||||
.cache_creation_tokens
|
||||
.saturating_add(group.tokens.cache_creation);
|
||||
entry.total_tokens = entry.total_tokens.saturating_add(group.total_tokens);
|
||||
|
||||
if let Some((model, price)) = resolve_model_price(&group.model, &group.alias, &prices) {
|
||||
let identity = format!(
|
||||
"{} {} {}",
|
||||
group.executor_type, group.provider, group.auth_type
|
||||
)
|
||||
.to_ascii_lowercase();
|
||||
let service_tier =
|
||||
if identity.contains("codex") || group.response_service_tier.trim().is_empty() {
|
||||
group.service_tier.as_str()
|
||||
} else {
|
||||
group.response_service_tier.as_str()
|
||||
};
|
||||
let cost = cost_for_price(model, service_tier, &group.tokens, &price);
|
||||
entry.estimated_cost += cost;
|
||||
entry.price = Some(price);
|
||||
total_cost += cost;
|
||||
priced_requests = priced_requests.saturating_add(group.requests);
|
||||
}
|
||||
}
|
||||
for price in prices.values() {
|
||||
rows.entry(price.model.clone())
|
||||
.or_insert_with(|| UsagePriceRow {
|
||||
model: price.model.clone(),
|
||||
price: Some(price.clone()),
|
||||
..UsagePriceRow::default()
|
||||
});
|
||||
}
|
||||
let mut rows = rows.into_values().collect::<Vec<_>>();
|
||||
rows.sort_by(|left, right| {
|
||||
left.price
|
||||
.is_some()
|
||||
.cmp(&right.price.is_some())
|
||||
.then_with(|| right.requests.cmp(&left.requests))
|
||||
.then_with(|| left.model.cmp(&right.model))
|
||||
});
|
||||
Ok(UsagePricing {
|
||||
rows,
|
||||
total_cost,
|
||||
total_requests,
|
||||
priced_requests,
|
||||
saved_prices: prices.len(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn save_usage_model_price(mut price: ModelPrice) -> Result<(), String> {
|
||||
let connection = open_usage_database()?;
|
||||
price.model = price.model.trim().to_string();
|
||||
price.source = "manual".to_string();
|
||||
price.source_model_id.clear();
|
||||
price.updated_at_ms = Local::now().timestamp_millis();
|
||||
upsert_model_price(&connection, &price)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn delete_usage_model_price(model: String) -> Result<(), String> {
|
||||
let connection = open_usage_database()?;
|
||||
connection
|
||||
.execute(
|
||||
"DELETE FROM model_prices WHERE model = ?1 COLLATE NOCASE",
|
||||
params![model.trim()],
|
||||
)
|
||||
.map_err(|error| format!("删除模型价格失败: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn sync_usage_model_prices(
|
||||
query: UsageQuery,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<ModelPriceSyncResult, String> {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let mut client_builder = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(30));
|
||||
let proxy_url = config.proxy_url.trim();
|
||||
let proxy_valid = if proxy_url.is_empty() {
|
||||
true
|
||||
} else if let Ok(proxy) = reqwest::Proxy::all(proxy_url) {
|
||||
client_builder = client_builder.proxy(proxy);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let remote_content = if proxy_valid {
|
||||
match client_builder.build() {
|
||||
Ok(client) => match client.get(MODEL_PRICE_SYNC_URL).send().await {
|
||||
Ok(response) if response.status().is_success() => response.text().await.ok(),
|
||||
_ => None,
|
||||
},
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let now = Local::now().timestamp_millis();
|
||||
let (remote_prices, used_builtin) = match remote_content
|
||||
.as_deref()
|
||||
.and_then(|content| parse_model_price_catalog(content, "github", now).ok())
|
||||
{
|
||||
Some(prices) => (prices, false),
|
||||
None => (bundled_model_prices()?, true),
|
||||
};
|
||||
|
||||
let mut connection = open_usage_database()?;
|
||||
let current_prices = load_model_prices(&connection)?;
|
||||
let manual_models = current_prices
|
||||
.values()
|
||||
.filter(|price| price.source == "manual")
|
||||
.map(|price| price.model.to_ascii_lowercase())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let mut result = ModelPriceSyncResult {
|
||||
used_builtin,
|
||||
..ModelPriceSyncResult::default()
|
||||
};
|
||||
if !used_builtin {
|
||||
let transaction = connection
|
||||
.transaction()
|
||||
.map_err(|error| format!("开始更新模型价格失败: {error}"))?;
|
||||
transaction
|
||||
.execute("DELETE FROM model_prices WHERE source = 'github'", [])
|
||||
.map_err(|error| format!("清理旧模型价格失败: {error}"))?;
|
||||
for price in remote_prices.values() {
|
||||
if manual_models.contains(&price.model.to_ascii_lowercase()) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
upsert_model_price(&transaction, price)?;
|
||||
result.imported += 1;
|
||||
}
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| format!("提交模型价格更新失败: {error}"))?;
|
||||
} else {
|
||||
connection
|
||||
.execute("DELETE FROM model_prices WHERE source = 'github'", [])
|
||||
.map_err(|error| format!("恢复软件内置模型价格失败: {error}"))?;
|
||||
}
|
||||
|
||||
let filter = build_usage_filter(&query);
|
||||
let models = load_usage_cost_groups(&connection, &filter)?
|
||||
.into_iter()
|
||||
.map(|group| group.model)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
let effective_prices = load_model_prices(&connection)?;
|
||||
for model in models {
|
||||
if resolve_model_price(&model, "", &effective_prices).is_none() {
|
||||
result.unmatched.push(model);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn canonical_model_tail(value: &str) -> String {
|
||||
normalized_model_tail(value)
|
||||
.chars()
|
||||
.filter(|character| character.is_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalized_model_tail(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.rsplit('/')
|
||||
.find(|part| !part.trim().is_empty() && !part.eq_ignore_ascii_case("models"))
|
||||
.unwrap_or(value)
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_usage_analysis(
|
||||
query: UsageQuery,
|
||||
@@ -1621,11 +2364,11 @@ mod tests {
|
||||
let root = test_root("sqlite-query");
|
||||
let mut connection = open_test_database(&root);
|
||||
let success = sample_record("request-1", "2026-07-17T20:30:00+08:00", "gpt-a");
|
||||
let mut failed = sample_record("request-2", "2026-07-17T21:30:00+08:00", "gpt-b");
|
||||
let mut failed = sample_record("request-2", "2026-07-17T21:30:00+08:00", "gpt-5.6-terra");
|
||||
failed.failed = true;
|
||||
insert_usage_records(&mut connection, &[success, failed]).unwrap();
|
||||
let query = UsageQuery {
|
||||
model: Some("GPT-B".to_string()),
|
||||
model: Some("GPT-5.6-TERRA".to_string()),
|
||||
failed: Some(true),
|
||||
page_size: Some(20),
|
||||
..UsageQuery::default()
|
||||
@@ -1640,10 +2383,110 @@ mod tests {
|
||||
assert_eq!(overview.failure_count, 1);
|
||||
assert_eq!(overview.tps, 200.0);
|
||||
assert_eq!(overview.cache_hit_rate, 0.2);
|
||||
assert_eq!(analysis.models[0].key, "gpt-b");
|
||||
assert!((overview.estimated_cost - 0.0003205).abs() < 0.0000001);
|
||||
assert_eq!(overview.priced_requests, 1);
|
||||
assert_eq!(analysis.models[0].key, "gpt-5.6-terra");
|
||||
assert_eq!(events.total, 1);
|
||||
assert_eq!(events.items[0].id, "request-2");
|
||||
drop(connection);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimates_gpt56_cost_with_cache_and_service_tier_rules() {
|
||||
let terra = official_model_price("openai/gpt-5.6-terra").unwrap();
|
||||
let standard_tokens = CostTokens {
|
||||
input: 1_000_000,
|
||||
output: 1_000_000,
|
||||
cache_read: 200_000,
|
||||
cache_creation: 100_000,
|
||||
..CostTokens::default()
|
||||
};
|
||||
let standard = cost_for_price("openai/gpt-5.6-terra", "default", &standard_tokens, &terra);
|
||||
assert!((standard - 17.1125).abs() < 0.000001);
|
||||
|
||||
let long_tokens = CostTokens {
|
||||
input: 300_000,
|
||||
output: 200_000,
|
||||
cache_read: 100_000,
|
||||
long_input: 300_000,
|
||||
long_output: 200_000,
|
||||
long_cache_read: 100_000,
|
||||
..CostTokens::default()
|
||||
};
|
||||
let long_priority = cost_for_price("gpt-5.6-terra", "priority", &long_tokens, &terra);
|
||||
assert!((long_priority - 5.55).abs() < 0.000001);
|
||||
assert!(official_model_price("unpriced-model").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_model_prices_are_available_offline_and_override_legacy_cloud_cache() {
|
||||
let root = test_root("bundled-pricing");
|
||||
let connection = open_test_database(&root);
|
||||
let bundled = bundled_model_prices().unwrap();
|
||||
assert!(bundled.len() >= 50);
|
||||
assert!(bundled.values().all(|price| price.source == "builtin"));
|
||||
assert_eq!(
|
||||
find_model_price(&bundled, "openai/gpt-5.6-terra-high")
|
||||
.unwrap()
|
||||
.prompt,
|
||||
2.5
|
||||
);
|
||||
|
||||
upsert_model_price(
|
||||
&connection,
|
||||
&ModelPrice {
|
||||
model: "gpt-5.6-terra".to_string(),
|
||||
prompt: 999.0,
|
||||
completion: 999.0,
|
||||
prompt_configured: true,
|
||||
completion_configured: true,
|
||||
source: "litellm".to_string(),
|
||||
..ModelPrice::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let prices = load_model_prices(&connection).unwrap();
|
||||
assert_eq!(prices["gpt-5.6-terra"].prompt, 2.5);
|
||||
assert_eq!(prices["gpt-5.6-terra"].source, "builtin");
|
||||
drop(connection);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_model_prices_drive_pricing_and_overview_cost() {
|
||||
let root = test_root("manual-pricing");
|
||||
let mut connection = open_test_database(&root);
|
||||
let record = sample_record("request-1", "2026-07-17T20:30:00+08:00", "custom-model");
|
||||
insert_usage_records(&mut connection, &[record]).unwrap();
|
||||
upsert_model_price(
|
||||
&connection,
|
||||
&ModelPrice {
|
||||
model: "custom-model".to_string(),
|
||||
prompt: 1.0,
|
||||
completion: 2.0,
|
||||
cache: 0.1,
|
||||
prompt_configured: true,
|
||||
completion_configured: true,
|
||||
source: "manual".to_string(),
|
||||
..ModelPrice::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let query = UsageQuery::default();
|
||||
let overview = load_usage_overview(&connection, &query).unwrap();
|
||||
let pricing = load_usage_pricing(&connection, &query).unwrap();
|
||||
|
||||
assert!((overview.estimated_cost - 0.0000482).abs() < 0.0000001);
|
||||
assert_eq!(overview.priced_requests, 1);
|
||||
assert!((pricing.total_cost - overview.estimated_cost).abs() < f64::EPSILON);
|
||||
assert_eq!(pricing.rows[0].model, "custom-model");
|
||||
assert_eq!(
|
||||
pricing.saved_prices,
|
||||
bundled_model_prices().unwrap().len() + 1
|
||||
);
|
||||
drop(connection);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user