feat(usage): add cost analytics and refine dashboard layout

This commit is contained in:
lzt404 committed 2026-07-29 01:38:25 +08:00
1 parent 3d2a7f357e
commit 7b9566ef73
6 files changed
+1427 -22

No files matched your search

+63
View File
@@ -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 }
}
}
+4
View File
@@ -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
View File
@@ -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();
}
}
+52 -2
View File
@@ -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<MessageKey, string> = {
'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<MessageKey, string> = {
'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',
+215 -6
View File
@@ -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<UsageAnalysis>(emptyAnalysis);
const [optionsAnalysis, setOptionsAnalysis] = useState<UsageAnalysis>(emptyAnalysis);
const [events, setEvents] = useState<UsageEventPage | null>(null);
const [pricing, setPricing] = useState<UsagePricing | null>(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<UsagePricing>('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() {
<button type="button" className={activeTab === 'overview' ? 'active' : ''} onClick={() => setActiveTab('overview')}><Activity size={15} />{t('usage.tab.overview')}</button>
<button type="button" className={activeTab === 'analysis' ? 'active' : ''} onClick={() => setActiveTab('analysis')}><BarChart3 size={15} />{t('usage.tab.analysis')}</button>
<button type="button" className={activeTab === 'events' ? 'active' : ''} onClick={() => setActiveTab('events')}><List size={15} />{t('usage.tab.events')}</button>
<button type="button" className={activeTab === 'pricing' ? 'active' : ''} onClick={() => setActiveTab('pricing')}><CircleDollarSign size={15} />{t('usage.tab.pricing')}</button>
</div>
<section className="panel usage-filter-panel">
@@ -334,6 +408,7 @@ export function UsageRecordsPage() {
{activeTab === 'overview' && overview ? <OverviewView overview={overview} /> : null}
{activeTab === 'analysis' ? <AnalysisView analysis={analysis} overview={overview} /> : null}
{activeTab === 'events' && events ? <EventsView events={events} onPage={setPage} /> : null}
{activeTab === 'pricing' && pricing ? <PricingView pricing={pricing} query={buildQueries().query} onChanged={() => loadData(true)} /> : null}
</section>
);
}
@@ -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 <div className="usage-overview-layout">
<div className="usage-stat-grid">{cards.map(({ icon: Icon, label, value, meta }) => <article className="panel usage-stat-card" key={label}><span><Icon size={16} />{label}</span><strong>{value}</strong><small>{meta}</small></article>)}</div>
@@ -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 <section className="panel usage-events-panel"><div className="usage-events-summary"><span>{t('usage.events.total', { count: compactNumber(events.total) })}</span><span>{t('usage.events.page', { page: events.page, total: events.totalPages })}</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>{t('usage.column.time')}</th><th>{t('usage.column.model')}</th><th>Provider</th><th>{t('usage.column.source')}</th><th>API Key</th><th>{t('usage.column.result')}</th><th>{t('usage.column.latency')}</th><th>TTFT</th><th>{t('usage.column.input')}</th><th>{t('usage.column.output')}</th><th>{t('usage.column.reasoning')}</th><th>{t('usage.column.cache')}</th><th>{t('usage.column.total')}</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source_display || undefined}>{record.source_display || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong><small>{record.api_key_display || '—'}</small></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? t('usage.result.failed') : t('usage.result.success')}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>{t('usage.previous')}</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}</button></div></section>;
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>{t('usage.events.total', { count: compactNumber(events.total) })}</span><span>{t('usage.events.page', { page: events.page, total: events.totalPages })}</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>{t('usage.column.time')}</th><th>{t('usage.column.model')}</th><th>Provider</th><th>{t('usage.column.source')}</th><th>API Key</th><th>{t('usage.column.input')}</th><th>{t('usage.column.output')}</th><th>{t('usage.column.reasoning')}</th><th>{t('usage.column.cache')}</th><th>{t('usage.column.total')}</th><th>{t('usage.column.result')}</th><th>{t('usage.column.latency')}</th><th>TTFT</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source_display || undefined}>{record.source_display || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong><small>{record.api_key_display || '—'}</small></td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? t('usage.result.failed') : t('usage.result.success')}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>{t('usage.previous')}</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}</button></div></section>;
}
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<void> }) {
const { t } = useI18n();
const [search, setSearch] = useState('');
const [draft, setDraft] = useState<PriceDraft | null>(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<ModelPriceSyncResult>('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 <section className="panel usage-pricing-panel">
<div className="usage-pricing-toolbar">
<div className="usage-pricing-summary">
<strong>{formatUsd(pricing.totalCost)}</strong>
<span>{t('usage.pricing.coverage', { priced: compactNumber(pricing.pricedRequests), total: compactNumber(pricing.totalRequests), saved: compactNumber(pricing.savedPrices) })}</span>
</div>
<div className="usage-pricing-actions">
<input value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder={t('usage.pricing.search')} aria-label={t('usage.pricing.search')} />
<button type="button" className="secondary-button" onClick={() => setDraft(emptyPriceDraft())}>{t('usage.pricing.add')}</button>
<button type="button" className="primary-button" disabled={syncing} onClick={() => void syncPrices()}><RefreshCw size={14} className={syncing ? 'spin' : ''} />{syncing ? t('usage.pricing.syncing') : t('usage.pricing.sync')}</button>
</div>
</div>
{localError ? <div className="management-alert error">{localError}</div> : null}
{message ? <div className="management-alert success">{message}</div> : null}
{draft ? <div className="usage-price-editor">
<label><span>{t('usage.pricing.model')}</span><input value={draft.model} onChange={(event) => setDraft({ ...draft, model: event.currentTarget.value })} placeholder="gpt-5.6-terra" /></label>
<label><span>{t('usage.pricing.prompt')}</span><input type="number" min="0" step="0.0001" value={draft.prompt} onChange={(event) => setDraft({ ...draft, prompt: event.currentTarget.value })} /></label>
<label><span>{t('usage.pricing.completion')}</span><input type="number" min="0" step="0.0001" value={draft.completion} onChange={(event) => setDraft({ ...draft, completion: event.currentTarget.value })} /></label>
<label><span>{t('usage.pricing.cache')}</span><input type="number" min="0" step="0.0001" value={draft.cache} onChange={(event) => setDraft({ ...draft, cache: event.currentTarget.value })} /></label>
<label><span>{t('usage.pricing.cacheRead')}</span><input type="number" min="0" step="0.0001" value={draft.cacheRead} onChange={(event) => setDraft({ ...draft, cacheRead: event.currentTarget.value })} placeholder={t('usage.pricing.optional')} /></label>
<label><span>{t('usage.pricing.cacheCreation')}</span><input type="number" min="0" step="0.0001" value={draft.cacheCreation} onChange={(event) => setDraft({ ...draft, cacheCreation: event.currentTarget.value })} placeholder={t('usage.pricing.optional')} /></label>
<div className="usage-price-editor-actions"><button type="button" className="secondary-button" onClick={() => setDraft(null)}><X size={14} />{t('common.cancel')}</button><button type="button" className="primary-button" disabled={saving} onClick={() => void savePrice()}>{saving ? t('usage.pricing.saving') : t('common.save')}</button></div>
</div> : null}
{visibleRows.length ? <div className="usage-table-wrap usage-pricing-table-wrap"><table className="usage-pricing-table"><thead><tr><th>{t('usage.pricing.model')}</th><th>{t('usage.pricing.calls')}</th><th>Token</th><th>{t('usage.pricing.cost')}</th><th>{t('usage.pricing.prompt')}</th><th>{t('usage.pricing.completion')}</th><th>{t('usage.pricing.cacheRead')}</th><th>{t('usage.pricing.cacheCreation')}</th><th>{t('usage.pricing.actions')}</th></tr></thead><tbody>{visibleRows.map((row) => <tr key={row.model}><td><strong>{row.model}</strong></td><td>{compactNumber(row.requests)}</td><td>{compactNumber(row.totalTokens)}</td><td><strong>{row.price ? formatUsd(row.estimatedCost) : '—'}</strong></td><td>{row.price ? priceUnit(row.price.prompt) : '—'}</td><td>{row.price ? priceUnit(row.price.completion) : '—'}</td><td>{row.price ? priceUnit(row.price.cacheReadConfigured || row.price.cacheRead > 0 ? row.price.cacheRead : row.price.cache) : '—'}</td><td>{row.price ? priceUnit(row.price.cacheCreationConfigured || row.price.cacheCreation > 0 ? row.price.cacheCreation : row.price.prompt) : '—'}</td><td><div className="usage-price-row-actions"><button type="button" className="icon-button" title={t('common.edit')} onClick={() => setDraft(priceDraftFor(row.model, row.price))}><Pencil size={14} /></button>{row.price?.source === 'manual' ? <button type="button" className="icon-button danger" title={t('common.delete')} onClick={() => void deletePrice(row.model)}><Trash2 size={14} /></button> : null}</div></td></tr>)}</tbody></table></div> : <UsageEmpty />}
</section>;
}
function UsageEmpty() {
+246 -10
View File
@@ -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;
}