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