From 7b9566ef73f54548cf1e03455913bd2cc5808eb2 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Wed, 29 Jul 2026 01:38:25 +0800 Subject: [PATCH] feat(usage): add cost analytics and refine dashboard layout --- src-tauri/resources/model_prices.json | 63 ++ src-tauri/src/main.rs | 4 + src-tauri/src/usage.rs | 851 +++++++++++++++++++++++++- src/i18n/resources.ts | 54 +- src/pages/UsageRecordsPage.tsx | 221 ++++++- src/styles.css | 256 +++++++- 6 files changed, 1427 insertions(+), 22 deletions(-) create mode 100644 src-tauri/resources/model_prices.json diff --git a/src-tauri/resources/model_prices.json b/src-tauri/resources/model_prices.json new file mode 100644 index 0000000..0fac7ef --- /dev/null +++ b/src-tauri/resources/model_prices.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 1, + "updatedAt": "2026-07-28", + "models": { + "gpt-5.6": { "inputPer1M": 5.0, "outputPer1M": 30.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 }, + "gpt-5.6-sol": { "inputPer1M": 5.0, "outputPer1M": 30.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 }, + "gpt-5.6-terra": { "inputPer1M": 2.5, "outputPer1M": 15.0, "cacheReadPer1M": 0.25, "cacheCreationPer1M": 3.125 }, + "gpt-5.6-luna": { "inputPer1M": 1.0, "outputPer1M": 6.0, "cacheReadPer1M": 0.1, "cacheCreationPer1M": 1.25 }, + "gpt-5.5": { "inputPer1M": 5.0, "outputPer1M": 30.0, "cacheReadPer1M": 0.5 }, + "gpt-5.5-pro": { "inputPer1M": 30.0, "outputPer1M": 180.0, "cacheReadPer1M": 3.0 }, + "gpt-5.4": { "inputPer1M": 2.5, "outputPer1M": 15.0, "cacheReadPer1M": 0.25 }, + "gpt-5.4-pro": { "inputPer1M": 30.0, "outputPer1M": 180.0, "cacheReadPer1M": 3.0 }, + "gpt-5.4-mini": { "inputPer1M": 0.75, "outputPer1M": 4.5, "cacheReadPer1M": 0.075 }, + "gpt-5.4-nano": { "inputPer1M": 0.2, "outputPer1M": 1.25, "cacheReadPer1M": 0.02 }, + "gpt-5.3-codex": { "inputPer1M": 1.75, "outputPer1M": 14.0, "cacheReadPer1M": 0.175 }, + "gpt-5.2": { "inputPer1M": 1.75, "outputPer1M": 14.0, "cacheReadPer1M": 0.175 }, + "gpt-5.2-pro": { "inputPer1M": 21.0, "outputPer1M": 168.0 }, + "gpt-5.2-codex": { "inputPer1M": 1.75, "outputPer1M": 14.0, "cacheReadPer1M": 0.175 }, + "gpt-5.1": { "inputPer1M": 1.25, "outputPer1M": 10.0, "cacheReadPer1M": 0.125 }, + "gpt-5.1-codex": { "inputPer1M": 1.25, "outputPer1M": 10.0, "cacheReadPer1M": 0.125 }, + "gpt-5.1-codex-mini": { "inputPer1M": 0.25, "outputPer1M": 2.0, "cacheReadPer1M": 0.025 }, + "gpt-5": { "inputPer1M": 1.25, "outputPer1M": 10.0, "cacheReadPer1M": 0.125 }, + "gpt-5-pro": { "inputPer1M": 15.0, "outputPer1M": 120.0 }, + "gpt-5-mini": { "inputPer1M": 0.25, "outputPer1M": 2.0, "cacheReadPer1M": 0.025 }, + "gpt-5-nano": { "inputPer1M": 0.05, "outputPer1M": 0.4, "cacheReadPer1M": 0.005 }, + "gpt-4.1": { "inputPer1M": 2.0, "outputPer1M": 8.0, "cacheReadPer1M": 0.5 }, + "gpt-4.1-mini": { "inputPer1M": 0.4, "outputPer1M": 1.6, "cacheReadPer1M": 0.1 }, + "gpt-4.1-nano": { "inputPer1M": 0.1, "outputPer1M": 0.4, "cacheReadPer1M": 0.025 }, + "o1": { "inputPer1M": 15.0, "outputPer1M": 60.0, "cacheReadPer1M": 7.5 }, + "o1-pro": { "inputPer1M": 150.0, "outputPer1M": 600.0 }, + "o3": { "inputPer1M": 2.0, "outputPer1M": 8.0, "cacheReadPer1M": 0.5 }, + "o3-mini": { "inputPer1M": 1.1, "outputPer1M": 4.4, "cacheReadPer1M": 0.55 }, + "o3-pro": { "inputPer1M": 20.0, "outputPer1M": 80.0 }, + "o4-mini": { "inputPer1M": 1.1, "outputPer1M": 4.4, "cacheReadPer1M": 0.275 }, + "claude-opus-4-8": { "inputPer1M": 5.0, "outputPer1M": 25.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 }, + "claude-opus-4-7": { "inputPer1M": 5.0, "outputPer1M": 25.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 }, + "claude-opus-4-6": { "inputPer1M": 5.0, "outputPer1M": 25.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 }, + "claude-opus-4-5": { "inputPer1M": 5.0, "outputPer1M": 25.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 }, + "claude-opus-4-1": { "inputPer1M": 15.0, "outputPer1M": 75.0, "cacheReadPer1M": 1.5, "cacheCreationPer1M": 18.75 }, + "claude-sonnet-4-6": { "inputPer1M": 3.0, "outputPer1M": 15.0, "cacheReadPer1M": 0.3, "cacheCreationPer1M": 3.75 }, + "claude-sonnet-4-5": { "inputPer1M": 3.0, "outputPer1M": 15.0, "cacheReadPer1M": 0.3, "cacheCreationPer1M": 3.75 }, + "claude-sonnet-4": { "inputPer1M": 3.0, "outputPer1M": 15.0, "cacheReadPer1M": 0.3, "cacheCreationPer1M": 3.75 }, + "claude-haiku-4-5": { "inputPer1M": 1.0, "outputPer1M": 5.0, "cacheReadPer1M": 0.1, "cacheCreationPer1M": 1.25 }, + "gemini-3.6-flash": { "inputPer1M": 1.5, "outputPer1M": 7.5, "cacheReadPer1M": 0.15 }, + "gemini-3.5-flash": { "inputPer1M": 1.5, "outputPer1M": 9.0, "cacheReadPer1M": 0.15 }, + "gemini-3.1-pro-preview": { "inputPer1M": 2.0, "outputPer1M": 12.0, "cacheReadPer1M": 0.2 }, + "gemini-3.1-flash-lite": { "inputPer1M": 0.25, "outputPer1M": 1.5, "cacheReadPer1M": 0.025 }, + "gemini-3-pro-preview": { "inputPer1M": 2.0, "outputPer1M": 12.0, "cacheReadPer1M": 0.2 }, + "gemini-3-flash-preview": { "inputPer1M": 0.5, "outputPer1M": 3.0, "cacheReadPer1M": 0.05 }, + "gemini-2.5-pro": { "inputPer1M": 1.25, "outputPer1M": 10.0, "cacheReadPer1M": 0.125 }, + "gemini-2.5-flash": { "inputPer1M": 0.3, "outputPer1M": 2.5, "cacheReadPer1M": 0.03 }, + "gemini-2.5-flash-lite": { "inputPer1M": 0.1, "outputPer1M": 0.4, "cacheReadPer1M": 0.01 }, + "deepseek-chat": { "inputPer1M": 0.28, "outputPer1M": 0.42, "cacheReadPer1M": 0.028 }, + "deepseek-reasoner": { "inputPer1M": 0.28, "outputPer1M": 0.42, "cacheReadPer1M": 0.028 }, + "grok-4.5": { "inputPer1M": 2.0, "outputPer1M": 6.0, "cacheReadPer1M": 0.5 }, + "grok-4.3": { "inputPer1M": 1.25, "outputPer1M": 2.5, "cacheReadPer1M": 0.2 }, + "grok-4.20": { "inputPer1M": 2.0, "outputPer1M": 6.0, "cacheReadPer1M": 0.2 }, + "grok-4": { "inputPer1M": 3.0, "outputPer1M": 15.0 }, + "grok-4-1-fast": { "inputPer1M": 0.2, "outputPer1M": 0.5, "cacheReadPer1M": 0.05 }, + "grok-3": { "inputPer1M": 3.0, "outputPer1M": 15.0, "cacheReadPer1M": 0.75 }, + "grok-3-mini": { "inputPer1M": 0.3, "outputPer1M": 0.5, "cacheReadPer1M": 0.075 } + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 269ad95..9ed6cce 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 9e1d88a..72259ee 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -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, @@ -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, } +#[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, +} + +#[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, + #[serde(default)] + cache_creation_per_1_m: Option, +} + +#[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, + 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, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ModelPriceSyncResult { + imported: usize, + skipped: usize, + unmatched: Vec, + 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, 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::, _>>() + .map_err(|error| format!("读取使用成本失败: {error}"))?; + Ok(groups) +} + +fn sum_usage_cost(groups: &[UsageCostGroup], prices: &HashMap) -> (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 { + let prices = bundled_model_prices().ok()?; + find_model_price(&prices, model).cloned() +} + +fn bundled_model_prices() -> Result, 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, String> { + let catalog = serde_json::from_str::(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, + 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::>(); + 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::>(); + 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, +) -> 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, 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::, _>>() + .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 { + let connection = open_usage_database()?; + load_usage_pricing(&connection, &query) +} + +fn load_usage_pricing(connection: &Connection, query: &UsageQuery) -> Result { + let filter = build_usage_filter(query); + let prices = load_model_prices(connection)?; + let groups = load_usage_cost_groups(connection, &filter)?; + let mut rows = HashMap::::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::>(); + 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 { + 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::>(); + 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::>(); + 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(); + } } diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 35d0193..d81e6c6 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -98,6 +98,7 @@ export const zhCN = { 'usage.tab.overview': '总览', 'usage.tab.analysis': '分析', 'usage.tab.events': '请求明细', + 'usage.tab.pricing': '价格统计', 'usage.filter.timeRange': '时间范围', 'usage.range.4h': '最近 4 小时', 'usage.range.24h': '最近 24 小时', @@ -128,9 +129,33 @@ export const zhCN = { 'usage.stat.reasoningMeta': '思考 {tokens} Token', 'usage.stat.averageLatency': '平均延迟', 'usage.stat.tps': 'TPS', - 'usage.stat.performanceMeta': '{rpm} RPM · {tpm} TPM · 平均延迟 {latency} ms', + 'usage.stat.performanceMeta': '{rpm} RPM · 平均延迟 {latency} ms', 'usage.stat.cacheHitRate': '缓存命中率', 'usage.stat.cacheHitMeta': '命中 {hit} / 输入 {input} Token', + 'usage.stat.estimatedCost': '预估成本', + 'usage.stat.costMeta': '已计价 {priced} / {total} 次请求 · 仅供参考', + 'usage.pricing.coverage': '已计价 {priced} / {total} 次请求 · 可用 {saved} 个模型价格', + 'usage.pricing.search': '搜索模型', + 'usage.pricing.add': '手动添加', + 'usage.pricing.sync': '同步价格', + 'usage.pricing.syncing': '同步中', + 'usage.pricing.syncResult': '价格更新完成:导入 {imported},保留手动价格 {skipped},未匹配 {unmatched}', + 'usage.pricing.syncFallback': '远程价格读取失败,已继续使用软件内置价格', + 'usage.pricing.model': '模型', + 'usage.pricing.calls': '请求数', + 'usage.pricing.cost': '预估成本', + 'usage.pricing.prompt': '输入价 ($/1M)', + 'usage.pricing.completion': '输出价 ($/1M)', + 'usage.pricing.cache': '兼容缓存价 ($/1M)', + 'usage.pricing.cacheRead': '缓存读取价 ($/1M)', + 'usage.pricing.cacheCreation': '缓存创建价 ($/1M)', + 'usage.pricing.actions': '操作', + 'usage.pricing.optional': '可选,留空使用回退价', + 'usage.pricing.modelRequired': '请输入模型名称', + 'usage.pricing.saving': '保存中', + 'usage.pricing.saved': '模型价格已保存,总览成本已更新', + 'usage.pricing.deleted': '模型价格已删除,总览成本已更新', + 'usage.pricing.deleteConfirm': '确定删除模型 {model} 的价格吗?', 'usage.trend.title': '请求与 Token 趋势', 'usage.trend.description': '按系统本地小时聚合', 'usage.token.title': 'Token 构成', @@ -917,6 +942,7 @@ export const en: Record = { 'usage.tab.overview': 'Overview', 'usage.tab.analysis': 'Analysis', 'usage.tab.events': 'Request Details', + 'usage.tab.pricing': 'Price Statistics', 'usage.filter.timeRange': 'Time range', 'usage.range.4h': 'Last 4 Hours', 'usage.range.24h': 'Last 24 Hours', @@ -947,9 +973,33 @@ export const en: Record = { 'usage.stat.reasoningMeta': 'Reasoning {tokens} tokens', 'usage.stat.averageLatency': 'Average Latency', 'usage.stat.tps': 'TPS', - 'usage.stat.performanceMeta': '{rpm} RPM · {tpm} TPM · Avg latency {latency} ms', + 'usage.stat.performanceMeta': '{rpm} RPM · Avg latency {latency} ms', 'usage.stat.cacheHitRate': 'Cache Hit Rate', 'usage.stat.cacheHitMeta': '{hit} hit / {input} input tokens', + 'usage.stat.estimatedCost': 'Estimated Cost', + 'usage.stat.costMeta': '{priced} / {total} requests priced · Estimate only', + 'usage.pricing.coverage': '{priced} / {total} requests priced · {saved} model prices available', + 'usage.pricing.search': 'Search model', + 'usage.pricing.add': 'Add Manually', + 'usage.pricing.sync': 'Sync Prices', + 'usage.pricing.syncing': 'Syncing', + 'usage.pricing.syncResult': 'Prices updated: {imported} imported, {skipped} manual prices kept, {unmatched} unmatched', + 'usage.pricing.syncFallback': 'Remote prices could not be read; built-in prices remain active', + 'usage.pricing.model': 'Model', + 'usage.pricing.calls': 'Requests', + 'usage.pricing.cost': 'Estimated Cost', + 'usage.pricing.prompt': 'Input ($/1M)', + 'usage.pricing.completion': 'Output ($/1M)', + 'usage.pricing.cache': 'Legacy Cache ($/1M)', + 'usage.pricing.cacheRead': 'Cache Read ($/1M)', + 'usage.pricing.cacheCreation': 'Cache Creation ($/1M)', + 'usage.pricing.actions': 'Actions', + 'usage.pricing.optional': 'Optional; blank uses fallback', + 'usage.pricing.modelRequired': 'Enter a model name', + 'usage.pricing.saving': 'Saving', + 'usage.pricing.saved': 'Model price saved; overview cost updated', + 'usage.pricing.deleted': 'Model price deleted; overview cost updated', + 'usage.pricing.deleteConfirm': 'Delete the price for {model}?', 'usage.trend.title': 'Request and Token Trend', 'usage.trend.description': 'Grouped by local system hour', 'usage.token.title': 'Token Breakdown', diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index efc80a1..91d4e15 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; -import { Activity, BarChart3, Clock3, Database, List, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react'; +import { Activity, BarChart3, CircleDollarSign, Clock3, Database, List, Pencil, RefreshCw, ShieldCheck, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react'; import { getCurrentLocale, useI18n } from '../i18n'; -type UsageTab = 'overview' | 'analysis' | 'events'; +type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing'; type UsageRange = '4h' | '24h' | 'today' | '7d' | '30d' | 'all' | 'custom'; type CollectorStatus = { @@ -38,6 +38,8 @@ type UsageOverview = { tps: number; averageLatencyMs: number; cacheHitRate: number; + estimatedCost: number; + pricedRequests: number; timeline: TimelinePoint[]; }; @@ -90,6 +92,49 @@ type UsageEventPage = { totalPages: number; }; +type ModelPrice = { + model: string; + prompt: number; + completion: number; + cache: number; + cacheRead: number; + cacheCreation: number; + promptConfigured: boolean; + completionConfigured: boolean; + cacheReadConfigured: boolean; + cacheCreationConfigured: boolean; + source: string; + sourceModelId: string; + updatedAtMs: number; +}; + +type UsagePriceRow = { + model: string; + requests: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; + estimatedCost: number; + price: ModelPrice | null; +}; + +type UsagePricing = { + rows: UsagePriceRow[]; + totalCost: number; + totalRequests: number; + pricedRequests: number; + savedPrices: number; +}; + +type ModelPriceSyncResult = { + imported: number; + skipped: number; + unmatched: string[]; + usedBuiltin: boolean; +}; + type UsageQuery = { start?: string; end?: string; @@ -109,7 +154,7 @@ const emptyAnalysis: UsageAnalysis = { models: [], providers: [], sources: [], a const loadTab = (): UsageTab => { try { const saved = localStorage.getItem(TAB_KEY); - return saved === 'analysis' || saved === 'events' ? saved : 'overview'; + return saved === 'analysis' || saved === 'events' || saved === 'pricing' ? saved : 'overview'; } catch { return 'overview'; } @@ -150,6 +195,22 @@ const compactNumber = (value: number) => new Intl.NumberFormat(getCurrentLocale( maximumFractionDigits: 1, }).format(Number.isFinite(value) ? value : 0); +const formatUsd = (value: number) => { + const amount = Number.isFinite(value) ? value : 0; + const absolute = Math.abs(amount); + const maximumFractionDigits = absolute === 0 || absolute >= 1 + ? 2 + : absolute >= 0.01 + ? 4 + : absolute >= 0.0001 + ? 6 + : 8; + return `$${new Intl.NumberFormat(getCurrentLocale(), { + minimumFractionDigits: 2, + maximumFractionDigits, + }).format(amount)}`; +}; + const formatTime = (value: string) => { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(getCurrentLocale(), { @@ -176,6 +237,7 @@ export function UsageRecordsPage() { const [analysis, setAnalysis] = useState(emptyAnalysis); const [optionsAnalysis, setOptionsAnalysis] = useState(emptyAnalysis); const [events, setEvents] = useState(null); + const [pricing, setPricing] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const requestIdRef = useRef(0); @@ -232,7 +294,7 @@ export function UsageRecordsPage() { setOptionsAnalysis(nextOptions); setOverview(nextOverview); setAnalysis(nextAnalysis); - } else { + } else if (activeTab === 'events') { const [nextStatus, nextOptions, nextEvents] = await Promise.all([ statusRequest, optionsRequest, @@ -244,6 +306,16 @@ export function UsageRecordsPage() { setStatus(nextStatus); setOptionsAnalysis(nextOptions); setEvents(nextEvents); + } else { + const [nextStatus, nextOptions, nextPricing] = await Promise.all([ + statusRequest, + optionsRequest, + invoke('get_usage_pricing', { query }), + ]); + if (requestId !== requestIdRef.current) return; + setStatus(nextStatus); + setOptionsAnalysis(nextOptions); + setPricing(nextPricing); } setError(''); } catch (requestError) { @@ -291,6 +363,7 @@ export function UsageRecordsPage() { (activeTab === 'overview' && !overview) || (activeTab === 'analysis' && !overview) || (activeTab === 'events' && !events) + || (activeTab === 'pricing' && !pricing) ); return ( @@ -315,6 +388,7 @@ export function UsageRecordsPage() { +
@@ -334,6 +408,7 @@ export function UsageRecordsPage() { {activeTab === 'overview' && overview ? : null} {activeTab === 'analysis' ? : null} {activeTab === 'events' && events ? : null} + {activeTab === 'pricing' && pricing ? loadData(true)} /> : null}
); } @@ -344,8 +419,9 @@ function OverviewView({ overview }: { overview: UsageOverview }) { { icon: Activity, label: t('usage.stat.requests'), value: compactNumber(overview.totalRequests), meta: t('usage.stat.requestMeta', { success: overview.successCount, failed: overview.failureCount }) }, { icon: Sparkles, label: t('usage.stat.tokens'), value: compactNumber(overview.totalTokens), meta: t('usage.stat.tokenMeta', { input: compactNumber(overview.inputTokens), output: compactNumber(overview.outputTokens) }) }, { icon: ShieldCheck, label: t('usage.stat.successRate'), value: `${overview.successRate.toFixed(1)}%`, meta: t('usage.stat.reasoningMeta', { tokens: compactNumber(overview.reasoningTokens) }) }, - { icon: Clock3, label: t('usage.stat.tps'), value: `${overview.tps.toFixed(1)} TPS`, meta: t('usage.stat.performanceMeta', { rpm: overview.rpm.toFixed(2), tpm: compactNumber(overview.tpm), latency: Math.round(overview.averageLatencyMs) }) }, + { icon: Clock3, label: t('usage.stat.tps'), value: `${overview.tps.toFixed(1)} TPS`, meta: t('usage.stat.performanceMeta', { rpm: overview.rpm.toFixed(2), latency: Math.round(overview.averageLatencyMs) }) }, { icon: Database, label: t('usage.stat.cacheHitRate'), value: `${(overview.cacheHitRate * 100).toFixed(1)}%`, meta: t('usage.stat.cacheHitMeta', { hit: compactNumber(overview.cacheReadTokens), input: compactNumber(overview.inputTokens) }) }, + { icon: CircleDollarSign, label: t('usage.stat.estimatedCost'), value: formatUsd(overview.estimatedCost), meta: t('usage.stat.costMeta', { priced: compactNumber(overview.pricedRequests), total: compactNumber(overview.totalRequests) }) }, ]; return
{cards.map(({ icon: Icon, label, value, meta }) =>
{label}{value}{meta}
)}
@@ -388,7 +464,140 @@ function CategoryPanel({ title, items, compactLabels = false }: { title: string; function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) { const { t } = useI18n(); - return
{t('usage.events.total', { count: compactNumber(events.total) })}{t('usage.events.page', { page: events.page, total: events.totalPages })}
{events.items.length ?
{events.items.map((record) => )}
{t('usage.column.time')}{t('usage.column.model')}Provider{t('usage.column.source')}API Key{t('usage.column.result')}{t('usage.column.latency')}TTFT{t('usage.column.input')}{t('usage.column.output')}{t('usage.column.reasoning')}{t('usage.column.cache')}{t('usage.column.total')}
{formatTime(record.timestamp)}{record.alias || record.model}{record.alias || record.reasoning_effort ? {record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort} : null}{record.provider || '—'}{record.source_display || '—'}{record.api_key_remark || t('usage.key.noRemark')}{record.api_key_display || '—'}{record.failed ? t('usage.result.failed') : t('usage.result.success')}{record.latency_ms} ms{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}{compactNumber(record.tokens.input_tokens)}{compactNumber(record.tokens.output_tokens)}{compactNumber(record.tokens.reasoning_tokens)}{compactNumber(record.tokens.cache_read_tokens)}{compactNumber(record.tokens.total_tokens)}
: }
; + return
{t('usage.events.total', { count: compactNumber(events.total) })}{t('usage.events.page', { page: events.page, total: events.totalPages })}
{events.items.length ?
{events.items.map((record) => )}
{t('usage.column.time')}{t('usage.column.model')}Provider{t('usage.column.source')}API Key{t('usage.column.input')}{t('usage.column.output')}{t('usage.column.reasoning')}{t('usage.column.cache')}{t('usage.column.total')}{t('usage.column.result')}{t('usage.column.latency')}TTFT
{formatTime(record.timestamp)}{record.alias || record.model}{record.alias || record.reasoning_effort ? {record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort} : null}{record.provider || '—'}{record.source_display || '—'}{record.api_key_remark || t('usage.key.noRemark')}{record.api_key_display || '—'}{compactNumber(record.tokens.input_tokens)}{compactNumber(record.tokens.output_tokens)}{compactNumber(record.tokens.reasoning_tokens)}{compactNumber(record.tokens.cache_read_tokens)}{compactNumber(record.tokens.total_tokens)}{record.failed ? t('usage.result.failed') : t('usage.result.success')}{record.latency_ms} ms{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}
: }
; +} + +type PriceDraft = { + model: string; + prompt: string; + completion: string; + cache: string; + cacheRead: string; + cacheCreation: string; +}; + +const emptyPriceDraft = (): PriceDraft => ({ model: '', prompt: '', completion: '', cache: '', cacheRead: '', cacheCreation: '' }); +const priceDraftFor = (model = '', price?: ModelPrice | null): PriceDraft => ({ + model, + prompt: price ? String(price.prompt) : '', + completion: price ? String(price.completion) : '', + cache: price ? String(price.cache) : '', + cacheRead: price && (price.cacheReadConfigured || price.cacheRead > 0) ? String(price.cacheRead) : '', + cacheCreation: price && (price.cacheCreationConfigured || price.cacheCreation > 0) ? String(price.cacheCreation) : '', +}); + +const parsePrice = (value: string) => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; +}; + +const priceUnit = (value: number | undefined) => Number.isFinite(value) ? `$${Number(value).toFixed(4)}` : '—'; + +function PricingView({ pricing, query, onChanged }: { pricing: UsagePricing; query: UsageQuery; onChanged: () => void | Promise }) { + const { t } = useI18n(); + const [search, setSearch] = useState(''); + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [syncing, setSyncing] = useState(false); + const [message, setMessage] = useState(''); + const [localError, setLocalError] = useState(''); + const visibleRows = pricing.rows.filter((row) => { + const keyword = search.trim().toLowerCase(); + return !keyword || row.model.toLowerCase().includes(keyword); + }); + + const savePrice = async () => { + if (!draft?.model.trim()) { + setLocalError(t('usage.pricing.modelRequired')); + return; + } + setSaving(true); + setLocalError(''); + try { + await invoke('save_usage_model_price', { + price: { + model: draft.model.trim(), + prompt: parsePrice(draft.prompt), + completion: parsePrice(draft.completion), + cache: draft.cache.trim() ? parsePrice(draft.cache) : parsePrice(draft.prompt), + cacheRead: parsePrice(draft.cacheRead), + cacheCreation: parsePrice(draft.cacheCreation), + promptConfigured: draft.prompt.trim() !== '', + completionConfigured: draft.completion.trim() !== '', + cacheReadConfigured: draft.cacheRead.trim() !== '', + cacheCreationConfigured: draft.cacheCreation.trim() !== '', + source: 'manual', + sourceModelId: '', + updatedAtMs: 0, + } satisfies ModelPrice, + }); + setDraft(null); + setMessage(t('usage.pricing.saved')); + await onChanged(); + } catch (saveError) { + setLocalError(String(saveError)); + } finally { + setSaving(false); + } + }; + + const deletePrice = async (model: string) => { + if (!window.confirm(t('usage.pricing.deleteConfirm', { model }))) return; + setLocalError(''); + try { + await invoke('delete_usage_model_price', { model }); + setMessage(t('usage.pricing.deleted')); + await onChanged(); + } catch (deleteError) { + setLocalError(String(deleteError)); + } + }; + + const syncPrices = async () => { + setSyncing(true); + setLocalError(''); + setMessage(''); + try { + const result = await invoke('sync_usage_model_prices', { query }); + setMessage(result.usedBuiltin + ? t('usage.pricing.syncFallback') + : t('usage.pricing.syncResult', { imported: result.imported, skipped: result.skipped, unmatched: result.unmatched.length })); + await onChanged(); + } catch (syncError) { + setLocalError(String(syncError)); + } finally { + setSyncing(false); + } + }; + + return
+
+
+ {formatUsd(pricing.totalCost)} + {t('usage.pricing.coverage', { priced: compactNumber(pricing.pricedRequests), total: compactNumber(pricing.totalRequests), saved: compactNumber(pricing.savedPrices) })} +
+
+ setSearch(event.currentTarget.value)} placeholder={t('usage.pricing.search')} aria-label={t('usage.pricing.search')} /> + + +
+
+ + {localError ?
{localError}
: null} + {message ?
{message}
: null} + + {draft ?
+ + + + + + +
+
: null} + + {visibleRows.length ?
{visibleRows.map((row) => )}
{t('usage.pricing.model')}{t('usage.pricing.calls')}Token{t('usage.pricing.cost')}{t('usage.pricing.prompt')}{t('usage.pricing.completion')}{t('usage.pricing.cacheRead')}{t('usage.pricing.cacheCreation')}{t('usage.pricing.actions')}
{row.model}{compactNumber(row.requests)}{compactNumber(row.totalTokens)}{row.price ? formatUsd(row.estimatedCost) : '—'}{row.price ? priceUnit(row.price.prompt) : '—'}{row.price ? priceUnit(row.price.completion) : '—'}{row.price ? priceUnit(row.price.cacheReadConfigured || row.price.cacheRead > 0 ? row.price.cacheRead : row.price.cache) : '—'}{row.price ? priceUnit(row.price.cacheCreationConfigured || row.price.cacheCreation > 0 ? row.price.cacheCreation : row.price.prompt) : '—'}
{row.price?.source === 'manual' ? : null}
: } +
; } function UsageEmpty() { diff --git a/src/styles.css b/src/styles.css index 2bae300..bb5f678 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5211,17 +5211,25 @@ input:focus-visible { .usage-overview-layout { display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.6fr); - gap: 12px; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 10px; } .usage-stat-grid { display: grid; grid-column: 1 / -1; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; } +.usage-trend-panel { + grid-column: 1 / 5; +} + +.usage-health-panel { + grid-column: 5 / 7; +} + .usage-stat-card { display: grid; min-width: 0; @@ -5422,8 +5430,9 @@ input:focus-visible { .usage-events-table { width: 100%; - min-width: 1320px; + min-width: 1140px; border-collapse: collapse; + table-layout: fixed; font-size: 10px; } @@ -5449,20 +5458,85 @@ input:focus-visible { .usage-events-table th:first-child, .usage-events-table th:nth-child(2), .usage-events-table th:nth-child(3), -.usage-events-table th:nth-child(4), -.usage-events-table th:nth-child(5), .usage-events-table td:first-child, .usage-events-table td:nth-child(2), -.usage-events-table td:nth-child(3), -.usage-events-table td:nth-child(4), +.usage-events-table td:nth-child(3) { + text-align: left; +} + +.usage-events-table th:first-child { + width: 120px; +} + +.usage-events-table th:nth-child(2) { + width: 120px; +} + +.usage-events-table th:nth-child(3) { + width: 120px; +} + +.usage-events-table th:nth-child(4) { + width: 120px; +} + +.usage-events-table th:nth-child(5) { + width: 78px; +} + +.usage-events-table th:nth-child(n + 6):nth-child(-n + 10) { + width: 64px; + text-align: center; +} + +.usage-events-table td:nth-child(n + 6):nth-child(-n + 10) { + text-align: center; +} + +.usage-events-table th:nth-child(11) { + width: 72px; +} + +.usage-events-table th:nth-child(12) { + width: 85px; +} + +.usage-events-table th:nth-child(13) { + width: 70px; +} + +.usage-events-table th:nth-child(-n + 5), +.usage-events-table td:nth-child(-n + 5) { + padding-left: 12px; + padding-right: 12px; + text-align: left; +} + +.usage-events-table th:nth-child(5), .usage-events-table td:nth-child(5) { + padding-right: 2px; +} + +.usage-events-table th:nth-child(4), +.usage-events-table td:nth-child(4) { + overflow: hidden; + text-overflow: ellipsis; +} + +.usage-events-table th:nth-child(6), +.usage-events-table td:nth-child(6) { + padding-left: 2px; text-align: left; } .usage-events-table .usage-stacked-cell { - display: grid; - align-content: center; max-width: 180px; + vertical-align: middle; +} + +.usage-events-table .usage-stacked-cell > strong, +.usage-events-table .usage-stacked-cell > small { + display: block; } .usage-events-table td strong, @@ -5502,6 +5576,140 @@ input:focus-visible { min-height: 32px; } +.usage-pricing-panel { + display: grid; + min-width: 0; + gap: 12px; + padding: 12px; +} + +.usage-pricing-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.usage-pricing-summary { + display: grid; + min-width: 0; + gap: 3px; +} + +.usage-pricing-summary strong { + color: #2d2a26; + font-size: 22px; +} + +.usage-pricing-summary span { + color: #8a847c; + font-size: 10px; +} + +.usage-pricing-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.usage-pricing-actions input { + width: 220px; + height: 38px; + padding: 0 10px; + border: 1px solid #d5d2cb; + border-radius: 6px; + background: #fff; + color: #2d2a26; + font-size: 11px; +} + +.usage-pricing-actions button, +.usage-price-editor-actions button { + gap: 6px; +} + +.usage-price-editor { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + padding: 12px; + border: 1px solid #ddd8ce; + border-radius: 8px; + background: #f8f5ee; +} + +.usage-price-editor label { + display: grid; + min-width: 0; + gap: 5px; +} + +.usage-price-editor label > span { + color: #6f6961; + font-size: 10px; + font-weight: 700; +} + +.usage-price-editor input { + min-width: 0; + height: 36px; + padding: 0 9px; + border: 1px solid #d5d2cb; + border-radius: 6px; + background: #fff; + color: #2d2a26; + font-size: 11px; +} + +.usage-price-editor-actions { + display: flex; + grid-column: 1 / -1; + justify-content: flex-end; + gap: 8px; +} + +.usage-pricing-table-wrap { + min-height: 390px; +} + +.usage-pricing-table { + width: 100%; + min-width: 980px; + border-collapse: collapse; + font-size: 10px; +} + +.usage-pricing-table th, +.usage-pricing-table td { + height: 44px; + padding: 7px 9px; + border-bottom: 1px solid #eeeae2; + color: #5f5a53; + text-align: right; + white-space: nowrap; +} + +.usage-pricing-table th { + position: sticky; + z-index: 1; + top: 0; + background: #f4f1ea; + color: #7a746c; + font-weight: 700; +} + +.usage-pricing-table th:first-child, +.usage-pricing-table td:first-child { + text-align: left; +} + +.usage-price-row-actions { + display: flex; + justify-content: flex-end; + gap: 5px; +} + .real-auth-files-panel { display: block; height: auto; @@ -6230,6 +6438,16 @@ input:focus-visible { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .usage-overview-layout { + grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.6fr); + gap: 12px; + } + + .usage-trend-panel, + .usage-health-panel { + grid-column: auto; + } + .usage-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -6365,6 +6583,24 @@ input:focus-visible { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .usage-pricing-toolbar { + align-items: stretch; + flex-direction: column; + } + + .usage-pricing-actions { + display: grid; + grid-template-columns: 1fr; + } + + .usage-pricing-actions input { + width: 100%; + } + + .usage-price-editor { + grid-template-columns: 1fr; + } + .thinking-alias-workbench { grid-template-columns: 1fr; }