Files
EasyCLIProxyAPI/src-tauri/src/usage.rs
T
lzt404 247e9052b6 fix(usage): preserve persistent WebSocket usage events
Treat request_id/event_key as correlation fields instead of unique keys,
allowing multiple usage events from the same persistent WebSocket connection.
Migrate existing usage.db schemas and add regression coverage.

Fixes #180
2026-08-30 16:55:25 +08:00

4781 lines
170 KiB
Rust

mod resp;
#[cfg(target_os = "macos")]
use super::executable_dir;
#[cfg(test)]
use super::VersionDownloadSource;
use super::{
apply_configured_proxy, core_base_dir, current_core_status, management_authorization,
management_endpoint, management_http_client, CoreProcessState, GuiConfigFile, GuiConfigState,
};
use chrono::{DateTime, Local};
use rusqlite::{
params, params_from_iter, types::Value as SqlValue, Connection, OptionalExtension, Row,
Transaction,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
sync::Mutex,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tauri::{Emitter, Manager};
use tokio_util::sync::CancellationToken;
use self::resp::UsageSubscription;
const USAGE_DIR_NAME: &str = "usage-records";
const USAGE_DATABASE_FILE: &str = "usage.db";
const USAGE_BACKUP_DIR_NAME: &str = "backups";
const LEGACY_USAGE_EVENTS_DIR: &str = "events";
const LEGACY_USAGE_INBOX_DIR: &str = "inbox";
const LEGACY_JSON_MIGRATION_KEY: &str = "legacy_json_v1";
const USAGE_DATABASE_MIGRATION_KEY: &str = "keeper_v3";
const USAGE_FAILURE_MIGRATION_KEY: &str = "failure_details_v4";
const USAGE_EVENT_KEY_MIGRATION_KEY: &str = "event_key_v5";
const USAGE_UPDATED_EVENT: &str = "usage-records-updated";
const USAGE_SCHEMA_VERSION: u8 = 1;
const USAGE_DATABASE_SCHEMA_VERSION: i64 = 5;
const MAX_USAGE_FAILURE_BODY_CHARS: usize = 2_000;
const USAGE_QUEUE_BATCH_SIZE: usize = 500;
const USAGE_INBOX_PROCESS_LIMIT: usize = 500;
const USAGE_INBOX_MAX_ATTEMPTS: i64 = 5;
const USAGE_SUBSCRIBE_RETRY_SECONDS: u64 = 30;
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>,
}
struct UsageCollectorInner {
token: Option<CancellationToken>,
status: UsageCollectorStatus,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum UsageDatabaseLayout {
Empty,
LegacyV2,
CurrentV3,
}
#[derive(Debug)]
struct UsageInboxRow {
id: i64,
source: String,
raw_message: String,
attempt_count: i64,
}
#[derive(Debug, Eq, PartialEq)]
struct UsageMigrationSnapshot {
records: i64,
input_tokens: i64,
output_tokens: i64,
reasoning_tokens: i64,
cache_read_tokens: i64,
cache_creation_tokens: i64,
total_tokens: i64,
successes: i64,
failures: i64,
first_timestamp: Option<String>,
last_timestamp: Option<String>,
}
impl Default for UsageCollectorState {
fn default() -> Self {
Self {
inner: Mutex::new(UsageCollectorInner {
token: None,
status: UsageCollectorStatus::waiting(),
}),
}
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageCollectorStatus {
state: String,
message: String,
last_collected_at: Option<String>,
total_records: u64,
}
impl UsageCollectorStatus {
fn waiting() -> Self {
Self {
state: "waiting-core".to_string(),
message: "等待内核启动".to_string(),
last_collected_at: None,
total_records: 0,
}
}
}
#[derive(Clone, Default, Serialize, Deserialize)]
struct UsageTokenStats {
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
#[serde(default)]
reasoning_tokens: u64,
#[serde(default)]
cache_read_tokens: u64,
#[serde(default)]
cache_creation_tokens: u64,
#[serde(default)]
total_tokens: u64,
}
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct UsageRecord {
id: String,
timestamp: String,
#[serde(default)]
latency_ms: u64,
#[serde(default)]
ttft_ms: Option<u64>,
#[serde(default)]
source: String,
#[serde(default)]
source_display: String,
#[serde(default)]
auth_index: String,
#[serde(default)]
failed: bool,
#[serde(default)]
canceled: bool,
#[serde(default)]
failure_status: u16,
#[serde(default)]
failure_body: String,
#[serde(default)]
provider: String,
#[serde(default, skip_serializing)]
api_group_key: String,
#[serde(default)]
model: String,
#[serde(default)]
alias: String,
#[serde(default, skip_serializing)]
client_ip: Option<String>,
#[serde(default, skip_serializing)]
x_forwarded_for: Option<String>,
#[serde(default, skip_serializing)]
user_agent: Option<String>,
#[serde(default)]
reasoning_effort: String,
#[serde(default)]
service_tier: String,
#[serde(default)]
response_service_tier: String,
#[serde(default)]
executor_type: String,
#[serde(default)]
endpoint: String,
#[serde(default)]
auth_type: String,
#[serde(default)]
api_key_hash: String,
#[serde(default)]
api_key_display: String,
#[serde(default)]
api_key_remark: String,
#[serde(default)]
request_id: String,
#[serde(default = "default_usage_generate", skip_serializing)]
generate: bool,
#[serde(default, skip_serializing)]
cached_tokens: u64,
#[serde(default, skip_serializing)]
collector_source: String,
#[serde(default)]
tokens: UsageTokenStats,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyUsageHourFile {
schema_version: u8,
records: Vec<UsageRecord>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyUsageInboxFile {
schema_version: u8,
records: Vec<UsageRecord>,
}
#[derive(Clone, Default, Deserialize)]
pub(crate) struct UsageQuery {
#[serde(default)]
start: Option<String>,
#[serde(default)]
end: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
provider: Option<String>,
#[serde(default)]
source: Option<String>,
#[serde(default)]
api_key_hash: Option<String>,
#[serde(default)]
failed: Option<bool>,
#[serde(default)]
canceled: Option<bool>,
#[serde(default)]
page: Option<usize>,
#[serde(default)]
page_size: Option<usize>,
}
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageOverview {
total_requests: u64,
success_count: u64,
failure_count: u64,
canceled_count: u64,
success_rate: f64,
input_tokens: u64,
output_tokens: u64,
reasoning_tokens: u64,
cache_read_tokens: u64,
cache_creation_tokens: u64,
total_tokens: u64,
rpm: f64,
tpm: f64,
tps: f64,
tps_sample_count: u64,
average_latency_ms: f64,
cache_hit_rate: f64,
estimated_cost: f64,
priced_requests: u64,
timeline: Vec<UsageTimelinePoint>,
}
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageRepairResult {
scanned: u64,
repaired: u64,
backup_path: Option<String>,
}
#[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 {
hour: String,
requests: u64,
success: u64,
failure: u64,
canceled: u64,
tokens: u64,
}
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageAnalysis {
models: Vec<UsageCategory>,
providers: Vec<UsageCategory>,
sources: Vec<UsageCategory>,
api_keys: Vec<UsageCategory>,
}
#[derive(Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct UsageCategory {
key: String,
label: String,
requests: u64,
failures: u64,
tokens: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageEventPage {
items: Vec<UsageRecord>,
total: usize,
page: usize,
page_size: usize,
total_pages: usize,
}
struct UsageSqlFilter {
clause: String,
params: Vec<SqlValue>,
}
impl UsageCollectorState {
fn start(&self) -> Option<CancellationToken> {
let mut inner = self.inner.lock().ok()?;
if inner.token.is_some() {
return None;
}
let token = CancellationToken::new();
inner.token = Some(token.clone());
Some(token)
}
fn stop(&self) {
if let Ok(mut inner) = self.inner.lock() {
if let Some(token) = inner.token.take() {
token.cancel();
}
}
}
fn set_status(&self, status: UsageCollectorStatus) {
if let Ok(mut inner) = self.inner.lock() {
inner.status = status;
}
}
fn set_total_records(&self, total_records: u64) {
if let Ok(mut inner) = self.inner.lock() {
inner.status.total_records = total_records;
}
}
fn increment_total_records(&self, added: usize) {
if let Ok(mut inner) = self.inner.lock() {
inner.status.total_records = inner.status.total_records.saturating_add(added as u64);
}
}
fn status(&self) -> Result<UsageCollectorStatus, String> {
self.inner
.lock()
.map(|inner| inner.status.clone())
.map_err(|_| "使用记录采集状态锁已损坏".to_string())
}
}
pub(crate) fn initialize_usage_storage() -> Result<(), String> {
let root = usage_root_dir()?;
migrate_legacy_usage_storage(&root)?;
initialize_usage_storage_at(&root)
}
#[cfg(target_os = "macos")]
fn migrate_legacy_usage_storage(target: &Path) -> Result<(), String> {
let legacy = executable_dir()?.join(USAGE_DIR_NAME);
migrate_usage_storage_directory(&legacy, target)
}
#[cfg(not(target_os = "macos"))]
fn migrate_legacy_usage_storage(target: &Path) -> Result<(), String> {
let _ = target;
Ok(())
}
#[cfg(any(target_os = "macos", test))]
fn migrate_usage_storage_directory(source: &Path, target: &Path) -> Result<(), String> {
if source == target || !source.is_dir() || target.exists() {
return Ok(());
}
let target_parent = target
.parent()
.ok_or_else(|| "使用记录目标目录没有父目录".to_string())?;
fs::create_dir_all(target_parent)
.map_err(|error| format!("创建使用记录迁移目标目录失败: {error}"))?;
if fs::rename(source, target).is_ok() {
return Ok(());
}
if let Err(error) = copy_usage_storage_directory(source, target) {
let _ = fs::remove_dir_all(target);
return Err(format!("迁移旧版使用记录失败: {error}"));
}
if let Err(error) = fs::remove_dir_all(source) {
eprintln!(
"旧版使用记录已复制,但无法清理原目录 {}: {error}",
source.display()
);
}
Ok(())
}
#[cfg(any(target_os = "macos", test))]
fn copy_usage_storage_directory(source: &Path, target: &Path) -> Result<(), String> {
fs::create_dir_all(target).map_err(|error| format!("创建使用记录目录失败: {error}"))?;
let entries = fs::read_dir(source).map_err(|error| format!("读取旧版使用记录失败: {error}"))?;
for entry in entries {
let entry = entry.map_err(|error| format!("读取旧版使用记录项目失败: {error}"))?;
let file_type = entry
.file_type()
.map_err(|error| format!("读取旧版使用记录项目类型失败: {error}"))?;
let destination = target.join(entry.file_name());
if file_type.is_dir() {
copy_usage_storage_directory(&entry.path(), &destination)?;
} else if file_type.is_file() {
fs::copy(entry.path(), &destination)
.map_err(|error| format!("复制旧版使用记录失败: {error}"))?;
} else {
return Err(format!(
"旧版使用记录包含不支持的文件类型: {}",
entry.path().display()
));
}
}
Ok(())
}
fn initialize_usage_storage_at(root: &Path) -> Result<(), String> {
fs::create_dir_all(root).map_err(|error| format!("创建使用记录目录失败: {error}"))?;
let mut connection = open_usage_database_at(root)?;
connection
.pragma_update(None, "journal_mode", "WAL")
.map_err(|error| format!("启用 SQLite WAL 失败: {error}"))?;
migrate_usage_database(&mut connection, root)?;
migrate_legacy_json_storage(&mut connection, root)?;
cleanup_usage_inbox(&connection, Local::now())
}
#[tauri::command]
pub(crate) fn repair_usage_cache_records() -> Result<UsageRepairResult, String> {
let root = usage_root_dir()?;
repair_usage_cache_records_at(&root)
}
fn repair_usage_cache_records_at(root: &Path) -> Result<UsageRepairResult, String> {
let mut connection = open_usage_database_at(root)?;
let candidate_count: i64 = connection
.query_row(
r#"SELECT COUNT(*) FROM usage_events
WHERE input_tokens > 0
AND cache_read_tokens + cache_creation_tokens > input_tokens
AND (lower(executor_type) = 'claudeexecutor'
OR lower(provider) = 'claude'
OR lower(provider) LIKE '%anthropic%')"#,
[],
|row| row.get(0),
)
.map_err(|error| format!("检查 Claude 使用记录异常行失败: {error}"))?;
if candidate_count <= 0 {
return Ok(UsageRepairResult::default());
}
let backup_path = {
let backup_dir = root.join(USAGE_BACKUP_DIR_NAME);
fs::create_dir_all(&backup_dir)
.map_err(|error| format!("创建 Claude 使用记录迁移备份目录失败: {error}"))?;
let backup_path = backup_dir.join(format!(
"usage-before-claude-input-v1-{}.db",
unique_file_stamp()
));
connection
.execute(
"VACUUM INTO ?1",
params![backup_path.to_string_lossy().to_string()],
)
.map_err(|error| format!("备份 Claude 使用记录失败: {error}"))?;
backup_path.to_string_lossy().to_string()
};
let transaction = connection
.transaction()
.map_err(|error| format!("开始 Claude 使用记录迁移事务失败: {error}"))?;
let migrated = transaction
.execute(
r#"UPDATE usage_events
SET input_tokens = input_tokens + cache_read_tokens + cache_creation_tokens,
total_tokens = CASE
WHEN total_tokens = 0
OR total_tokens = input_tokens + output_tokens
THEN input_tokens + cache_read_tokens + cache_creation_tokens + output_tokens
ELSE total_tokens
END,
cached_tokens = MAX(cached_tokens, cache_read_tokens + cache_creation_tokens)
WHERE input_tokens > 0
AND cache_read_tokens + cache_creation_tokens > input_tokens
AND (lower(executor_type) = 'claudeexecutor'
OR lower(provider) = 'claude'
OR lower(provider) LIKE '%anthropic%')"#,
[],
)
.map_err(|error| format!("迁移 Claude 使用记录失败: {error}"))?;
transaction
.commit()
.map_err(|error| format!("提交 Claude 使用记录迁移失败: {error}"))?;
Ok(UsageRepairResult {
scanned: candidate_count.max(0) as u64,
repaired: migrated as u64,
backup_path: Some(backup_path),
})
}
fn migrate_usage_database(connection: &mut Connection, root: &Path) -> Result<(), String> {
match detect_usage_database_layout(connection)? {
UsageDatabaseLayout::Empty => initialize_usage_schema(connection),
UsageDatabaseLayout::CurrentV3 => {
initialize_usage_schema(connection)?;
migrate_usage_event_key_uniqueness(connection)
}
UsageDatabaseLayout::LegacyV2 => {
let backup_path = create_usage_migration_backup(connection, root)?;
if let Err(error) = migrate_legacy_v2_usage_schema(connection) {
return Err(format!(
"迁移旧版使用记录数据库失败,备份保留在 {}: {error}",
backup_path.display()
));
}
initialize_usage_schema(connection)
}
}
}
fn detect_usage_database_layout(connection: &Connection) -> Result<UsageDatabaseLayout, String> {
if !usage_table_exists(connection, "usage_events")? {
return Ok(UsageDatabaseLayout::Empty);
}
let columns = usage_table_columns(connection, "usage_events")?;
let legacy_columns = [
"event_key",
"timestamp_ms",
"local_hour",
"api_key_hash",
"api_key_display",
"api_key_remark",
];
if !legacy_columns
.iter()
.all(|column| columns.contains(*column))
{
return Err("无法识别使用记录数据库结构,已拒绝自动迁移".to_string());
}
let keeper_columns = [
"api_group_key",
"model_alias",
"client_ip",
"x_forwarded_for",
"user_agent",
"generate",
"cached_tokens",
"collector_source",
];
let keeper_column_count = keeper_columns
.iter()
.filter(|column| columns.contains(**column))
.count();
if keeper_column_count == 0 && !usage_table_exists(connection, "usage_inbox")? {
return Ok(UsageDatabaseLayout::LegacyV2);
}
if keeper_column_count == keeper_columns.len()
&& usage_table_exists(connection, "usage_inbox")?
&& usage_table_exists(connection, "usage_aggregation_checkpoints")?
{
return Ok(UsageDatabaseLayout::CurrentV3);
}
Err("检测到不完整的使用记录数据库迁移,已拒绝继续修改".to_string())
}
fn usage_table_exists(connection: &Connection, table: &str) -> Result<bool, String> {
connection
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
params![table],
|row| row.get::<_, i64>(0),
)
.map(|exists| exists != 0)
.map_err(|error| format!("检查 SQLite 表 {table} 失败: {error}"))
}
fn usage_table_columns(connection: &Connection, table: &str) -> Result<HashSet<String>, String> {
let mut statement = connection
.prepare("SELECT name FROM pragma_table_info(?1)")
.map_err(|error| format!("准备读取 SQLite 表结构失败 {table}: {error}"))?;
let columns = statement
.query_map(params![table], |row| row.get::<_, String>(0))
.map_err(|error| format!("读取 SQLite 表结构失败 {table}: {error}"))?
.collect::<Result<HashSet<_>, _>>()
.map_err(|error| format!("解析 SQLite 表结构失败 {table}: {error}"))?;
Ok(columns)
}
fn migrate_usage_event_key_uniqueness(connection: &mut Connection) -> Result<(), String> {
let migrated = connection
.query_row(
"SELECT value FROM usage_metadata WHERE key = ?1",
params![USAGE_EVENT_KEY_MIGRATION_KEY],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(|error| format!("read usage event key migration state failed: {error}"))?;
if migrated.is_some() {
return Ok(());
}
let transaction = connection
.transaction()
.map_err(|error| format!("begin usage event key migration failed: {error}"))?;
ensure_usage_event_key_non_unique_in_transaction(&transaction)?;
transaction
.execute(
"INSERT INTO usage_metadata (key, value) VALUES (?1, ?2)",
params![USAGE_EVENT_KEY_MIGRATION_KEY, Local::now().to_rfc3339()],
)
.map_err(|error| format!("record usage event key migration state failed: {error}"))?;
transaction
.commit()
.map_err(|error| format!("commit usage event key migration failed: {error}"))
}
fn ensure_usage_event_key_non_unique_in_transaction(connection: &Connection) -> Result<(), String> {
if !usage_table_exists(connection, "usage_events")? {
return Ok(());
}
let unique_indexes = usage_event_key_unique_indexes(connection)?;
if unique_indexes.is_empty() {
create_usage_event_key_index(connection)?;
return Ok(());
}
if unique_indexes
.iter()
.all(|(_, is_auto_index)| !is_auto_index)
{
for (index_name, _) in unique_indexes {
connection
.execute(
&format!(
"DROP INDEX IF EXISTS {}",
quote_sqlite_identifier(&index_name)
),
[],
)
.map_err(|error| {
format!("drop unique usage event key index {index_name} failed: {error}")
})?;
}
create_usage_event_key_index(connection)?;
return Ok(());
}
rebuild_usage_events_without_event_key_unique(connection, &unique_indexes)
}
fn usage_event_key_unique_indexes(connection: &Connection) -> Result<Vec<(String, bool)>, String> {
let index_names = {
let mut statement = connection
.prepare("SELECT name FROM pragma_index_list(?1) WHERE \"unique\" != 0 ORDER BY seq")
.map_err(|error| format!("prepare usage event key index query failed: {error}"))?;
let index_names = statement
.query_map(params!["usage_events"], |row| row.get::<_, String>(0))
.map_err(|error| format!("query usage event key indexes failed: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("read usage event key indexes failed: {error}"))?;
index_names
};
let mut matches = Vec::new();
for index_name in index_names {
let mut statement = connection
.prepare("SELECT name FROM pragma_index_info(?1) ORDER BY seqno")
.map_err(|error| format!("prepare usage event key index columns failed: {error}"))?;
let columns = statement
.query_map(params![index_name.as_str()], |row| row.get::<_, String>(0))
.map_err(|error| format!("query usage event key index columns failed: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("read usage event key index columns failed: {error}"))?;
if columns.len() == 1 && columns[0] == "event_key" {
matches.push((
index_name.clone(),
index_name.starts_with("sqlite_autoindex_"),
));
}
}
Ok(matches)
}
fn rebuild_usage_events_without_event_key_unique(
connection: &Connection,
unique_indexes: &[(String, bool)],
) -> Result<(), String> {
let table_sql = connection
.query_row(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?1",
params!["usage_events"],
|row| row.get::<_, String>(0),
)
.map_err(|error| format!("read usage events table schema failed: {error}"))?;
let temporary_table = format!("usage_events_rebuild_{}", unique_file_stamp());
let create_table_sql = replace_sql_fragment_case_insensitive(
&table_sql,
"create table usage_events",
&format!("CREATE TABLE {temporary_table}"),
)
.or_else(|| {
replace_sql_fragment_case_insensitive(
&table_sql,
"create table if not exists usage_events",
&format!("CREATE TABLE {temporary_table}"),
)
})
.ok_or_else(|| "usage events table schema has an unsupported CREATE TABLE form".to_string())?;
let create_table_sql = replace_sql_fragment_case_insensitive(
&create_table_sql,
"event_key text not null unique",
"event_key TEXT NOT NULL",
)
.ok_or_else(|| {
"usage events table schema does not contain the expected unique event_key constraint"
.to_string()
})?;
let unique_index_names = unique_indexes
.iter()
.map(|(name, _)| name.as_str())
.collect::<HashSet<_>>();
let index_sqls = {
let mut statement = connection
.prepare(
"SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ?1 AND sql IS NOT NULL ORDER BY name",
)
.map_err(|error| format!("prepare usage event index schema query failed: {error}"))?;
let index_sqls = statement
.query_map(params!["usage_events"], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|error| format!("query usage event index schemas failed: {error}"))?
.filter_map(|row| match row {
Ok((name, sql)) if !unique_index_names.contains(name.as_str()) => Some(Ok(sql)),
Ok(_) => None,
Err(error) => Some(Err(error)),
})
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("read usage event index schemas failed: {error}"))?;
index_sqls
};
connection
.execute_batch(&create_table_sql)
.map_err(|error| format!("create rebuilt usage events table failed: {error}"))?;
connection
.execute(
&format!(
"INSERT INTO {} SELECT * FROM usage_events",
quote_sqlite_identifier(&temporary_table)
),
[],
)
.map_err(|error| format!("copy usage events during event key migration failed: {error}"))?;
connection
.execute("DROP TABLE usage_events", [])
.map_err(|error| format!("drop old usage events table failed: {error}"))?;
connection
.execute(
&format!(
"ALTER TABLE {} RENAME TO usage_events",
quote_sqlite_identifier(&temporary_table)
),
[],
)
.map_err(|error| format!("rename rebuilt usage events table failed: {error}"))?;
for index_sql in index_sqls {
connection
.execute_batch(&index_sql)
.map_err(|error| format!("restore usage event index failed: {error}"))?;
}
create_usage_event_key_index(connection)
}
fn create_usage_event_key_index(connection: &Connection) -> Result<(), String> {
connection
.execute(
"CREATE INDEX IF NOT EXISTS idx_usage_events_event_key ON usage_events(event_key)",
[],
)
.map(|_| ())
.map_err(|error| format!("create usage event key index failed: {error}"))
}
fn quote_sqlite_identifier(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}
fn replace_sql_fragment_case_insensitive(
value: &str,
needle: &str,
replacement: &str,
) -> Option<String> {
let value_lower = value.to_ascii_lowercase();
let needle_lower = needle.to_ascii_lowercase();
let start = value_lower.find(&needle_lower)?;
let mut result = String::with_capacity(value.len() + replacement.len());
result.push_str(&value[..start]);
result.push_str(replacement);
result.push_str(&value[start + needle.len()..]);
Some(result)
}
fn create_usage_migration_backup(connection: &Connection, root: &Path) -> Result<PathBuf, String> {
let backup_dir = root.join(USAGE_BACKUP_DIR_NAME);
fs::create_dir_all(&backup_dir)
.map_err(|error| format!("创建使用记录备份目录失败: {error}"))?;
let backup_path = backup_dir.join(format!("usage-before-keeper-v3-{}.db", unique_file_stamp()));
connection
.execute(
"VACUUM INTO ?1",
params![backup_path.to_string_lossy().to_string()],
)
.map_err(|error| format!("备份旧版使用记录数据库失败: {error}"))?;
Ok(backup_path)
}
fn migrate_legacy_v2_usage_schema(connection: &mut Connection) -> Result<(), String> {
let before = load_usage_migration_snapshot(connection)?;
let transaction = connection
.transaction()
.map_err(|error| format!("开始使用记录数据库迁移事务失败: {error}"))?;
transaction
.execute_batch(
r#"
ALTER TABLE usage_events ADD COLUMN api_group_key TEXT NOT NULL DEFAULT '';
ALTER TABLE usage_events ADD COLUMN model_alias TEXT;
ALTER TABLE usage_events ADD COLUMN client_ip TEXT;
ALTER TABLE usage_events ADD COLUMN x_forwarded_for TEXT;
ALTER TABLE usage_events ADD COLUMN user_agent TEXT;
ALTER TABLE usage_events ADD COLUMN generate INTEGER NOT NULL DEFAULT 1;
ALTER TABLE usage_events ADD COLUMN cached_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE usage_events ADD COLUMN collector_source TEXT NOT NULL DEFAULT 'legacy_migration';
UPDATE usage_events
SET api_group_key = CASE
WHEN api_key_hash != '' THEN api_key_hash
WHEN provider != '' THEN provider
WHEN endpoint != '' THEN endpoint
ELSE 'unknown'
END,
model_alias = NULLIF(alias, ''),
generate = CASE
WHEN failed = 0
AND executor_type = 'CodexWebsocketsExecutor'
AND input_tokens = 0
AND output_tokens = 0
AND reasoning_tokens = 0
AND cache_read_tokens = 0
AND cache_creation_tokens = 0
AND total_tokens = 0
THEN 0 ELSE 1
END,
cached_tokens = cache_read_tokens + cache_creation_tokens,
collector_source = 'legacy_migration';
"#,
)
.map_err(|error| format!("转换旧版使用记录字段失败: {error}"))?;
let after = load_usage_migration_snapshot(&transaction)?;
if before != after {
return Err(format!(
"使用记录迁移校验不一致,迁移前 {before:?},迁移后 {after:?}"
));
}
initialize_usage_schema(&transaction)?;
ensure_usage_event_key_non_unique_in_transaction(&transaction)?;
transaction
.execute(
"INSERT INTO usage_metadata (key, value) VALUES (?1, ?2)",
params![USAGE_EVENT_KEY_MIGRATION_KEY, Local::now().to_rfc3339()],
)
.map_err(|error| format!("record usage event key migration state failed: {error}"))?;
transaction
.execute(
"INSERT INTO usage_metadata (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![USAGE_DATABASE_MIGRATION_KEY, Local::now().to_rfc3339()],
)
.map_err(|error| format!("写入使用记录迁移标记失败: {error}"))?;
transaction
.commit()
.map_err(|error| format!("提交使用记录数据库迁移失败: {error}"))
}
fn load_usage_migration_snapshot(
connection: &Connection,
) -> Result<UsageMigrationSnapshot, String> {
connection
.query_row(
r#"
SELECT
COUNT(*),
COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
COALESCE(SUM(reasoning_tokens), 0),
COALESCE(SUM(cache_read_tokens), 0),
COALESCE(SUM(cache_creation_tokens), 0),
COALESCE(SUM(total_tokens), 0),
COALESCE(SUM(CASE WHEN failed = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN failed != 0 THEN 1 ELSE 0 END), 0),
MIN(timestamp),
MAX(timestamp)
FROM usage_events
"#,
[],
|row| {
Ok(UsageMigrationSnapshot {
records: row.get(0)?,
input_tokens: row.get(1)?,
output_tokens: row.get(2)?,
reasoning_tokens: row.get(3)?,
cache_read_tokens: row.get(4)?,
cache_creation_tokens: row.get(5)?,
total_tokens: row.get(6)?,
successes: row.get(7)?,
failures: row.get(8)?,
first_timestamp: row.get(9)?,
last_timestamp: row.get(10)?,
})
},
)
.map_err(|error| format!("读取使用记录迁移校验快照失败: {error}"))
}
fn initialize_usage_schema(connection: &Connection) -> Result<(), String> {
connection
.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS usage_metadata (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_key TEXT NOT NULL,
timestamp TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL,
local_hour TEXT NOT NULL,
latency_ms INTEGER NOT NULL DEFAULT 0,
ttft_ms INTEGER,
source TEXT NOT NULL DEFAULT '',
auth_index TEXT NOT NULL DEFAULT '',
failed INTEGER NOT NULL DEFAULT 0,
canceled INTEGER NOT NULL DEFAULT 0,
failure_status INTEGER NOT NULL DEFAULT 0,
failure_body TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
alias TEXT NOT NULL DEFAULT '',
reasoning_effort TEXT NOT NULL DEFAULT '',
service_tier TEXT NOT NULL DEFAULT '',
response_service_tier TEXT NOT NULL DEFAULT '',
executor_type TEXT NOT NULL DEFAULT '',
endpoint TEXT NOT NULL DEFAULT '',
auth_type TEXT NOT NULL DEFAULT '',
api_key_hash TEXT NOT NULL DEFAULT '',
api_key_display TEXT NOT NULL DEFAULT '',
api_key_remark TEXT NOT NULL DEFAULT '',
request_id TEXT NOT NULL DEFAULT '',
api_group_key TEXT NOT NULL DEFAULT '',
model_alias TEXT,
client_ip TEXT,
x_forwarded_for TEXT,
user_agent TEXT,
generate INTEGER NOT NULL DEFAULT 1,
cached_tokens INTEGER NOT NULL DEFAULT 0,
collector_source TEXT NOT NULL DEFAULT '',
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_usage_events_timestamp
ON usage_events(timestamp_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_usage_events_event_key
ON usage_events(event_key);
CREATE INDEX IF NOT EXISTS idx_usage_events_local_hour
ON usage_events(local_hour, timestamp_ms DESC);
CREATE INDEX IF NOT EXISTS idx_usage_events_model_timestamp
ON usage_events(model, timestamp_ms DESC);
CREATE INDEX IF NOT EXISTS idx_usage_events_provider_timestamp
ON usage_events(provider, timestamp_ms DESC);
CREATE INDEX IF NOT EXISTS idx_usage_events_source_timestamp
ON usage_events(source, timestamp_ms DESC);
CREATE INDEX IF NOT EXISTS idx_usage_events_api_key_timestamp
ON usage_events(api_key_hash, timestamp_ms DESC);
CREATE INDEX IF NOT EXISTS idx_usage_events_failed_timestamp
ON usage_events(failed, timestamp_ms DESC);
CREATE INDEX IF NOT EXISTS idx_usage_events_api_group_timestamp
ON usage_events(api_group_key, timestamp_ms DESC);
CREATE TABLE IF NOT EXISTS usage_inbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
message_hash TEXT NOT NULL,
raw_message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
usage_event_key TEXT NOT NULL DEFAULT '',
received_at TEXT NOT NULL,
processed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_usage_inbox_status_id
ON usage_inbox(status, id);
CREATE TABLE IF NOT EXISTS usage_aggregation_checkpoints (
name TEXT PRIMARY KEY NOT NULL,
last_usage_event_id INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
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
);
"#,
)
.map_err(|error| format!("初始化 SQLite 使用记录结构失败: {error}"))?;
ensure_usage_failure_columns(connection)?;
connection
.execute(
"CREATE INDEX IF NOT EXISTS idx_usage_events_canceled_timestamp ON usage_events(canceled, timestamp_ms DESC)",
[],
)
.map_err(|error| format!("创建 SQLite 取消记录索引失败: {error}"))?;
connection
.pragma_update(None, "user_version", USAGE_DATABASE_SCHEMA_VERSION)
.map_err(|error| format!("更新 SQLite 使用记录版本失败: {error}"))
}
fn ensure_usage_failure_columns(connection: &Connection) -> Result<(), String> {
let mut columns = usage_table_columns(connection, "usage_events")?;
for (column, definition) in [
("canceled", "INTEGER NOT NULL DEFAULT 0"),
("failure_status", "INTEGER NOT NULL DEFAULT 0"),
("failure_body", "TEXT NOT NULL DEFAULT ''"),
] {
if columns.contains(column) {
continue;
}
connection
.execute(
&format!("ALTER TABLE usage_events ADD COLUMN {column} {definition}"),
[],
)
.map_err(|error| format!("添加 SQLite 使用记录字段 {column} 失败: {error}"))?;
columns.insert(column.to_string());
}
backfill_usage_failure_details(connection)?;
Ok(())
}
fn backfill_usage_failure_details(connection: &Connection) -> Result<(), String> {
let migrated = connection
.query_row(
"SELECT value FROM usage_metadata WHERE key = ?1",
params![USAGE_FAILURE_MIGRATION_KEY],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(|error| format!("读取使用记录失败详情迁移状态失败: {error}"))?;
if migrated.is_some() {
return Ok(());
}
if usage_table_exists(connection, "usage_inbox")? {
let rows = {
let mut statement = connection
.prepare(
r#"
SELECT usage_event_key, raw_message
FROM usage_inbox
WHERE status = 'processed' AND usage_event_key != ''
"#,
)
.map_err(|error| format!("准备回填使用记录失败详情失败: {error}"))?;
let rows = statement
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|error| format!("查询使用记录失败详情失败: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取使用记录失败详情失败: {error}"))?;
rows
};
for (event_key, raw_message) in rows {
let Some(object) = serde_json::from_str::<Value>(&raw_message)
.ok()
.and_then(|value| value.as_object().cloned())
else {
continue;
};
if !object
.get("failed")
.and_then(Value::as_bool)
.unwrap_or(false)
{
continue;
}
let (failure_status, failure_body) = usage_failure_details(&object);
let canceled = usage_failure_is_canceled(failure_status, &failure_body);
connection
.execute(
r#"
UPDATE usage_events
SET canceled = ?1, failure_status = ?2, failure_body = ?3
WHERE event_key = ?4
"#,
params![canceled, i64::from(failure_status), failure_body, event_key],
)
.map_err(|error| format!("回填使用记录失败详情失败: {error}"))?;
}
}
connection
.execute(
"INSERT INTO usage_metadata (key, value) VALUES (?1, ?2)",
params![USAGE_FAILURE_MIGRATION_KEY, Local::now().to_rfc3339()],
)
.map_err(|error| format!("记录使用记录失败详情迁移状态失败: {error}"))?;
Ok(())
}
fn open_usage_database() -> Result<Connection, String> {
open_usage_database_at(&usage_root_dir()?)
}
fn open_usage_database_at(root: &Path) -> Result<Connection, String> {
fs::create_dir_all(root).map_err(|error| format!("创建使用记录目录失败: {error}"))?;
let path = root.join(USAGE_DATABASE_FILE);
let connection = Connection::open(&path)
.map_err(|error| format!("打开 SQLite 使用记录数据库失败 {}: {error}", path.display()))?;
connection
.busy_timeout(Duration::from_secs(SQLITE_BUSY_TIMEOUT_SECONDS))
.map_err(|error| format!("设置 SQLite busy timeout 失败: {error}"))?;
connection
.pragma_update(None, "foreign_keys", "ON")
.map_err(|error| format!("启用 SQLite foreign keys 失败: {error}"))?;
connection
.pragma_update(None, "synchronous", "NORMAL")
.map_err(|error| format!("设置 SQLite synchronous 模式失败: {error}"))?;
Ok(connection)
}
fn migrate_legacy_json_storage(connection: &mut Connection, root: &Path) -> Result<(), String> {
let migrated = connection
.query_row(
"SELECT value FROM usage_metadata WHERE key = ?1",
params![LEGACY_JSON_MIGRATION_KEY],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(|error| format!("读取旧使用记录迁移状态失败: {error}"))?
.is_some();
if migrated {
return Ok(());
}
let transaction = connection
.transaction()
.map_err(|error| format!("开始旧使用记录迁移事务失败: {error}"))?;
let mut migrated_records = 0_usize;
for path in sorted_json_files(&root.join(LEGACY_USAGE_EVENTS_DIR))? {
let content = fs::read_to_string(&path)
.map_err(|error| format!("读取旧使用记录失败 {}: {error}", path.display()))?;
let file = serde_json::from_str::<LegacyUsageHourFile>(&content)
.map_err(|error| format!("解析旧使用记录失败 {}: {error}", path.display()))?;
validate_legacy_schema(file.schema_version, &path)?;
migrated_records = migrated_records.saturating_add(insert_usage_records_in_transaction(
&transaction,
&file.records,
)?);
}
for path in sorted_json_files(&root.join(LEGACY_USAGE_INBOX_DIR))? {
let content = fs::read_to_string(&path)
.map_err(|error| format!("读取旧使用记录收件箱失败 {}: {error}", path.display()))?;
let file = serde_json::from_str::<LegacyUsageInboxFile>(&content)
.map_err(|error| format!("解析旧使用记录收件箱失败 {}: {error}", path.display()))?;
validate_legacy_schema(file.schema_version, &path)?;
migrated_records = migrated_records.saturating_add(insert_usage_records_in_transaction(
&transaction,
&file.records,
)?);
}
transaction
.execute(
"INSERT INTO usage_metadata (key, value) VALUES (?1, ?2)",
params![LEGACY_JSON_MIGRATION_KEY, migrated_records.to_string()],
)
.map_err(|error| format!("记录旧使用记录迁移状态失败: {error}"))?;
transaction
.commit()
.map_err(|error| format!("提交旧使用记录迁移失败: {error}"))?;
Ok(())
}
fn sorted_json_files(directory: &Path) -> Result<Vec<PathBuf>, String> {
if !directory.is_dir() {
return Ok(Vec::new());
}
let mut paths = fs::read_dir(directory)
.map_err(|error| format!("读取旧使用记录目录失败 {}: {error}", directory.display()))?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json"))
.collect::<Vec<_>>();
paths.sort();
Ok(paths)
}
fn validate_legacy_schema(version: u8, path: &Path) -> Result<(), String> {
if version == USAGE_SCHEMA_VERSION {
Ok(())
} else {
Err(format!(
"不支持的旧使用记录版本 {version}: {}",
path.display()
))
}
}
pub(crate) fn start_usage_collector(app: tauri::AppHandle) {
let state = app.state::<UsageCollectorState>();
if let Ok(total_records) = total_usage_records() {
state.set_total_records(total_records);
}
let Some(token) = state.start() else {
return;
};
tauri::async_runtime::spawn(async move {
usage_collector_loop(app, token).await;
});
}
pub(crate) fn stop_usage_collector(app: &tauri::AppHandle) {
app.state::<UsageCollectorState>().stop();
}
async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) {
let root = match usage_root_dir() {
Ok(root) => root,
Err(error) => {
set_collector_error(&app, error);
return;
}
};
let mut retry_seconds = 1_u64;
let mut subscription: Option<UsageSubscription> = None;
let mut subscribe_retry_at = tokio::time::Instant::now();
let mut next_inbox_cleanup_at = tokio::time::Instant::now() + Duration::from_secs(60 * 60);
loop {
if token.is_cancelled() {
return;
}
let config = match app.state::<GuiConfigState>().snapshot() {
Ok(config) => config,
Err(error) => {
set_collector_error(&app, error);
wait_or_cancel(&token, retry_seconds).await;
retry_seconds = (retry_seconds * 2).min(10);
continue;
}
};
if tokio::time::Instant::now() >= next_inbox_cleanup_at {
if let Err(error) = open_usage_database_at(&root)
.and_then(|connection| cleanup_usage_inbox(&connection, Local::now()))
{
eprintln!("清理使用记录 inbox 失败: {error}");
}
next_inbox_cleanup_at = tokio::time::Instant::now() + Duration::from_secs(60 * 60);
}
let recovered = open_usage_database_at(&root)
.and_then(|mut connection| process_usage_inbox(&mut connection, &config));
match recovered {
Ok(saved) if saved > 0 => {
let collected_at = Local::now().to_rfc3339();
app.state::<UsageCollectorState>()
.increment_total_records(saved);
set_collector_status(
&app,
"collecting",
&format!("已恢复 {saved} 条待处理记录"),
Some(collected_at.clone()),
);
let _ = app.emit(USAGE_UPDATED_EVENT, collected_at);
retry_seconds = 1;
continue;
}
Ok(_) => {}
Err(error) => {
set_collector_error(&app, error);
wait_or_cancel(&token, retry_seconds).await;
retry_seconds = (retry_seconds * 2).min(10);
continue;
}
}
let process_state = app.state::<CoreProcessState>();
let core_running = current_core_status(Some(process_state.inner()), Some(config.port))
.map(|status| status.running)
.unwrap_or(false);
if !core_running {
subscription = None;
set_collector_status(&app, "waiting-core", "等待内核启动", None);
retry_seconds = 1;
wait_or_cancel(&token, 1).await;
continue;
}
if subscription.is_none() && tokio::time::Instant::now() >= subscribe_retry_at {
match UsageSubscription::connect(config.port, &config.management_secret_key).await {
Ok(next_subscription) => {
subscription = Some(next_subscription);
set_collector_status(&app, "collecting", "已连接 CPA usage 实时订阅", None);
match backfill_usage_queue(&root, &config).await {
Ok(saved) => {
publish_collected_records(
&app,
saved,
&format!("实时订阅已连接,补录 {saved} 条队列记录"),
);
retry_seconds = 1;
}
Err(error) => {
set_collector_status(
&app,
"collecting",
&format!("实时订阅已连接,历史队列补录失败: {error}"),
None,
);
}
}
continue;
}
Err(error) => {
subscribe_retry_at = tokio::time::Instant::now()
+ Duration::from_secs(USAGE_SUBSCRIBE_RETRY_SECONDS);
set_collector_status(
&app,
"collecting",
&format!("使用 HTTP 兼容模式采集;实时订阅不可用: {error}"),
None,
);
}
}
}
if let Some(active_subscription) = subscription.as_mut() {
let message = tokio::select! {
_ = token.cancelled() => return,
result = tokio::time::timeout(
Duration::from_secs(2),
active_subscription.next_message(),
) => result,
};
match message {
Ok(Ok(payload)) => {
match persist_raw_usage_message_from_source(
&root,
"redis_subscribe:usage",
payload,
&config,
) {
Ok(saved) => {
publish_collected_records(
&app,
saved,
&format!("实时订阅已保存 {saved} 条新记录"),
);
retry_seconds = 1;
}
Err(error) => {
set_collector_error(&app, error);
wait_or_cancel(&token, retry_seconds).await;
retry_seconds = (retry_seconds * 2).min(10);
}
}
continue;
}
Err(_) => {
set_collector_status(&app, "collecting", "CPA usage 实时订阅采集中", None);
retry_seconds = 1;
continue;
}
Ok(Err(error)) => {
subscription = None;
subscribe_retry_at = tokio::time::Instant::now()
+ Duration::from_secs(USAGE_SUBSCRIBE_RETRY_SECONDS);
set_collector_status(
&app,
"collecting",
&format!("实时订阅断开,已切换 HTTP 兼容模式: {error}"),
None,
);
}
}
}
match fetch_usage_queue(&config).await {
Ok(items) if items.is_empty() => {
set_collector_status(&app, "collecting", "使用记录采集中", None);
retry_seconds = 1;
wait_or_cancel(&token, 1).await;
}
Ok(items) => match persist_queue_items(&root, items, &config) {
Ok(saved) => {
publish_collected_records(
&app,
saved,
&format!("HTTP 兼容模式已保存 {saved} 条新记录"),
);
retry_seconds = 1;
}
Err(error) => {
set_collector_error(&app, error);
wait_or_cancel(&token, retry_seconds).await;
retry_seconds = (retry_seconds * 2).min(10);
}
},
Err(error) => {
set_collector_error(&app, error);
wait_or_cancel(&token, retry_seconds).await;
retry_seconds = (retry_seconds * 2).min(10);
}
}
}
}
async fn backfill_usage_queue(root: &Path, config: &GuiConfigFile) -> Result<usize, String> {
let mut saved_total = 0_usize;
loop {
let items = fetch_usage_queue(config).await?;
let fetched = items.len();
if fetched == 0 {
return Ok(saved_total);
}
saved_total = saved_total.saturating_add(persist_queue_items_from_source(
root,
"http_backfill",
items,
config,
)?);
if fetched < USAGE_QUEUE_BATCH_SIZE {
return Ok(saved_total);
}
}
}
fn publish_collected_records(app: &tauri::AppHandle, saved: usize, message: &str) {
let collected_at = Local::now().to_rfc3339();
if saved > 0 {
app.state::<UsageCollectorState>()
.increment_total_records(saved);
}
set_collector_status(
app,
"collecting",
message,
(saved > 0).then_some(collected_at.clone()),
);
if saved > 0 {
let _ = app.emit(USAGE_UPDATED_EVENT, collected_at);
}
}
pub(crate) fn persist_local_usage_event(
app: &tauri::AppHandle,
collector_source: &str,
value: Value,
) -> Result<usize, String> {
let config = app.state::<GuiConfigState>().snapshot()?;
let root = usage_root_dir()?;
let saved = persist_queue_items_from_source(&root, collector_source, vec![value], &config)?;
publish_collected_records(app, saved, "已保存桌面健康检测使用记录");
Ok(saved)
}
async fn wait_or_cancel(token: &CancellationToken, seconds: u64) {
tokio::select! {
_ = token.cancelled() => {},
_ = tokio::time::sleep(Duration::from_secs(seconds)) => {},
}
}
async fn fetch_usage_queue(config: &GuiConfigFile) -> Result<Vec<Value>, String> {
let client = management_http_client()?;
let response = client
.get(management_endpoint(config, "usage-queue")?)
.header("Authorization", management_authorization(config)?)
.query(&[("count", USAGE_QUEUE_BATCH_SIZE)])
.send()
.await
.map_err(|error| format!("读取 CPA 使用记录队列失败: {error}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|error| format!("读取 CPA 使用记录响应失败: {error}"))?;
if !status.is_success() {
return Err(format!(
"CPA 使用记录队列返回 HTTP {}: {}",
status.as_u16(),
text.trim()
));
}
serde_json::from_str::<Vec<Value>>(&text)
.map_err(|error| format!("解析 CPA 使用记录失败: {error}"))
}
fn set_collector_error(app: &tauri::AppHandle, error: String) {
set_collector_status(app, "error", &error, None);
}
fn set_collector_status(
app: &tauri::AppHandle,
state_name: &str,
message: &str,
last_collected_at: Option<String>,
) {
let state = app.state::<UsageCollectorState>();
let previous = state.status().ok();
let total_records = previous
.as_ref()
.map(|value| value.total_records)
.unwrap_or(0);
state.set_status(UsageCollectorStatus {
state: state_name.to_string(),
message: message.to_string(),
last_collected_at: last_collected_at
.or_else(|| previous.and_then(|value| value.last_collected_at)),
total_records,
});
}
fn persist_queue_items(
root: &Path,
items: Vec<Value>,
config: &GuiConfigFile,
) -> Result<usize, String> {
persist_queue_items_from_source(root, "http_pull", items, config)
}
fn persist_queue_items_from_source(
root: &Path,
source: &str,
items: Vec<Value>,
config: &GuiConfigFile,
) -> Result<usize, String> {
let mut connection = open_usage_database_at(root)?;
enqueue_usage_queue_items(&mut connection, source, items)?;
process_usage_inbox(&mut connection, config)
}
fn enqueue_usage_queue_items(
connection: &mut Connection,
source: &str,
items: Vec<Value>,
) -> Result<usize, String> {
let messages = items
.into_iter()
.map(|item| {
serde_json::to_string(&item)
.map_err(|error| format!("序列化 CPA 使用记录失败: {error}"))
})
.collect::<Result<Vec<_>, _>>()?;
enqueue_usage_raw_messages(connection, source, messages)
}
fn enqueue_usage_raw_messages(
connection: &mut Connection,
source: &str,
messages: Vec<String>,
) -> Result<usize, String> {
if messages.is_empty() {
return Ok(0);
}
let transaction = connection
.transaction()
.map_err(|error| format!("开始 SQLite 使用记录 inbox 事务失败: {error}"))?;
let mut statement = transaction
.prepare(
r#"
INSERT INTO usage_inbox (
source, message_hash, raw_message, status, attempt_count,
received_at, created_at, updated_at
) VALUES (?1, ?2, ?3, 'pending', 0, ?4, ?4, ?4)
"#,
)
.map_err(|error| format!("准备 SQLite 使用记录 inbox 写入失败: {error}"))?;
let received_at = Local::now().to_rfc3339();
let mut inserted = 0_usize;
for raw_message in messages {
inserted = inserted.saturating_add(
statement
.execute(params![
source,
hash_text(&raw_message),
raw_message,
received_at,
])
.map_err(|error| format!("写入 SQLite 使用记录 inbox 失败: {error}"))?,
);
}
drop(statement);
transaction
.commit()
.map_err(|error| format!("提交 SQLite 使用记录 inbox 失败: {error}"))?;
Ok(inserted)
}
fn persist_raw_usage_message_from_source(
root: &Path,
source: &str,
raw_message: String,
config: &GuiConfigFile,
) -> Result<usize, String> {
let mut connection = open_usage_database_at(root)?;
enqueue_usage_raw_messages(&mut connection, source, vec![raw_message])?;
process_usage_inbox(&mut connection, config)
}
fn process_usage_inbox(
connection: &mut Connection,
config: &GuiConfigFile,
) -> Result<usize, String> {
let rows = list_processable_usage_inbox(connection, USAGE_INBOX_PROCESS_LIMIT)?;
if rows.is_empty() {
return Ok(0);
}
let mut valid_rows = Vec::with_capacity(rows.len());
let mut records = Vec::with_capacity(rows.len());
for row in rows {
let parsed = serde_json::from_str::<Value>(&row.raw_message)
.map_err(|error| format!("解析 inbox JSON 失败: {error}"))
.and_then(|value| normalize_usage_record(value, config));
match parsed {
Ok(mut record) => {
record.collector_source = row.source.clone();
valid_rows.push(row);
records.push(record);
}
Err(error) => mark_usage_inbox_decode_failed(connection, row.id, &error)?,
}
}
if records.is_empty() {
return Ok(0);
}
let persist_result = (|| -> Result<usize, String> {
let transaction = connection
.transaction()
.map_err(|error| format!("开始 SQLite inbox 处理事务失败: {error}"))?;
let inserted = insert_usage_records_in_transaction(&transaction, &records)?;
let processed_at = Local::now().to_rfc3339();
for (row, record) in valid_rows.iter().zip(records.iter()) {
transaction
.execute(
r#"
UPDATE usage_inbox
SET status = 'processed', attempt_count = attempt_count + 1,
last_error = '', usage_event_key = ?1,
processed_at = ?2, updated_at = ?2
WHERE id = ?3
"#,
params![record.id, processed_at, row.id],
)
.map_err(|error| format!("标记 SQLite 使用记录 inbox 已处理失败: {error}"))?;
}
transaction
.commit()
.map_err(|error| format!("提交 SQLite inbox 处理事务失败: {error}"))?;
Ok(inserted)
})();
match persist_result {
Ok(inserted) => Ok(inserted),
Err(error) => {
mark_usage_inbox_process_failed(connection, &valid_rows, &error)?;
Err(error)
}
}
}
fn list_processable_usage_inbox(
connection: &Connection,
limit: usize,
) -> Result<Vec<UsageInboxRow>, String> {
let mut statement = connection
.prepare(
r#"
SELECT id, source, raw_message, attempt_count
FROM usage_inbox
WHERE status IN ('pending', 'process_failed')
AND attempt_count < ?1
ORDER BY id ASC
LIMIT ?2
"#,
)
.map_err(|error| format!("准备读取 SQLite 使用记录 inbox 失败: {error}"))?;
let rows = statement
.query_map(
params![
USAGE_INBOX_MAX_ATTEMPTS,
limit.min(i64::MAX as usize) as i64
],
|row| {
Ok(UsageInboxRow {
id: row.get(0)?,
source: row.get(1)?,
raw_message: row.get(2)?,
attempt_count: row.get(3)?,
})
},
)
.map_err(|error| format!("查询 SQLite 使用记录 inbox 失败: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取 SQLite 使用记录 inbox 失败: {error}"))?;
Ok(rows)
}
fn mark_usage_inbox_decode_failed(
connection: &Connection,
id: i64,
error: &str,
) -> Result<(), String> {
let now = Local::now().to_rfc3339();
connection
.execute(
r#"
UPDATE usage_inbox
SET status = 'decode_failed', attempt_count = attempt_count + 1,
last_error = ?1, processed_at = ?2, updated_at = ?2
WHERE id = ?3
"#,
params![bounded_usage_inbox_error(error), now, id],
)
.map(|_| ())
.map_err(|update_error| {
format!("标记 SQLite 使用记录 inbox 解码失败时出错: {update_error}")
})
}
fn mark_usage_inbox_process_failed(
connection: &Connection,
rows: &[UsageInboxRow],
error: &str,
) -> Result<(), String> {
let now = Local::now().to_rfc3339();
let error = bounded_usage_inbox_error(error);
for row in rows {
let next_attempt = row.attempt_count.saturating_add(1);
let status = if next_attempt >= USAGE_INBOX_MAX_ATTEMPTS {
"discarded"
} else {
"process_failed"
};
connection
.execute(
r#"
UPDATE usage_inbox
SET status = ?1, attempt_count = ?2, last_error = ?3,
processed_at = CASE WHEN ?1 = 'discarded' THEN ?4 ELSE NULL END,
updated_at = ?4
WHERE id = ?5
"#,
params![status, next_attempt, error, now, row.id],
)
.map_err(|update_error| {
format!("标记 SQLite 使用记录 inbox 处理失败时出错: {update_error}")
})?;
}
Ok(())
}
fn bounded_usage_inbox_error(error: &str) -> String {
error.chars().take(1_000).collect()
}
fn cleanup_usage_inbox(connection: &Connection, now: DateTime<Local>) -> Result<(), String> {
let processed_cutoff = now
.date_naive()
.and_hms_opt(0, 0, 0)
.and_then(|value| value.and_local_timezone(Local).single())
.unwrap_or(now)
.to_rfc3339();
let failed_cutoff = (now - chrono::Duration::days(7)).to_rfc3339();
connection
.execute(
"DELETE FROM usage_inbox WHERE status = 'processed' AND processed_at < ?1",
params![processed_cutoff],
)
.map_err(|error| format!("清理已处理使用记录 inbox 失败: {error}"))?;
connection
.execute(
"DELETE FROM usage_inbox WHERE status IN ('decode_failed', 'discarded') AND updated_at < ?1",
params![failed_cutoff],
)
.map_err(|error| format!("清理失败使用记录 inbox 失败: {error}"))?;
Ok(())
}
#[cfg(test)]
fn insert_usage_records(
connection: &mut Connection,
records: &[UsageRecord],
) -> Result<usize, String> {
let transaction = connection
.transaction()
.map_err(|error| format!("开始 SQLite 使用记录事务失败: {error}"))?;
let inserted = insert_usage_records_in_transaction(&transaction, records)?;
transaction
.commit()
.map_err(|error| format!("提交 SQLite 使用记录失败: {error}"))?;
Ok(inserted)
}
fn insert_usage_records_in_transaction(
transaction: &Transaction<'_>,
records: &[UsageRecord],
) -> Result<usize, String> {
if records.is_empty() {
return Ok(0);
}
let mut statement = transaction
.prepare(
r#"
INSERT INTO usage_events (
event_key, timestamp, timestamp_ms, local_hour, latency_ms, ttft_ms,
source, auth_index, failed, provider, model, alias, reasoning_effort,
service_tier, response_service_tier, executor_type, endpoint, auth_type,
api_key_hash, api_key_display, api_key_remark, request_id,
api_group_key, model_alias, client_ip, x_forwarded_for, user_agent,
generate, cached_tokens, collector_source,
input_tokens, output_tokens, reasoning_tokens, cache_read_tokens,
cache_creation_tokens, total_tokens, canceled, failure_status,
failure_body, created_at
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20,
?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30,
?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40
)
"#,
)
.map_err(|error| format!("准备 SQLite 使用记录写入失败: {error}"))?;
let created_at = Local::now().to_rfc3339();
let mut inserted = 0_usize;
for record in records {
let api_group_key = if !record.api_group_key.trim().is_empty() {
record.api_group_key.as_str()
} else if !record.api_key_hash.trim().is_empty() {
record.api_key_hash.as_str()
} else if !record.provider.trim().is_empty() {
record.provider.as_str()
} else if !record.endpoint.trim().is_empty() {
record.endpoint.as_str()
} else {
"unknown"
};
let cache_components = record
.tokens
.cache_read_tokens
.saturating_add(record.tokens.cache_creation_tokens);
let input_before_invariant = record.tokens.input_tokens;
let input_tokens = if cache_components > input_before_invariant {
input_before_invariant.saturating_add(cache_components)
} else {
input_before_invariant
};
let total_tokens = if record.tokens.total_tokens == 0
|| record.tokens.total_tokens
== input_before_invariant.saturating_add(record.tokens.output_tokens)
{
input_tokens.saturating_add(record.tokens.output_tokens)
} else {
record.tokens.total_tokens
};
let cached_tokens = record.cached_tokens.max(cache_components);
let collector_source = if record.collector_source.trim().is_empty() {
"legacy_json"
} else {
record.collector_source.as_str()
};
inserted = inserted.saturating_add(
statement
.execute(params![
record.id,
record.timestamp,
record_timestamp_millis(record),
record_local_hour(record),
to_sql_i64(record.latency_ms),
record.ttft_ms.map(to_sql_i64),
record.source,
record.auth_index,
record.failed,
record.provider,
record.model,
record.alias,
record.reasoning_effort,
record.service_tier,
record.response_service_tier,
record.executor_type,
record.endpoint,
record.auth_type,
record.api_key_hash,
record.api_key_display,
record.api_key_remark,
record.request_id,
api_group_key,
if record.alias.trim().is_empty() {
None::<&str>
} else {
Some(record.alias.as_str())
},
record.client_ip,
record.x_forwarded_for,
record.user_agent,
record.generate,
to_sql_i64(cached_tokens),
collector_source,
to_sql_i64(input_tokens),
to_sql_i64(record.tokens.output_tokens),
to_sql_i64(record.tokens.reasoning_tokens),
to_sql_i64(record.tokens.cache_read_tokens),
to_sql_i64(record.tokens.cache_creation_tokens),
to_sql_i64(total_tokens),
record.canceled,
i64::from(record.failure_status),
record.failure_body,
created_at,
])
.map_err(|error| format!("写入 SQLite 使用记录失败: {error}"))?,
);
}
Ok(inserted)
}
fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result<UsageRecord, String> {
let object = value
.as_object()
.ok_or_else(|| "CPA 使用记录必须是 JSON 对象".to_string())?;
let timestamp = string_field(object, "timestamp")
.filter(|value| DateTime::parse_from_rfc3339(value).is_ok())
.unwrap_or_else(|| Local::now().to_rfc3339());
let request_id = string_field(object, "request_id").unwrap_or_default();
let api_key = string_field(object, "api_key").unwrap_or_default();
let api_key_hash = hash_text(&api_key);
let api_key_remark = config
.api_keys
.iter()
.find(|entry| entry.key == api_key)
.map(|entry| entry.remark.clone())
.unwrap_or_default();
let provider = string_field(object, "provider").unwrap_or_default();
let executor_type = string_field(object, "executor_type").unwrap_or_default();
let tokens_object = object.get("tokens").and_then(Value::as_object);
let cache_read_present = tokens_object
.and_then(|tokens| tokens.get("cache_read_tokens"))
.is_some();
let raw_cache_read_tokens = token_u64(tokens_object, "cache_read_tokens");
let cache_creation_tokens = token_u64(tokens_object, "cache_creation_tokens");
let raw_cached_tokens =
token_u64(tokens_object, "cached_tokens").max(token_u64(tokens_object, "cache_tokens"));
let normalized_cache_read_tokens = if cache_read_present {
raw_cache_read_tokens
} else {
raw_cached_tokens
};
let raw_input_tokens = token_u64(tokens_object, "input_tokens");
let mut tokens = UsageTokenStats {
input_tokens: raw_input_tokens,
output_tokens: token_u64(tokens_object, "output_tokens"),
reasoning_tokens: token_u64(tokens_object, "reasoning_tokens"),
cache_read_tokens: normalized_cache_read_tokens,
cache_creation_tokens,
total_tokens: token_u64(tokens_object, "total_tokens"),
};
let provider_lower = provider.to_ascii_lowercase();
let is_claude_executor = executor_type.eq_ignore_ascii_case("ClaudeExecutor")
|| provider_lower == "claude"
|| provider_lower.contains("anthropic");
let cache_components = tokens
.cache_read_tokens
.saturating_add(tokens.cache_creation_tokens);
let raw_total_without_cache = raw_input_tokens.saturating_add(tokens.output_tokens);
let raw_total_with_cache = raw_total_without_cache.saturating_add(cache_components);
let claude_excludes_cache = is_claude_executor
&& cache_components > 0
&& (raw_input_tokens < cache_components || tokens.total_tokens == raw_total_with_cache);
if claude_excludes_cache {
tokens.input_tokens = raw_input_tokens
.saturating_add(tokens.cache_read_tokens)
.saturating_add(tokens.cache_creation_tokens);
}
let input_before_invariant = tokens.input_tokens;
if cache_components > input_before_invariant {
tokens.input_tokens = input_before_invariant.saturating_add(cache_components);
if tokens.total_tokens == 0
|| tokens.total_tokens == input_before_invariant.saturating_add(tokens.output_tokens)
{
tokens.total_tokens = tokens.input_tokens.saturating_add(tokens.output_tokens);
}
}
if tokens.total_tokens == 0
|| (claude_excludes_cache && tokens.total_tokens == raw_total_without_cache)
{
tokens.total_tokens = tokens.input_tokens.saturating_add(tokens.output_tokens);
}
let mut canonical = object.clone();
canonical.remove("response_headers");
canonical.remove("api_key");
let id = if request_id.is_empty() {
hash_text(&serde_json::to_string(&canonical).unwrap_or_default())
} else {
request_id.clone()
};
let endpoint = string_field(object, "endpoint").unwrap_or_default();
let failed = object
.get("failed")
.and_then(Value::as_bool)
.unwrap_or(false);
let (failure_status, failure_body) = usage_failure_details(object);
let canceled = failed && usage_failure_is_canceled(failure_status, &failure_body);
let generate = object
.get("generate")
.and_then(Value::as_bool)
.unwrap_or_else(|| {
!(executor_type == "CodexWebsocketsExecutor"
&& !failed
&& tokens.input_tokens == 0
&& tokens.output_tokens == 0
&& tokens.reasoning_tokens == 0
&& tokens.cache_read_tokens == 0
&& tokens.cache_creation_tokens == 0
&& tokens.total_tokens == 0)
});
let api_group_key = if api_key_hash.is_empty() {
if !provider.is_empty() {
provider.clone()
} else if !endpoint.is_empty() {
endpoint.clone()
} else {
"unknown".to_string()
}
} else {
api_key_hash.clone()
};
Ok(UsageRecord {
id,
timestamp,
latency_ms: u64_field(object, "latency_ms"),
ttft_ms: optional_u64_field(object, "ttft_ms"),
source: string_field(object, "source").unwrap_or_default(),
source_display: String::new(),
auth_index: string_field(object, "auth_index").unwrap_or_default(),
failed,
canceled,
failure_status,
failure_body,
provider,
api_group_key,
model: string_field(object, "model").unwrap_or_else(|| "unknown".to_string()),
alias: string_field(object, "alias").unwrap_or_default(),
client_ip: string_field(object, "client_ip"),
x_forwarded_for: string_field(object, "x_forwarded_for"),
user_agent: string_field(object, "user_agent"),
reasoning_effort: string_field(object, "reasoning_effort").unwrap_or_default(),
service_tier: string_field(object, "service_tier").unwrap_or_default(),
response_service_tier: string_field(object, "response_service_tier").unwrap_or_default(),
executor_type,
endpoint,
auth_type: string_field(object, "auth_type").unwrap_or_default(),
api_key_hash,
api_key_display: mask_api_key(&api_key),
api_key_remark,
request_id,
generate,
cached_tokens: raw_cached_tokens.max(
tokens
.cache_read_tokens
.saturating_add(tokens.cache_creation_tokens),
),
collector_source: "http_pull".to_string(),
tokens,
})
}
fn usage_failure_body(value: &Value) -> String {
let body = match value {
Value::String(value) => value.trim().to_string(),
Value::Null => String::new(),
value => serde_json::to_string(value).unwrap_or_default(),
};
if body.chars().count() <= MAX_USAGE_FAILURE_BODY_CHARS {
body
} else {
let mut bounded = body
.chars()
.take(MAX_USAGE_FAILURE_BODY_CHARS)
.collect::<String>();
bounded.push('…');
bounded
}
}
fn usage_failure_details(object: &serde_json::Map<String, Value>) -> (u16, String) {
let failure = object.get("fail").and_then(Value::as_object);
let status = failure
.and_then(|value| value.get("status_code").or_else(|| value.get("statusCode")))
.and_then(Value::as_u64)
.unwrap_or_default()
.min(u16::MAX as u64) as u16;
let body = failure
.and_then(|value| value.get("body"))
.map(usage_failure_body)
.unwrap_or_default();
(status, body)
}
fn usage_failure_is_canceled(status: u16, body: &str) -> bool {
if status == 499 {
return true;
}
let body = body.to_ascii_lowercase();
body.contains("context canceled") || body.contains("client closed request")
}
fn default_usage_generate() -> bool {
true
}
fn build_usage_filter(query: &UsageQuery) -> UsageSqlFilter {
let mut clauses = Vec::<String>::new();
let mut params = Vec::<SqlValue>::new();
if let Some(start) = query.start.as_deref().and_then(parse_timestamp_millis) {
clauses.push("timestamp_ms >= ?".to_string());
params.push(SqlValue::Integer(start));
}
if let Some(end) = query.end.as_deref().and_then(parse_timestamp_millis) {
clauses.push("timestamp_ms <= ?".to_string());
params.push(SqlValue::Integer(end));
}
add_text_filter(&mut clauses, &mut params, "model", query.model.as_deref());
add_text_filter(
&mut clauses,
&mut params,
"provider",
query.provider.as_deref(),
);
add_text_filter(&mut clauses, &mut params, "source", query.source.as_deref());
add_text_filter(
&mut clauses,
&mut params,
"api_key_hash",
query.api_key_hash.as_deref(),
);
if let Some(canceled) = query.canceled {
clauses.push("canceled = ?".to_string());
params.push(SqlValue::Integer(i64::from(canceled)));
}
if let Some(failed) = query.failed {
if failed {
clauses.push("failed != 0 AND canceled = 0".to_string());
} else {
clauses.push("failed = 0".to_string());
}
}
UsageSqlFilter {
clause: if clauses.is_empty() {
String::new()
} else {
format!(" WHERE {}", clauses.join(" AND "))
},
params,
}
}
fn add_text_filter(
clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
column: &str,
value: Option<&str>,
) {
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
clauses.push(format!("{column} = ? COLLATE NOCASE"));
params.push(SqlValue::Text(value.to_string()));
}
}
#[tauri::command]
pub(crate) fn get_usage_collector_status(
state: tauri::State<'_, UsageCollectorState>,
) -> Result<UsageCollectorStatus, String> {
state.status()
}
#[tauri::command]
pub(crate) fn get_usage_overview(query: UsageQuery) -> Result<UsageOverview, String> {
let connection = open_usage_database()?;
load_usage_overview(&connection, &query)
}
fn load_usage_overview(
connection: &Connection,
query: &UsageQuery,
) -> Result<UsageOverview, String> {
let filter = build_usage_filter(query);
let summary_sql = format!(
r#"
SELECT
COUNT(*),
COALESCE(SUM(CASE WHEN failed = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN failed != 0 AND canceled = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN canceled != 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
COALESCE(SUM(reasoning_tokens), 0),
COALESCE(SUM(cache_read_tokens), 0),
COALESCE(SUM(cache_creation_tokens), 0),
COALESCE(SUM(total_tokens), 0),
COALESCE(SUM(latency_ms), 0),
COALESCE(
SUM(CASE
WHEN generate != 0
AND failed = 0
AND canceled = 0
AND output_tokens > 0
AND ttft_ms IS NOT NULL
AND ttft_ms > 0
AND latency_ms > ttft_ms
THEN output_tokens
ELSE 0
END) * 1000.0
/ NULLIF(SUM(CASE
WHEN generate != 0
AND failed = 0
AND canceled = 0
AND output_tokens > 0
AND ttft_ms IS NOT NULL
AND ttft_ms > 0
AND latency_ms > ttft_ms
THEN latency_ms - ttft_ms
ELSE 0
END), 0),
0.0
),
COALESCE(SUM(CASE
WHEN generate != 0
AND failed = 0
AND canceled = 0
AND output_tokens > 0
AND ttft_ms IS NOT NULL
AND ttft_ms > 0
AND latency_ms > ttft_ms
THEN 1
ELSE 0
END), 0),
MIN(timestamp_ms),
MAX(timestamp_ms)
FROM usage_events{}
"#,
filter.clause
);
let summary = connection
.query_row(
&summary_sql,
params_from_iter(filter.params.iter()),
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, i64>(4)?,
row.get::<_, i64>(5)?,
row.get::<_, i64>(6)?,
row.get::<_, i64>(7)?,
row.get::<_, i64>(8)?,
row.get::<_, i64>(9)?,
row.get::<_, i64>(10)?,
row.get::<_, f64>(11)?,
row.get::<_, i64>(12)?,
row.get::<_, Option<i64>>(13)?,
row.get::<_, Option<i64>>(14)?,
))
},
)
.map_err(|error| format!("统计 SQLite 使用记录失败: {error}"))?;
let (estimated_cost, priced_requests) = load_estimated_cost(connection, &filter)?;
let timeline_sql = format!(
r#"
SELECT
local_hour,
COUNT(*),
COALESCE(SUM(CASE WHEN failed = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN failed != 0 AND canceled = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN canceled != 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(total_tokens), 0)
FROM usage_events{}
GROUP BY local_hour
ORDER BY local_hour ASC
"#,
filter.clause
);
let mut statement = connection
.prepare(&timeline_sql)
.map_err(|error| format!("准备 SQLite 使用趋势查询失败: {error}"))?;
let timeline = statement
.query_map(params_from_iter(filter.params.iter()), |row| {
Ok(UsageTimelinePoint {
hour: row.get(0)?,
requests: from_sql_i64(row.get(1)?),
success: from_sql_i64(row.get(2)?),
failure: from_sql_i64(row.get(3)?),
canceled: from_sql_i64(row.get(4)?),
tokens: from_sql_i64(row.get(5)?),
})
})
.map_err(|error| format!("查询 SQLite 使用趋势失败: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取 SQLite 使用趋势失败: {error}"))?;
let mut overview = UsageOverview {
total_requests: from_sql_i64(summary.0),
success_count: from_sql_i64(summary.1),
failure_count: from_sql_i64(summary.2),
canceled_count: from_sql_i64(summary.3),
input_tokens: from_sql_i64(summary.4),
output_tokens: from_sql_i64(summary.5),
reasoning_tokens: from_sql_i64(summary.6),
cache_read_tokens: from_sql_i64(summary.7),
cache_creation_tokens: from_sql_i64(summary.8),
total_tokens: from_sql_i64(summary.9),
estimated_cost,
priced_requests,
timeline,
..UsageOverview::default()
};
if overview.total_requests > 0 {
let completed_requests = overview
.success_count
.saturating_add(overview.failure_count);
if completed_requests > 0 {
overview.success_rate =
overview.success_count as f64 * 100.0 / completed_requests as f64;
}
overview.average_latency_ms =
from_sql_i64(summary.10) as f64 / overview.total_requests as f64;
overview.tps = summary.11;
overview.tps_sample_count = from_sql_i64(summary.12);
if overview.input_tokens > 0 {
overview.cache_hit_rate =
(overview.cache_read_tokens as f64 / overview.input_tokens as f64).min(1.0);
}
let minutes = query_window_minutes(query, summary.13, summary.14);
overview.rpm = overview.total_requests as f64 / minutes;
overview.tpm = overview.total_tokens as f64 / minutes;
}
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 client_builder = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30));
let proxy_url = config.proxy_url.trim();
let client = if proxy_url.is_empty() {
client_builder.build().ok()
} else {
apply_configured_proxy(client_builder, proxy_url)
.ok()
.and_then(|builder| builder.build().ok())
};
let remote_content = match client {
Some(client) => match client.get(MODEL_PRICE_SYNC_URL).send().await {
Ok(response) if response.status().is_success() => response.text().await.ok(),
_ => None,
},
None => 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,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<UsageAnalysis, String> {
let connection = open_usage_database()?;
let config = gui_config_state.snapshot()?;
load_usage_analysis(&connection, &query, &config)
}
fn load_usage_analysis(
connection: &Connection,
query: &UsageQuery,
config: &GuiConfigFile,
) -> Result<UsageAnalysis, String> {
Ok(UsageAnalysis {
models: load_simple_categories(connection, query, "model", "unknown")?,
providers: load_simple_categories(connection, query, "provider", "未知 Provider")?,
sources: load_source_categories(connection, query, config)?,
api_keys: load_api_key_categories(connection, query)?,
})
}
fn load_source_categories(
connection: &Connection,
query: &UsageQuery,
config: &GuiConfigFile,
) -> Result<Vec<UsageCategory>, String> {
let mut categories = load_simple_categories(connection, query, "source", "未知来源")?;
for category in &mut categories {
category.label = usage_source_display(config, "", &category.key);
}
Ok(categories)
}
fn load_simple_categories(
connection: &Connection,
query: &UsageQuery,
column: &str,
fallback: &str,
) -> Result<Vec<UsageCategory>, String> {
let filter = build_usage_filter(query);
let sql = format!(
r#"
SELECT
COALESCE(NULLIF(TRIM({column}), ''), ?),
COUNT(*),
COALESCE(SUM(CASE WHEN failed != 0 AND canceled = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(total_tokens), 0)
FROM usage_events{}
GROUP BY 1
ORDER BY 4 DESC, 2 DESC
"#,
filter.clause
);
let mut values = Vec::with_capacity(filter.params.len() + 1);
values.push(SqlValue::Text(fallback.to_string()));
values.extend(filter.params);
let mut statement = connection
.prepare(&sql)
.map_err(|error| format!("准备 SQLite 使用分析查询失败: {error}"))?;
let categories = statement
.query_map(params_from_iter(values.iter()), |row| {
let key = row.get::<_, String>(0)?;
Ok(UsageCategory {
label: key.clone(),
key,
requests: from_sql_i64(row.get(1)?),
failures: from_sql_i64(row.get(2)?),
tokens: from_sql_i64(row.get(3)?),
})
})
.map_err(|error| format!("查询 SQLite 使用分析失败: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取 SQLite 使用分析失败: {error}"))?;
Ok(categories)
}
fn load_api_key_categories(
connection: &Connection,
query: &UsageQuery,
) -> Result<Vec<UsageCategory>, String> {
let filter = build_usage_filter(query);
let sql = format!(
r#"
SELECT
COALESCE(NULLIF(TRIM(api_key_hash), ''), '未记录密钥'),
MAX(TRIM(api_key_remark)),
MAX(TRIM(api_key_display)),
COUNT(*),
COALESCE(SUM(CASE WHEN failed != 0 AND canceled = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(total_tokens), 0)
FROM usage_events{}
GROUP BY 1
ORDER BY 6 DESC, 4 DESC
"#,
filter.clause
);
let mut statement = connection
.prepare(&sql)
.map_err(|error| format!("准备 SQLite API Key 使用分析查询失败: {error}"))?;
let categories = statement
.query_map(params_from_iter(filter.params.iter()), |row| {
let key = row.get::<_, String>(0)?;
let remark = row.get::<_, String>(1)?;
let display = row.get::<_, String>(2)?;
let label = api_key_category_label(remark, display);
Ok(UsageCategory {
key,
label,
requests: from_sql_i64(row.get(3)?),
failures: from_sql_i64(row.get(4)?),
tokens: from_sql_i64(row.get(5)?),
})
})
.map_err(|error| format!("查询 SQLite API Key 使用分析失败: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取 SQLite API Key 使用分析失败: {error}"))?;
Ok(categories)
}
#[tauri::command]
pub(crate) fn get_usage_events(
query: UsageQuery,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<UsageEventPage, String> {
let connection = open_usage_database()?;
let config = gui_config_state.snapshot()?;
load_usage_events(&connection, &query, &config)
}
fn load_usage_events(
connection: &Connection,
query: &UsageQuery,
config: &GuiConfigFile,
) -> Result<UsageEventPage, String> {
let filter = build_usage_filter(query);
let total_sql = format!("SELECT COUNT(*) FROM usage_events{}", filter.clause);
let total = connection
.query_row(&total_sql, params_from_iter(filter.params.iter()), |row| {
row.get::<_, i64>(0)
})
.map(from_sql_i64)
.map_err(|error| format!("统计 SQLite 使用事件失败: {error}"))?
.min(usize::MAX as u64) as usize;
let page_size = query.page_size.unwrap_or(50).clamp(20, 200);
let total_pages = total.div_ceil(page_size).max(1);
let page = query.page.unwrap_or(1).clamp(1, total_pages);
let offset = (page - 1).saturating_mul(page_size);
let sql = format!(
r#"
SELECT
event_key, timestamp, latency_ms, ttft_ms, source, auth_index, failed,
provider, model, alias, reasoning_effort, service_tier,
response_service_tier, executor_type, endpoint, auth_type,
api_key_hash, api_key_display, api_key_remark, request_id,
api_group_key, client_ip, x_forwarded_for, user_agent, generate,
cached_tokens, collector_source,
input_tokens, output_tokens, reasoning_tokens, cache_read_tokens,
cache_creation_tokens, total_tokens, canceled, failure_status,
failure_body
FROM usage_events{}
ORDER BY timestamp_ms DESC, id DESC
LIMIT ? OFFSET ?
"#,
filter.clause
);
let mut values = filter.params;
values.push(SqlValue::Integer(page_size as i64));
values.push(SqlValue::Integer(offset.min(i64::MAX as usize) as i64));
let mut statement = connection
.prepare(&sql)
.map_err(|error| format!("准备 SQLite 使用事件查询失败: {error}"))?;
let mut items = statement
.query_map(params_from_iter(values.iter()), usage_record_from_row)
.map_err(|error| format!("查询 SQLite 使用事件失败: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取 SQLite 使用事件失败: {error}"))?;
for item in &mut items {
item.source_display = usage_source_display(config, &item.provider, &item.source);
}
Ok(UsageEventPage {
items,
total,
page,
page_size,
total_pages,
})
}
fn usage_record_from_row(row: &Row<'_>) -> rusqlite::Result<UsageRecord> {
Ok(UsageRecord {
id: row.get(0)?,
timestamp: row.get(1)?,
latency_ms: from_sql_i64(row.get(2)?),
ttft_ms: row.get::<_, Option<i64>>(3)?.map(from_sql_i64),
source: row.get(4)?,
source_display: String::new(),
auth_index: row.get(5)?,
failed: row.get::<_, i64>(6)? != 0,
canceled: row.get::<_, i64>(33)? != 0,
failure_status: row.get::<_, i64>(34)?.clamp(0, u16::MAX as i64) as u16,
failure_body: row.get(35)?,
provider: row.get(7)?,
api_group_key: row.get(20)?,
model: row.get(8)?,
alias: row.get(9)?,
client_ip: row.get(21)?,
x_forwarded_for: row.get(22)?,
user_agent: row.get(23)?,
reasoning_effort: row.get(10)?,
service_tier: row.get(11)?,
response_service_tier: row.get(12)?,
executor_type: row.get(13)?,
endpoint: row.get(14)?,
auth_type: row.get(15)?,
api_key_hash: row.get(16)?,
api_key_display: row.get(17)?,
api_key_remark: row.get(18)?,
request_id: row.get(19)?,
generate: row.get::<_, i64>(24)? != 0,
cached_tokens: from_sql_i64(row.get(25)?),
collector_source: row.get(26)?,
tokens: UsageTokenStats {
input_tokens: from_sql_i64(row.get(27)?),
output_tokens: from_sql_i64(row.get(28)?),
reasoning_tokens: from_sql_i64(row.get(29)?),
cache_read_tokens: from_sql_i64(row.get(30)?),
cache_creation_tokens: from_sql_i64(row.get(31)?),
total_tokens: from_sql_i64(row.get(32)?),
},
})
}
fn total_usage_records() -> Result<u64, String> {
let connection = open_usage_database()?;
connection
.query_row("SELECT COUNT(*) FROM usage_events", [], |row| {
row.get::<_, i64>(0)
})
.map(from_sql_i64)
.map_err(|error| format!("统计 SQLite 使用记录总数失败: {error}"))
}
fn query_window_minutes(query: &UsageQuery, first: Option<i64>, last: Option<i64>) -> f64 {
let start = query
.start
.as_deref()
.and_then(parse_timestamp_millis)
.or(first)
.unwrap_or(0);
let end = query
.end
.as_deref()
.and_then(parse_timestamp_millis)
.or(last)
.unwrap_or(start);
((end.saturating_sub(start)) as f64 / 60_000.0).max(1.0)
}
fn usage_root_dir() -> Result<PathBuf, String> {
Ok(core_base_dir()?.join(USAGE_DIR_NAME))
}
fn record_local_hour(record: &UsageRecord) -> String {
local_hour_from_timestamp(&record.timestamp)
.unwrap_or_else(|| Local::now().format("%Y-%m-%d-%H").to_string())
}
fn local_hour_from_timestamp(value: &str) -> Option<String> {
DateTime::parse_from_rfc3339(value).ok().map(|timestamp| {
timestamp
.with_timezone(&Local)
.format("%Y-%m-%d-%H")
.to_string()
})
}
fn record_timestamp_millis(record: &UsageRecord) -> i64 {
parse_timestamp_millis(&record.timestamp).unwrap_or(0)
}
fn parse_timestamp_millis(value: &str) -> Option<i64> {
DateTime::parse_from_rfc3339(value)
.ok()
.map(|timestamp| timestamp.timestamp_millis())
}
fn to_sql_i64(value: u64) -> i64 {
value.min(i64::MAX as u64) as i64
}
fn from_sql_i64(value: i64) -> u64 {
value.max(0) as u64
}
fn unique_file_stamp() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
}
fn string_field(object: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
object
.get(key)
.and_then(|value| match value {
Value::String(value) => Some(value.trim().to_string()),
Value::Number(value) => Some(value.to_string()),
_ => None,
})
.filter(|value| !value.is_empty())
}
fn u64_field(object: &serde_json::Map<String, Value>, key: &str) -> u64 {
optional_u64_field(object, key).unwrap_or(0)
}
fn optional_u64_field(object: &serde_json::Map<String, Value>, key: &str) -> Option<u64> {
object.get(key).and_then(|value| {
value.as_u64().or_else(|| {
value
.as_i64()
.map(|number| number.max(0) as u64)
.or_else(|| value.as_str().and_then(|text| text.parse::<u64>().ok()))
})
})
}
fn token_u64(object: Option<&serde_json::Map<String, Value>>, key: &str) -> u64 {
object.map(|object| u64_field(object, key)).unwrap_or(0)
}
fn hash_text(value: &str) -> String {
if value.is_empty() {
return String::new();
}
format!("{:x}", Sha256::digest(value.as_bytes()))
}
fn mask_api_key(value: &str) -> String {
let value = value.trim();
if value.is_empty() {
return String::new();
}
if value.chars().count() <= 8 {
return format!("{}••••", value.chars().take(2).collect::<String>());
}
let start = value.chars().take(4).collect::<String>();
let end = value
.chars()
.rev()
.take(4)
.collect::<String>()
.chars()
.rev()
.collect::<String>();
format!("{start}••••{end}")
}
fn api_key_category_label(remark: String, display: String) -> String {
if !remark.is_empty() {
remark
} else if !display.is_empty() {
mask_api_key(&display)
} else {
"未记录密钥".to_string()
}
}
fn usage_source_display(config: &GuiConfigFile, provider: &str, source: &str) -> String {
let source = source.trim();
if source.is_empty() {
return "未知来源".to_string();
}
if let Some(remark) = config.api_access_remark_for_source(provider, source) {
return remark.to_string();
}
let character_count = source.chars().count();
let looks_like_secret = !source.contains('@')
&& !source.chars().any(char::is_whitespace)
&& (source.starts_with("sk-")
|| source.starts_with("AIza")
|| source.starts_with("key-")
|| character_count >= 48);
if looks_like_secret {
mask_api_key(source)
} else {
source.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collector_total_records_are_cached_and_incremented() {
let state = UsageCollectorState::default();
state.set_total_records(40);
state.increment_total_records(2);
assert_eq!(state.status().unwrap().total_records, 42);
}
fn test_root(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"cpa-gui-usage-{name}-{}-{}",
std::process::id(),
unique_file_stamp()
));
fs::create_dir_all(&root).unwrap();
root
}
#[test]
fn legacy_usage_storage_is_moved_with_nested_files() {
let root = test_root("storage-location-migration");
let source = root.join("legacy").join(USAGE_DIR_NAME);
let target = root.join("application-support").join(USAGE_DIR_NAME);
fs::create_dir_all(source.join(USAGE_BACKUP_DIR_NAME)).unwrap();
fs::write(source.join(USAGE_DATABASE_FILE), b"database").unwrap();
fs::write(
source.join(USAGE_BACKUP_DIR_NAME).join("usage.db.backup"),
b"backup",
)
.unwrap();
migrate_usage_storage_directory(&source, &target).unwrap();
assert!(!source.exists());
assert_eq!(
fs::read(target.join(USAGE_DATABASE_FILE)).unwrap(),
b"database"
);
assert_eq!(
fs::read(target.join(USAGE_BACKUP_DIR_NAME).join("usage.db.backup")).unwrap(),
b"backup"
);
fs::remove_dir_all(root).unwrap();
}
fn open_test_database(root: &Path) -> Connection {
initialize_usage_storage_at(root).unwrap();
open_usage_database_at(root).unwrap()
}
#[test]
fn repairs_legacy_claude_input_tokens_on_demand() {
let root = test_root("claude-input-migration");
let connection = open_test_database(&root);
connection
.execute(
r#"INSERT INTO usage_events (
event_key, timestamp, timestamp_ms, local_hour, provider,
executor_type, input_tokens, output_tokens, cache_read_tokens,
cache_creation_tokens, total_tokens, created_at
) VALUES ('legacy-claude', '2026-08-27T00:00:00Z', 1,
'2026-08-27T00', 'claude', 'ClaudeExecutor',
100, 20, 600, 20, 740, '2026-08-27T00:00:00Z')"#,
[],
)
.unwrap();
drop(connection);
let result = repair_usage_cache_records_at(&root).unwrap();
assert_eq!(result.scanned, 1);
assert_eq!(result.repaired, 1);
assert!(result.backup_path.is_some());
let connection = open_usage_database_at(&root).unwrap();
let row: (i64, i64, i64) = connection
.query_row(
"SELECT input_tokens, total_tokens, cached_tokens FROM usage_events WHERE event_key = 'legacy-claude'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.unwrap();
assert_eq!(row, (720, 740, 620));
drop(connection);
let second = repair_usage_cache_records_at(&root).unwrap();
assert_eq!(second.scanned, 0);
assert_eq!(second.repaired, 0);
let rerun_connection = open_usage_database_at(&root).unwrap();
let rerun_input: i64 = rerun_connection
.query_row(
"SELECT input_tokens FROM usage_events WHERE event_key = 'legacy-claude'",
[],
|row| row.get(0),
)
.unwrap();
drop(rerun_connection);
assert_eq!(rerun_input, 720);
fs::remove_dir_all(root).unwrap();
}
fn create_legacy_v2_database(root: &Path) -> Connection {
fs::create_dir_all(root).unwrap();
let connection = Connection::open(root.join(USAGE_DATABASE_FILE)).unwrap();
connection
.execute_batch(
r#"
CREATE TABLE usage_metadata (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
);
CREATE TABLE usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_key TEXT NOT NULL UNIQUE,
timestamp TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL,
local_hour TEXT NOT NULL,
latency_ms INTEGER NOT NULL DEFAULT 0,
ttft_ms INTEGER,
source TEXT NOT NULL DEFAULT '',
auth_index TEXT NOT NULL DEFAULT '',
failed INTEGER NOT NULL DEFAULT 0,
provider TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
alias TEXT NOT NULL DEFAULT '',
reasoning_effort TEXT NOT NULL DEFAULT '',
service_tier TEXT NOT NULL DEFAULT '',
response_service_tier TEXT NOT NULL DEFAULT '',
executor_type TEXT NOT NULL DEFAULT '',
endpoint TEXT NOT NULL DEFAULT '',
auth_type TEXT NOT NULL DEFAULT '',
api_key_hash TEXT NOT NULL DEFAULT '',
api_key_display TEXT NOT NULL DEFAULT '',
api_key_remark TEXT NOT NULL DEFAULT '',
request_id TEXT NOT NULL DEFAULT '',
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
PRAGMA user_version = 2;
"#,
)
.unwrap();
connection
}
fn sample_record(id: &str, timestamp: &str, model: &str) -> UsageRecord {
UsageRecord {
id: id.to_string(),
timestamp: timestamp.to_string(),
latency_ms: 100,
ttft_ms: Some(20),
source: "source".to_string(),
source_display: String::new(),
auth_index: "auth".to_string(),
failed: false,
canceled: false,
failure_status: 0,
failure_body: String::new(),
provider: "openai".to_string(),
api_group_key: "hash".to_string(),
model: model.to_string(),
alias: String::new(),
client_ip: None,
x_forwarded_for: None,
user_agent: None,
reasoning_effort: "high".to_string(),
service_tier: String::new(),
response_service_tier: String::new(),
executor_type: String::new(),
endpoint: "POST /v1/responses".to_string(),
auth_type: "oauth".to_string(),
api_key_hash: "hash".to_string(),
api_key_display: "12••••".to_string(),
api_key_remark: "内置密钥".to_string(),
request_id: id.to_string(),
generate: true,
cached_tokens: 2,
collector_source: "test".to_string(),
tokens: UsageTokenStats {
input_tokens: 10,
output_tokens: 20,
reasoning_tokens: 5,
cache_read_tokens: 2,
cache_creation_tokens: 0,
total_tokens: 30,
},
}
}
#[test]
fn masks_api_keys_without_exposing_the_full_value() {
assert_eq!(mask_api_key("123456"), "12••••");
assert_eq!(mask_api_key("sk-1234567890"), "sk-1••••7890");
}
#[test]
fn api_key_category_uses_either_remark_or_masked_key() {
assert_eq!(
api_key_category_label("生产环境".to_string(), "12••••".to_string()),
"生产环境"
);
assert_eq!(
api_key_category_label(String::new(), "123456".to_string()),
"12••••"
);
}
#[test]
fn usage_source_prefers_api_access_remark_and_masks_secret_fallbacks() {
let key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
let mut config = GuiConfigFile::default();
config.api_access_remarks.push(crate::GuiApiAccessRemark {
provider_section: "codex-api-key".to_string(),
api_key_hash: hash_text(key),
remark: "生产环境".to_string(),
});
assert_eq!(usage_source_display(&config, "codex", key), "生产环境");
assert_eq!(
usage_source_display(&GuiConfigFile::default(), "codex", key),
"sk-1••••wxyz"
);
assert_eq!(
usage_source_display(&config, "codex", "account@example.com"),
"account@example.com"
);
}
#[test]
fn local_hour_key_uses_year_month_day_and_hour() {
let record = sample_record("id", "2026-07-17T20:30:00+08:00", "model");
assert_eq!(record_local_hour(&record).len(), "2026-07-17-20".len());
}
#[test]
fn normalizes_queue_records_without_persisting_secrets_or_headers() {
let config = GuiConfigFile {
locale: "zh-CN".to_string(),
port: 8317,
allow_lan: false,
host: "127.0.0.1".to_string(),
run_on_startup: false,
start_core_on_launch: true,
silent_start: false,
close_behavior: crate::WindowsCloseBehavior::Ask,
window_width: None,
window_height: None,
auth_dir: String::new(),
api_keys: Vec::new(),
api_access_remarks: Vec::new(),
management_secret_key: "123456".to_string(),
usage_statistics_enabled: true,
plugins_enabled: false,
routing_strategy: "round-robin".to_string(),
proxy_url: String::new(),
download_source: VersionDownloadSource::Github,
custom_download_mirrors: Vec::new(),
active_custom_download_mirror: String::new(),
prefer_gitcode_downloads: false,
routing_session_affinity: false,
routing_session_affinity_ttl: String::new(),
request_retry: crate::DEFAULT_REQUEST_RETRY,
max_retry_credentials: crate::DEFAULT_MAX_RETRY_CREDENTIALS,
max_retry_interval: crate::DEFAULT_MAX_RETRY_INTERVAL,
streaming_bootstrap_retries: crate::DEFAULT_STREAMING_BOOTSTRAP_RETRIES,
};
let record = normalize_usage_record(
serde_json::json!({
"timestamp": "2026-07-17T20:30:00+08:00",
"request_id": "request-1",
"api_key": "secret-client-key",
"response_headers": { "authorization": ["secret-upstream-token"] },
"model": "gpt-test",
"tokens": {
"input_tokens": 10,
"output_tokens": 20,
"cached_tokens": 7,
"cache_read_tokens": 5,
"cache_creation_tokens": 2
}
}),
&config,
)
.unwrap();
let rendered = serde_json::to_string(&record).unwrap();
assert!(!rendered.contains("secret-client-key"));
assert!(!rendered.contains("secret-upstream-token"));
assert_eq!(record.tokens.total_tokens, 30);
assert_eq!(record.tokens.cache_read_tokens, 5);
assert!(!record.api_key_hash.is_empty());
}
#[test]
fn preserves_explicit_cache_read_and_only_backfills_missing_alias() {
let config = GuiConfigFile::default();
let explicit = normalize_usage_record(
serde_json::json!({
"request_id": "explicit-cache-read",
"tokens": {
"input_tokens": 100,
"cached_tokens": 10,
"cache_read_tokens": 5,
"cache_creation_tokens": 2,
"total_tokens": 100
}
}),
&config,
)
.unwrap();
assert_eq!(explicit.tokens.cache_read_tokens, 5);
let legacy = normalize_usage_record(
serde_json::json!({
"request_id": "legacy-cache-alias",
"tokens": {
"input_tokens": 100,
"cached_tokens": 10,
"cache_creation_tokens": 2,
"total_tokens": 100
}
}),
&config,
)
.unwrap();
assert_eq!(legacy.tokens.cache_read_tokens, 10);
}
#[test]
fn normalizes_claude_input_to_include_cache_tokens() {
let record = normalize_usage_record(
serde_json::json!({
"executor_type": "ClaudeExecutor",
"provider": "anthropic",
"request_id": "claude-cache-rate",
"tokens": {
"input_tokens": 100,
"output_tokens": 20,
"cache_read_tokens": 600,
"cache_creation_tokens": 20,
"total_tokens": 120
}
}),
&GuiConfigFile::default(),
)
.unwrap();
assert_eq!(record.tokens.input_tokens, 720);
assert_eq!(record.tokens.total_tokens, 740);
assert_eq!(record.tokens.cache_read_tokens, 600);
}
#[test]
fn keeps_already_inclusive_claude_input_unchanged() {
let record = normalize_usage_record(
serde_json::json!({
"provider": "anthropic",
"request_id": "claude-inclusive",
"tokens": {
"input_tokens": 720,
"output_tokens": 20,
"cache_read_tokens": 600,
"cache_creation_tokens": 20,
"total_tokens": 740
}
}),
&GuiConfigFile::default(),
)
.unwrap();
assert_eq!(record.tokens.input_tokens, 720);
assert_eq!(record.tokens.total_tokens, 740);
}
#[test]
fn enforces_cache_input_invariant_for_unknown_producers() {
let record = normalize_usage_record(
serde_json::json!({
"provider": "custom",
"request_id": "unknown-cache-shape",
"tokens": {
"input_tokens": 100,
"output_tokens": 20,
"cache_read_tokens": 600,
"total_tokens": 120
}
}),
&GuiConfigFile::default(),
)
.unwrap();
assert_eq!(record.tokens.input_tokens, 700);
assert_eq!(record.tokens.total_tokens, 720);
assert!(record.tokens.cache_read_tokens <= record.tokens.input_tokens);
}
#[test]
fn persists_successful_health_checks_and_zero_token_websocket_events() {
let root = test_root("queue-events-without-generation");
initialize_usage_storage_at(&root).unwrap();
let config = GuiConfigFile::default();
let inserted = persist_queue_items(
&root,
vec![
serde_json::json!({
"timestamp": "2026-07-29T10:00:00+08:00",
"request_id": "cherry-health-check",
"generate": false,
"failed": false,
"provider": "openai",
"model": "gpt-test",
"endpoint": "POST /v1/chat/completions",
"tokens": {
"input_tokens": 1,
"output_tokens": 1,
"total_tokens": 2
}
}),
serde_json::json!({
"timestamp": "2026-07-29T10:00:01+08:00",
"request_id": "websocket-zero-token",
"failed": false,
"provider": "codex",
"model": "gpt-test",
"executor_type": "CodexWebsocketsExecutor",
"tokens": {
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0
}
}),
],
&config,
)
.unwrap();
let connection = open_usage_database_at(&root).unwrap();
let events = load_usage_events(
&connection,
&UsageQuery {
failed: Some(false),
..UsageQuery::default()
},
&config,
)
.unwrap();
assert_eq!(inserted, 2);
assert_eq!(events.total, 2);
assert!(events.items.iter().all(|event| !event.failed));
assert!(events
.items
.iter()
.any(|event| event.request_id == "cherry-health-check"));
assert!(events
.items
.iter()
.any(|event| event.request_id == "websocket-zero-token"));
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn persists_failure_details_and_excludes_client_cancellations_from_failures() {
let root = test_root("usage-failure-details");
initialize_usage_storage_at(&root).unwrap();
let config = GuiConfigFile::default();
persist_queue_items(
&root,
vec![
serde_json::json!({
"timestamp": "2026-07-29T10:00:00+08:00",
"request_id": "success",
"failed": false,
"provider": "antigravity",
"model": "gemini-test"
}),
serde_json::json!({
"timestamp": "2026-07-29T10:00:01+08:00",
"request_id": "upstream-failure",
"failed": true,
"provider": "antigravity",
"model": "gemini-test",
"fail": {
"status_code": 429,
"body": { "error": { "message": "quota exhausted" } }
}
}),
serde_json::json!({
"timestamp": "2026-07-29T10:00:02+08:00",
"request_id": "client-canceled",
"failed": true,
"provider": "antigravity",
"model": "gemini-test",
"fail": {
"status_code": 499,
"body": "context canceled"
}
}),
],
&config,
)
.unwrap();
let connection = open_usage_database_at(&root).unwrap();
let overview = load_usage_overview(&connection, &UsageQuery::default()).unwrap();
let failures = load_usage_events(
&connection,
&UsageQuery {
failed: Some(true),
..UsageQuery::default()
},
&config,
)
.unwrap();
let cancellations = load_usage_events(
&connection,
&UsageQuery {
canceled: Some(true),
..UsageQuery::default()
},
&config,
)
.unwrap();
assert_eq!(overview.total_requests, 3);
assert_eq!(overview.success_count, 1);
assert_eq!(overview.failure_count, 1);
assert_eq!(overview.canceled_count, 1);
assert_eq!(overview.success_rate, 50.0);
assert_eq!(failures.total, 1);
assert_eq!(failures.items[0].request_id, "upstream-failure");
assert_eq!(failures.items[0].failure_status, 429);
assert!(failures.items[0].failure_body.contains("quota exhausted"));
assert_eq!(cancellations.total, 1);
assert_eq!(cancellations.items[0].request_id, "client-canceled");
assert!(cancellations.items[0].canceled);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn sqlite_storage_uses_wal_and_reference_busy_timeout() {
let root = test_root("sqlite-pragmas");
let connection = open_test_database(&root);
let journal_mode = connection
.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
.unwrap();
let busy_timeout = connection
.query_row("PRAGMA busy_timeout", [], |row| row.get::<_, i64>(0))
.unwrap();
let foreign_keys = connection
.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))
.unwrap();
assert_eq!(journal_mode.to_ascii_lowercase(), "wal");
assert_eq!(busy_timeout, 5_000);
assert_eq!(foreign_keys, 1);
assert!(root.join(USAGE_DATABASE_FILE).is_file());
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn legacy_v2_database_is_backed_up_and_migrated_once() {
let root = test_root("keeper-v3-migration");
let connection = create_legacy_v2_database(&root);
connection
.execute(
r#"
INSERT INTO usage_events (
event_key, timestamp, timestamp_ms, local_hour, latency_ms, ttft_ms,
source, auth_index, failed, provider, model, alias, reasoning_effort,
service_tier, response_service_tier, executor_type, endpoint, auth_type,
api_key_hash, api_key_display, api_key_remark, request_id,
input_tokens, output_tokens, reasoning_tokens, cache_read_tokens,
cache_creation_tokens, total_tokens, created_at
) VALUES (
'request-1', '2026-07-17T20:30:00+08:00', 1784291400000,
'2026-07-17-20', 100, 20, 'source', 'auth', 0, 'openai',
'gpt-test', 'alias-test', 'high', '', '', '',
'POST /v1/responses', 'oauth', 'legacy-hash', '12••••',
'旧密钥', 'request-1', 10, 20, 5, 2, 3, 30,
'2026-07-17T20:30:01+08:00'
)
"#,
[],
)
.unwrap();
drop(connection);
initialize_usage_storage_at(&root).unwrap();
initialize_usage_storage_at(&root).unwrap();
let connection = open_usage_database_at(&root).unwrap();
let user_version = connection
.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))
.unwrap();
let migrated = connection
.query_row(
r#"
SELECT COUNT(*), SUM(total_tokens), api_group_key, model_alias,
cached_tokens, collector_source
FROM usage_events
"#,
[],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, i64>(4)?,
row.get::<_, String>(5)?,
))
},
)
.unwrap();
let marker = connection
.query_row(
"SELECT value FROM usage_metadata WHERE key = ?1",
params![USAGE_DATABASE_MIGRATION_KEY],
|row| row.get::<_, String>(0),
)
.unwrap();
let backups = fs::read_dir(root.join(USAGE_BACKUP_DIR_NAME))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(user_version, USAGE_DATABASE_SCHEMA_VERSION);
assert_eq!(migrated.0, 1);
assert_eq!(migrated.1, 30);
assert_eq!(migrated.2, "legacy-hash");
assert_eq!(migrated.3.as_deref(), Some("alias-test"));
assert_eq!(migrated.4, 5);
assert_eq!(migrated.5, "legacy_migration");
assert!(!marker.is_empty());
assert_eq!(backups.len(), 1);
let duplicate_inserted = connection
.execute(
r#"
INSERT INTO usage_events (
event_key, timestamp, timestamp_ms, local_hour, created_at
) VALUES (
'request-1', '2026-07-17T20:31:00+08:00', 1784291460000,
'2026-07-17-20', '2026-07-17T20:31:01+08:00'
)
"#,
[],
)
.unwrap();
assert_eq!(duplicate_inserted, 1);
let duplicate_count = connection
.query_row(
"SELECT COUNT(*) FROM usage_events WHERE event_key = 'request-1'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap();
assert_eq!(duplicate_count, 2);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn failure_detail_migration_backfills_processed_inbox_rows_once() {
let root = test_root("failure-detail-v4-migration");
let mut connection = open_test_database(&root);
let mut record = sample_record(
"legacy-canceled",
"2026-07-17T20:30:00+08:00",
"gemini-test",
);
record.failed = true;
insert_usage_records(&mut connection, &[record]).unwrap();
connection
.execute(
r#"
INSERT INTO usage_inbox (
source, message_hash, raw_message, status, attempt_count,
usage_event_key, received_at, processed_at, created_at, updated_at
) VALUES (
'test', 'legacy-canceled-hash', ?1, 'processed', 1,
'legacy-canceled', ?2, ?2, ?2, ?2
)
"#,
params![
serde_json::json!({
"request_id": "legacy-canceled",
"failed": true,
"fail": {
"status_code": 499,
"body": "client closed request"
}
})
.to_string(),
Local::now().to_rfc3339(),
],
)
.unwrap();
connection
.execute(
"DELETE FROM usage_metadata WHERE key = ?1",
params![USAGE_FAILURE_MIGRATION_KEY],
)
.unwrap();
drop(connection);
initialize_usage_storage_at(&root).unwrap();
initialize_usage_storage_at(&root).unwrap();
let connection = open_usage_database_at(&root).unwrap();
let migrated = connection
.query_row(
r#"
SELECT canceled, failure_status, failure_body
FROM usage_events
WHERE event_key = 'legacy-canceled'
"#,
[],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
))
},
)
.unwrap();
let markers = connection
.query_row(
"SELECT COUNT(*) FROM usage_metadata WHERE key = ?1",
params![USAGE_FAILURE_MIGRATION_KEY],
|row| row.get::<_, i64>(0),
)
.unwrap();
assert_eq!(migrated.0, 1);
assert_eq!(migrated.1, 499);
assert_eq!(migrated.2, "client closed request");
assert_eq!(markers, 1);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn durable_inbox_recovers_valid_rows_and_isolates_malformed_rows() {
let root = test_root("durable-inbox");
initialize_usage_storage_at(&root).unwrap();
let mut connection = open_usage_database_at(&root).unwrap();
enqueue_usage_queue_items(
&mut connection,
"http_pull",
vec![
serde_json::json!({
"timestamp": "2026-07-17T20:30:00+08:00",
"request_id": "request-1",
"model": "gpt-test",
"tokens": { "input_tokens": 10, "output_tokens": 20 }
}),
serde_json::json!("not-an-object"),
],
)
.unwrap();
drop(connection);
let mut connection = open_usage_database_at(&root).unwrap();
let inserted = process_usage_inbox(&mut connection, &GuiConfigFile::default()).unwrap();
let event_count = connection
.query_row("SELECT COUNT(*) FROM usage_events", [], |row| {
row.get::<_, i64>(0)
})
.unwrap();
let processed_count = connection
.query_row(
"SELECT COUNT(*) FROM usage_inbox WHERE status = 'processed'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap();
let failed_count = connection
.query_row(
"SELECT COUNT(*) FROM usage_inbox WHERE status = 'decode_failed'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap();
assert_eq!(inserted, 1);
assert_eq!(event_count, 1);
assert_eq!(processed_count, 1);
assert_eq!(failed_count, 1);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn durable_inbox_preserves_multiple_events_with_same_request_id() {
let root = test_root("durable-inbox-duplicate-request-id");
initialize_usage_storage_at(&root).unwrap();
let mut connection = open_usage_database_at(&root).unwrap();
enqueue_usage_queue_items(
&mut connection,
"redis_subscribe:usage",
vec![
serde_json::json!({
"timestamp": "2026-07-17T20:30:00+08:00",
"request_id": "persistent-connection",
"model": "gpt-first",
"tokens": { "input_tokens": 10, "output_tokens": 20, "total_tokens": 30 }
}),
serde_json::json!({
"timestamp": "2026-07-17T20:31:00+08:00",
"request_id": "persistent-connection",
"model": "gpt-second",
"tokens": { "input_tokens": 30, "output_tokens": 40, "total_tokens": 70 }
}),
],
)
.unwrap();
let inserted = process_usage_inbox(&mut connection, &GuiConfigFile::default()).unwrap();
assert_eq!(inserted, 2);
let event_count = connection
.query_row("SELECT COUNT(*) FROM usage_events", [], |row| {
row.get::<_, i64>(0)
})
.unwrap();
assert_eq!(event_count, 2);
let rows = {
let mut statement = connection
.prepare("SELECT event_key, model, total_tokens FROM usage_events ORDER BY id")
.unwrap();
statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
))
})
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
};
assert_eq!(
rows,
vec![
(
"persistent-connection".to_string(),
"gpt-first".to_string(),
30
),
(
"persistent-connection".to_string(),
"gpt-second".to_string(),
70
),
]
);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn inbox_cleanup_preserves_pending_and_recent_failure_rows() {
let root = test_root("inbox-cleanup");
let connection = open_test_database(&root);
let now = Local::now();
let old_processed = (now - chrono::Duration::days(1)).to_rfc3339();
let recent_failure = (now - chrono::Duration::days(1)).to_rfc3339();
let old_failure = (now - chrono::Duration::days(8)).to_rfc3339();
for (status, processed_at, updated_at) in [
(
"processed",
Some(old_processed.as_str()),
old_processed.as_str(),
),
("pending", None, old_failure.as_str()),
(
"decode_failed",
Some(recent_failure.as_str()),
recent_failure.as_str(),
),
(
"discarded",
Some(old_failure.as_str()),
old_failure.as_str(),
),
] {
connection
.execute(
r#"
INSERT INTO usage_inbox (
source, message_hash, raw_message, status, attempt_count,
received_at, processed_at, created_at, updated_at
) VALUES ('test', 'hash', '{}', ?1, 0, ?3, ?2, ?3, ?3)
"#,
params![status, processed_at, updated_at],
)
.unwrap();
}
cleanup_usage_inbox(&connection, now).unwrap();
let statuses = {
let mut statement = connection
.prepare("SELECT status FROM usage_inbox ORDER BY id")
.unwrap();
statement
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
};
assert_eq!(statuses, vec!["pending", "decode_failed"]);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn sqlite_storage_preserves_duplicate_event_keys_transactionally() {
let root = test_root("sqlite-duplicate-event-keys");
let mut connection = open_test_database(&root);
let first = sample_record("request-1", "2026-07-17T20:30:00+08:00", "gpt-test");
let second = sample_record("request-1", "2026-07-17T20:31:00+08:00", "gpt-test-updated");
assert_eq!(
insert_usage_records(&mut connection, std::slice::from_ref(&first)).unwrap(),
1
);
assert_eq!(insert_usage_records(&mut connection, &[second]).unwrap(), 1);
let count = connection
.query_row("SELECT COUNT(*) FROM usage_events", [], |row| {
row.get::<_, i64>(0)
})
.unwrap();
assert_eq!(count, 2);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn current_v3_database_migrates_unique_event_key_without_losing_rows() {
let root = test_root("event-key-v5-migration");
let mut connection = open_test_database(&root);
let table_sql = connection
.query_row(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'usage_events'",
[],
|row| row.get::<_, String>(0),
)
.unwrap();
let indexes = {
let mut statement = connection
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'usage_events'")
.unwrap();
statement
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
};
for index in indexes {
connection
.execute(
&format!("DROP INDEX {}", quote_sqlite_identifier(&index)),
[],
)
.unwrap();
}
connection
.execute(
"ALTER TABLE usage_events RENAME TO usage_events_previous",
[],
)
.unwrap();
let unique_table_sql = replace_sql_fragment_case_insensitive(
&table_sql,
"event_key TEXT NOT NULL,",
"event_key TEXT NOT NULL UNIQUE,",
)
.unwrap();
connection.execute_batch(&unique_table_sql).unwrap();
connection
.execute("DROP TABLE usage_events_previous", [])
.unwrap();
migrate_usage_database(&mut connection, &root).unwrap();
connection
.execute(
"INSERT INTO usage_events (event_key, timestamp, timestamp_ms, local_hour, created_at) VALUES ('persistent', '2026-07-17T20:30:00+08:00', 1, '2026-07-17-20', '2026-07-17T20:30:00+08:00'), ('persistent', '2026-07-17T20:31:00+08:00', 2, '2026-07-17-20', '2026-07-17T20:31:00+08:00')",
[],
)
.unwrap();
let count = connection
.query_row(
"SELECT COUNT(*) FROM usage_events WHERE event_key = 'persistent'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap();
assert_eq!(count, 2);
assert_eq!(
connection
.query_row(
"SELECT COUNT(*) FROM usage_metadata WHERE key = ?1",
params![USAGE_EVENT_KEY_MIGRATION_KEY],
|row| row.get::<_, i64>(0),
)
.unwrap(),
1
);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn legacy_hour_and_inbox_json_are_migrated_once() {
let root = test_root("legacy-migration");
let events_dir = root.join(LEGACY_USAGE_EVENTS_DIR);
let inbox_dir = root.join(LEGACY_USAGE_INBOX_DIR);
fs::create_dir_all(&events_dir).unwrap();
fs::create_dir_all(&inbox_dir).unwrap();
let first = sample_record("request-1", "2026-07-17T20:30:00+08:00", "gpt-a");
let second = sample_record("request-2", "2026-07-17T20:31:00+08:00", "gpt-b");
fs::write(
events_dir.join("2026-07-17-20.json"),
serde_json::to_vec(&serde_json::json!({
"schemaVersion": USAGE_SCHEMA_VERSION,
"hour": "2026-07-17-20",
"timezone": "+08:00",
"records": [first]
}))
.unwrap(),
)
.unwrap();
fs::write(
inbox_dir.join("pending.json"),
serde_json::to_vec(&serde_json::json!({
"schemaVersion": USAGE_SCHEMA_VERSION,
"records": [second]
}))
.unwrap(),
)
.unwrap();
initialize_usage_storage_at(&root).unwrap();
initialize_usage_storage_at(&root).unwrap();
let connection = open_usage_database_at(&root).unwrap();
let count = connection
.query_row("SELECT COUNT(*) FROM usage_events", [], |row| {
row.get::<_, i64>(0)
})
.unwrap();
let marker = connection
.query_row(
"SELECT value FROM usage_metadata WHERE key = ?1",
params![LEGACY_JSON_MIGRATION_KEY],
|row| row.get::<_, String>(0),
)
.unwrap();
assert_eq!(count, 2);
assert_eq!(marker, "2");
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn sqlite_queries_filter_aggregate_and_paginate() {
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-5.6-terra");
failed.failed = true;
insert_usage_records(&mut connection, &[success, failed]).unwrap();
let query = UsageQuery {
model: Some("GPT-5.6-TERRA".to_string()),
failed: Some(true),
page_size: Some(20),
..UsageQuery::default()
};
let overview = load_usage_overview(&connection, &query).unwrap();
let config = GuiConfigFile::default();
let analysis = load_usage_analysis(&connection, &query, &config).unwrap();
let events = load_usage_events(&connection, &query, &config).unwrap();
assert_eq!(overview.total_requests, 1);
assert_eq!(overview.failure_count, 1);
assert_eq!(overview.tps, 0.0);
assert_eq!(overview.tps_sample_count, 0);
assert_eq!(overview.cache_hit_rate, 0.2);
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 overview_tps_uses_weighted_generation_time_and_ignores_invalid_records() {
let root = test_root("tps-overview");
let mut connection = open_test_database(&root);
let mut first = sample_record("tps-1", "2026-07-17T20:30:00+08:00", "gpt-a");
first.latency_ms = 1_000;
first.ttft_ms = Some(200);
first.tokens.output_tokens = 80;
let mut second = sample_record("tps-2", "2026-07-17T20:31:00+08:00", "gpt-a");
second.latency_ms = 2_000;
second.ttft_ms = Some(1_000);
second.tokens.output_tokens = 20;
let mut missing_ttft = sample_record("tps-3", "2026-07-17T20:32:00+08:00", "gpt-a");
missing_ttft.latency_ms = 500;
missing_ttft.ttft_ms = None;
missing_ttft.tokens.output_tokens = 100;
let mut equal_latency = sample_record("tps-4", "2026-07-17T20:33:00+08:00", "gpt-a");
equal_latency.latency_ms = 500;
equal_latency.ttft_ms = Some(500);
equal_latency.tokens.output_tokens = 100;
let mut failed = sample_record("tps-5", "2026-07-17T20:34:00+08:00", "gpt-a");
failed.failed = true;
failed.tokens.output_tokens = 100;
let mut canceled = sample_record("tps-6", "2026-07-17T20:35:00+08:00", "gpt-a");
canceled.canceled = true;
canceled.tokens.output_tokens = 100;
let mut non_generation = sample_record("tps-7", "2026-07-17T20:36:00+08:00", "gpt-a");
non_generation.generate = false;
non_generation.tokens.output_tokens = 100;
insert_usage_records(
&mut connection,
&[
first,
second,
missing_ttft,
equal_latency,
failed,
canceled,
non_generation,
],
)
.unwrap();
let overview = load_usage_overview(&connection, &UsageQuery::default()).unwrap();
assert!((overview.tps - (100.0 * 1_000.0 / 1_800.0)).abs() < f64::EPSILON);
assert_eq!(overview.tps_sample_count, 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();
}
}